From 93477f2b1b9cd86b28219e5df2668f5a9e4ad2f7 Mon Sep 17 00:00:00 2001 From: suzmii Date: Sun, 30 Aug 2026 14:21:18 +0800 Subject: [PATCH 01/43] =?UTF-8?q?=E9=87=8D=E6=95=B4AGC=E5=AE=98=E6=96=B9?= =?UTF-8?q?=E8=B7=AF=E7=94=B1=E4=B8=8E=E5=AE=89=E5=85=A8=E6=96=87=E4=BB=B6?= =?UTF-8?q?=E9=93=BE=E8=B7=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 记录当前 AGC 客户端、Tauri、API Server、后台查询和契约改动 保留后续按新 Router 后端方案重构的检查点 --- apps/admin-web/src/api/adminApiClient.ts | 35 + apps/admin-web/src/api/adminApiTypes.ts | 48 + .../pages/AdminDatabaseTablesPage.test.tsx | 73 +- .../src/pages/AdminDatabaseTablesPage.tsx | 487 +++- .../src-tauri/src/agent/codex_app_server.rs | 207 +- .../src-tauri/src/agent/codex_cli.rs | 9 +- .../src-tauri/src/agent/direct_runtime.rs | 53 +- .../src-tauri/src/agent/direct_tool_bridge.rs | 11 +- .../src-tauri/src/agent/direct_tools_mcp.rs | 3 +- .../src-tauri/src/agent/generation.rs | 3 +- .../src/agent/generation/canvas_generation.rs | 102 +- .../src/agent/generation/draft_writer.rs | 75 +- .../agent/generation/loop_orchestration.rs | 43 +- .../src/agent/generation/pass_artifacts.rs | 15 +- .../src/agent/generation/role_briefs.rs | 27 +- .../src/agent/generation/run_lifecycle.rs | 22 +- .../src-tauri/src/agent/generation/trace.rs | 31 +- .../runtime_actions/autonomous_policy.rs | 16 +- .../pending_confirmation_ledger.rs | 20 +- .../agent/runtime_actions/project_gates.rs | 6 +- .../runtime_actions/provider_action_batch.rs | 16 +- .../provider_request_builders.rs | 48 +- .../runtime_actions/provider_tool_plan.rs | 28 +- .../src/agent/runtime_driver/finalization.rs | 89 +- .../src/agent/runtime_driver/main_loop.rs | 307 +-- .../agent/runtime_driver/provider_recovery.rs | 43 +- .../src/agent/runtime_driver/recovery_scan.rs | 27 +- .../src/agent/runtime_driver/task_start.rs | 41 +- .../runtime_protocol/autonomous_completion.rs | 26 +- .../agent/runtime_protocol/context_bundle.rs | 1 + .../agent/runtime_protocol/json_sidecar.rs | 42 +- .../runtime_protocol/planning_storage.rs | 12 + .../runtime_protocol/provider_control.rs | 23 +- .../runtime_protocol/real_e2e_checkpoint.rs | 6 +- .../src/agent/runtime_protocol/steering.rs | 15 +- .../src-tauri/src/agent/runtime_state.rs | 101 +- .../src-tauri/src/agent/skill_pack.rs | 19 +- .../src-tauri/src/assets.rs | 206 +- .../src-tauri/src/browser/evidence.rs | 21 +- .../src-tauri/src/cli.rs | 95 +- .../src-tauri/src/command_exec.rs | 53 +- .../src-tauri/src/command_output.rs | 29 +- .../src-tauri/src/commands.rs | 178 +- .../src-tauri/src/config.rs | 2357 ++++++++++++++++- .../src-tauri/src/debug/debug_drafts.rs | 22 +- .../src-tauri/src/image_inspect.rs | 8 +- .../src-tauri/src/main.rs | 19 +- .../src-tauri/src/platform_session.rs | 27 +- .../src-tauri/src/preview.rs | 3 +- .../src-tauri/src/project/agent_db.rs | 194 +- .../src-tauri/src/project/asset_canvas.rs | 80 +- .../src-tauri/src/project/checkpoint.rs | 88 +- .../src-tauri/src/project/conversation.rs | 146 +- .../src-tauri/src/project/export.rs | 30 +- .../src/project/external_editor_bindings.rs | 6 + .../src-tauri/src/project/filesystem.rs | 89 +- .../src-tauri/src/project/manifest.rs | 120 +- .../src-tauri/src/project/memory.rs | 32 +- .../src-tauri/src/project/resource_editor.rs | 71 +- .../src-tauri/src/project/resource_layout.rs | 8 +- .../src-tauri/src/project/verification.rs | 36 +- .../src-tauri/src/resource_inspect.rs | 8 +- .../src-tauri/src/runner.rs | 6 +- .../src-tauri/src/runner/endpoint.rs | 92 +- .../src-tauri/src/runner/project_owner.rs | 29 +- .../src-tauri/src/tests/configuration.rs | 137 +- .../src-tauri/src/ui_editor/persistence.rs | 24 +- .../src/ui_editor/resource_bridge.rs | 17 +- .../src-tauri/src/ui_editor/workflow.rs | 80 +- apps/ai-game-creator-shell/src/App.tsx | 5 +- apps/ai-game-creator-shell/src/app/types.ts | 6 +- .../app-shell/useDeveloperAgentPanel.ts | 6 +- .../project-summary/agentPresentation.ts | 10 +- .../runtime-config/RuntimeConfigDialog.tsx | 770 +----- .../appSurface/project-commands.suite.ts | 116 +- .../assert-project-and-design-shortcuts.ts | 17 +- .../preview-shortcuts/invoke-mock.ts | 45 +- .../appSurface/runtime-settings.suite.ts | 290 +- .../shared-memory/decision-log.md | 9 + ...】server-rs与SpacetimeDB数据契约-2026-05-15.md | 3 +- ...构】外部OpenAPI与APIKey接入方案-2026-06-19.md | 17 + server-rs/crates/api-server/src/admin.rs | 632 ++++- server-rs/crates/api-server/src/config.rs | 86 + .../api-server/src/external_api_keys.rs | 676 +++++ server-rs/crates/api-server/src/llm/mod.rs | 474 +++- .../crates/api-server/src/modules/admin.rs | 18 +- .../crates/api-server/src/modules/platform.rs | 9 +- .../crates/api-server/src/modules/profile.rs | 12 +- .../crates/api-server/src/password_entry.rs | 10 + server-rs/crates/api-server/src/phone_auth.rs | 10 + server-rs/crates/api-server/src/tracking.rs | 1 + .../crates/api-server/src/wechat/auth.rs | 25 + .../crates/shared-contracts/src/admin.rs | 51 + .../src/active/mapper/external_api_key.rs | 47 +- .../src/mapper/external_api_key.rs | 47 +- ...llet_refund_outbox_and_return_procedure.rs | 16 +- .../external_api_key_create_input_type.rs | 5 + .../external_api_key_snapshot_type.rs | 5 + .../module_bindings/external_api_key_type.rs | 18 + ...llet_refund_outbox_and_return_procedure.rs | 16 +- ...ration_job_history_and_return_procedure.rs | 16 +- .../validate_auth_session_procedure.rs | 16 +- .../src/external_api_key_storage.rs | 53 + .../crates/spacetime-module/src/migration.rs | 20 + 104 files changed, 7715 insertions(+), 2283 deletions(-) diff --git a/apps/admin-web/src/api/adminApiClient.ts b/apps/admin-web/src/api/adminApiClient.ts index 9912b9cf7..d2e5fa292 100644 --- a/apps/admin-web/src/api/adminApiClient.ts +++ b/apps/admin-web/src/api/adminApiClient.ts @@ -23,6 +23,8 @@ import type { AdminEditorShowcaseListQuery, AdminEditorShowcaseListResponse, AdminEditorShowcaseReviewRequest, + AdminExternalApiKeyListQuery, + AdminExternalApiKeyListResponse, AdminFeatureGateConfigResponse, AdminLoginResponse, AdminMeResponse, @@ -246,6 +248,16 @@ export function getAdminDatabaseTableRows( ); } +export function getAdminExternalApiKeys( + token: string, + query: AdminExternalApiKeyListQuery = {}, +) { + return request( + `/admin/api/external-api-keys${buildExternalApiKeyQuery(query)}`, + { token }, + ); +} + export function debugAdminHttp(token: string, payload: AdminDebugHttpRequest) { return request('/admin/api/debug/http', { method: 'POST', @@ -861,6 +873,29 @@ function buildDatabaseTableRowsQuery(query: AdminDatabaseTableRowsQuery) { return queryString ? `?${queryString}` : ''; } +function buildExternalApiKeyQuery(query: AdminExternalApiKeyListQuery) { + const params = new URLSearchParams(); + appendQueryParam(params, 'ownerUserId', query.ownerUserId); + appendQueryParam(params, 'publicUserCode', query.publicUserCode); + appendQueryParam(params, 'keyId', query.keyId); + appendQueryParam(params, 'name', query.name); + appendQueryParam(params, 'keyPrefix', query.keyPrefix); + appendQueryParam(params, 'createdAfter', query.createdAfter); + appendQueryParam(params, 'createdBefore', query.createdBefore); + appendQueryParam(params, 'status', query.status); + appendQueryParam(params, 'purpose', query.purpose); + if (typeof query.limit === 'number' && Number.isFinite(query.limit)) { + params.set('limit', String(Math.floor(query.limit))); + } + if (typeof query.offset === 'number' && Number.isFinite(query.offset)) { + params.set('offset', String(Math.floor(query.offset))); + } + appendQueryParam(params, 'sortColumn', query.sortColumn); + appendQueryParam(params, 'sortDirection', query.sortDirection); + const queryString = params.toString(); + return queryString ? `?${queryString}` : ''; +} + function buildEditorAssetListQuery(query: AdminEditorAssetListQuery) { const params = new URLSearchParams(); appendQueryParam(params, 'cursor', query.cursor); diff --git a/apps/admin-web/src/api/adminApiTypes.ts b/apps/admin-web/src/api/adminApiTypes.ts index bcd2ee1db..765278ed7 100644 --- a/apps/admin-web/src/api/adminApiTypes.ts +++ b/apps/admin-web/src/api/adminApiTypes.ts @@ -243,6 +243,54 @@ export interface AdminDatabaseTableStatPayload { errorMessage: string | null; } +export interface AdminExternalApiKeyListQuery { + ownerUserId?: string; + publicUserCode?: string; + keyId?: string; + name?: string; + keyPrefix?: string; + createdAfter?: string; + createdBefore?: string; + status?: 'active' | 'revoked'; + purpose?: 'external-editor' | 'agc-llm'; + limit?: number; + offset?: number; + sortColumn?: + | 'keyId' + | 'ownerUserId' + | 'name' + | 'keyPrefix' + | 'purpose' + | 'createdAt' + | 'lastUsedAt' + | 'updatedAt'; + sortDirection?: 'asc' | 'desc'; +} + +export interface AdminExternalApiKeyPayload { + keyId: string; + ownerUserId: string; + name: string; + keyPrefix: string; + purpose: string; + scopes: string[]; + createdAt: string; + lastUsedAt: string | null; + revokedAt: string | null; + updatedAt: string; + status: 'active' | 'revoked'; +} + +export interface AdminExternalApiKeyListResponse { + keys: AdminExternalApiKeyPayload[]; + total: number; + limit: number; + offset: number; + scannedCount: number; + scanLimit: number; + scanLimitReached: boolean; +} + export interface AdminDebugHeaderInput { name: string; value: string; diff --git a/apps/admin-web/src/pages/AdminDatabaseTablesPage.test.tsx b/apps/admin-web/src/pages/AdminDatabaseTablesPage.test.tsx index d6bf2e71f..89561b438 100644 --- a/apps/admin-web/src/pages/AdminDatabaseTablesPage.test.tsx +++ b/apps/admin-web/src/pages/AdminDatabaseTablesPage.test.tsx @@ -7,6 +7,7 @@ import { beforeEach, expect, test, vi } from 'vitest'; import { getAdminDatabaseTableRows, getAdminDatabaseTables, + getAdminExternalApiKeys, } from '../api/adminApiClient'; import { AdminDatabaseTablesPage, @@ -19,11 +20,15 @@ vi.mock('../api/adminApiClient', () => ({ ), getAdminDatabaseTableRows: vi.fn(), getAdminDatabaseTables: vi.fn(), + getAdminExternalApiKeys: vi.fn(), isAdminApiError: vi.fn(() => false), })); vi.mock('../components/AdminUserReferenceButton', () => ({ - AdminUserReferenceButton: ({ userId, publicUserCode }: { + AdminUserReferenceButton: ({ + userId, + publicUserCode, + }: { userId?: string; publicUserCode?: string; }) => ( @@ -69,6 +74,7 @@ const referralRows = [ beforeEach(() => { window.location.hash = '#tables?table=profile_referral_relation'; + vi.mocked(getAdminExternalApiKeys).mockReset(); vi.mocked(getAdminDatabaseTables).mockResolvedValue({ fetchErrors: [], tables: ['profile_referral_relation'], @@ -87,6 +93,56 @@ beforeEach(() => { }); }); +test('external_api_key 使用专用安全查询且详情不展示原始 JSON', async () => { + const user = userEvent.setup(); + window.location.hash = '#tables?table=external_api_key'; + vi.mocked(getAdminDatabaseTables).mockResolvedValue({ + fetchErrors: [], + tables: ['external_api_key'], + }); + vi.mocked(getAdminExternalApiKeys).mockResolvedValue({ + keys: [ + { + keyId: 'external-api-key-1', + ownerUserId: 'user-1', + name: '陶泥儿 AGC 官方 LLM(本机)', + keyPrefix: 'tnr_sk_fixture', + purpose: 'agc-llm', + scopes: ['llm:responses'], + createdAt: '2026-08-29T00:00:00Z', + lastUsedAt: null, + revokedAt: null, + updatedAt: '2026-08-29T00:00:00Z', + status: 'active', + }, + ], + total: 1, + limit: 100, + offset: 0, + scannedCount: 1, + scanLimit: 5000, + scanLimitReached: false, + }); + + render( + , + ); + + await user.type(screen.getByPlaceholderText('精确 ownerUserId'), 'user-1'); + await user.click(screen.getByRole('button', { name: '安全查询' })); + await waitFor(() => { + expect(getAdminExternalApiKeys).toHaveBeenLastCalledWith( + 'admin-token', + expect.objectContaining({ ownerUserId: 'user-1', purpose: undefined }), + ); + }); + expect(await screen.findByText('tnr_sk_fixture')).toBeTruthy(); + await user.click(screen.getByRole('button', { name: '详情' })); + expect(screen.getByRole('dialog')).toBeTruthy(); + expect(screen.queryByText('复制 JSON')).toBeNull(); + expect(screen.queryByText('key_hash')).toBeNull(); +}); + test('后台表查询页通过页面级固定栏翻页并提示扫描结果可能不完整', async () => { const user = userEvent.setup(); vi.mocked(getAdminDatabaseTableRows).mockResolvedValue({ @@ -124,7 +180,10 @@ test('后台表查询页通过页面级固定栏翻页并提示扫描结果可 ), ).toBeTruthy(); - await user.type(screen.getByRole('textbox', { name: '关键词' }), '未执行条件'); + await user.type( + screen.getByRole('textbox', { name: '关键词' }), + '未执行条件', + ); await user.click(screen.getByRole('button', { name: '下一页' })); await waitFor(() => { @@ -227,7 +286,9 @@ test('数据库用户字段显示查看按钮且点击不会打开行详情', as , ); - const userButton = await screen.findByRole('button', { name: '查看用户 u-b' }); + const userButton = await screen.findByRole('button', { + name: '查看用户 u-b', + }); await user.click(userButton); expect(screen.queryByRole('dialog')).toBeNull(); }); @@ -250,7 +311,11 @@ test('数据库用户字段识别会排除后台操作者与合成邀请码字 resolveAdminDatabaseUserReference('audit_log', 'admin_user_id', 'u-1'), ).toBeNull(); expect( - resolveAdminDatabaseUserReference('profile_wallet', 'user_id', 'admin:root'), + resolveAdminDatabaseUserReference( + 'profile_wallet', + 'user_id', + 'admin:root', + ), ).toBeNull(); expect( resolveAdminDatabaseUserReference('profile_invite_code', 'user_id', 'u-1'), diff --git a/apps/admin-web/src/pages/AdminDatabaseTablesPage.tsx b/apps/admin-web/src/pages/AdminDatabaseTablesPage.tsx index f0d114373..0629b4f27 100644 --- a/apps/admin-web/src/pages/AdminDatabaseTablesPage.tsx +++ b/apps/admin-web/src/pages/AdminDatabaseTablesPage.tsx @@ -21,10 +21,12 @@ import { import { getAdminDatabaseTableRows, getAdminDatabaseTables, + getAdminExternalApiKeys, } from '../api/adminApiClient'; import type { AdminDatabaseTableRowPayload, AdminDatabaseTableRowsResponse, + AdminExternalApiKeyListResponse, } from '../api/adminApiTypes'; import { AdminUserReferenceButton } from '../components/AdminUserReferenceButton'; import { handlePageError } from './pageUtils'; @@ -35,6 +37,16 @@ interface AdminDatabaseTablesPageProps { } type SortDirection = 'asc' | 'desc'; +type ExternalApiKeySortColumn = + | '' + | 'keyId' + | 'ownerUserId' + | 'name' + | 'keyPrefix' + | 'purpose' + | 'createdAt' + | 'lastUsedAt' + | 'updatedAt'; export function AdminDatabaseTablesPage({ token, @@ -64,6 +76,7 @@ export function AdminDatabaseTablesPage({ const [sortDirection, setSortDirection] = useState('asc'); const [isLoadingTables, setIsLoadingTables] = useState(false); const [isLoadingRows, setIsLoadingRows] = useState(false); + const isExternalApiKeyTable = tableName === 'external_api_key'; useEffect(() => { void loadTables(); @@ -93,7 +106,7 @@ export function AdminDatabaseTablesPage({ }, [tableName, tables]); useEffect(() => { - if (tableName) { + if (tableName && tableName !== 'external_api_key') { void refreshRows(tableName); } // eslint-disable-next-line react-hooks/exhaustive-deps @@ -312,6 +325,50 @@ export function AdminDatabaseTablesPage({ } } + if (isExternalApiKeyTable) { + return ( +
+
+
+

表查询

+

external_api_key 使用专用安全查询

+
+
+ + +
+
+ +
+ ); + } + return (
void; +}) { + const [ownerUserId, setOwnerUserId] = useState(''); + const [publicUserCode, setPublicUserCode] = useState(''); + const [keyId, setKeyId] = useState(''); + const [name, setName] = useState(''); + const [keyPrefix, setKeyPrefix] = useState(''); + const [createdAfter, setCreatedAfter] = useState(''); + const [createdBefore, setCreatedBefore] = useState(''); + const [status, setStatus] = useState<'' | 'active' | 'revoked'>(''); + const [purpose, setPurpose] = useState<'' | 'external-editor' | 'agc-llm'>( + '', + ); + const [limit, setLimit] = useState('100'); + const [sortColumn, setSortColumn] = useState(''); + const [sortDirection, setSortDirection] = useState<'asc' | 'desc'>('desc'); + const [result, setResult] = useState( + null, + ); + const [selectedKey, setSelectedKey] = useState< + AdminExternalApiKeyListResponse['keys'][number] | null + >(null); + const [errorMessage, setErrorMessage] = useState(''); + const [isLoading, setIsLoading] = useState(false); + + async function submit(event?: FormEvent, nextOffset = 0) { + event?.preventDefault(); + const normalizedOwner = ownerUserId.trim(); + const normalizedPublicCode = publicUserCode.trim(); + const normalizedKeyId = keyId.trim(); + const normalizedPrefix = keyPrefix.trim(); + if ( + !normalizedOwner && + !normalizedPublicCode && + !normalizedKeyId && + !normalizedPrefix + ) { + setErrorMessage( + '请提供 owner、公开用户编号、keyId 或精确 Key 前缀后再查询。', + ); + setResult(null); + return; + } + if (normalizedOwner && normalizedPublicCode) { + setErrorMessage('owner 与公开用户编号不能同时提供。'); + setResult(null); + return; + } + setIsLoading(true); + setErrorMessage(''); + try { + const response = await getAdminExternalApiKeys(token, { + ownerUserId: normalizedOwner || undefined, + publicUserCode: normalizedPublicCode || undefined, + keyId: normalizedKeyId || undefined, + name: name.trim() || undefined, + keyPrefix: normalizedPrefix || undefined, + createdAfter: createdAfter.trim() || undefined, + createdBefore: createdBefore.trim() || undefined, + status: status || undefined, + purpose: purpose || undefined, + limit: parseLimit(limit), + offset: nextOffset, + sortColumn: sortColumn || undefined, + sortDirection: sortColumn ? sortDirection : undefined, + }); + setResult(response); + setSelectedKey(null); + } catch (error: unknown) { + handlePageError(error, onUnauthorized, setErrorMessage); + setResult(null); + } finally { + setIsLoading(false); + } + } + + function reset() { + setOwnerUserId(''); + setPublicUserCode(''); + setKeyId(''); + setName(''); + setKeyPrefix(''); + setCreatedAfter(''); + setCreatedBefore(''); + setStatus(''); + setPurpose(''); + setLimit('100'); + setSortColumn(''); + setSortDirection('desc'); + setResult(null); + setSelectedKey(null); + setErrorMessage(''); + } + + const resultLimit = result?.limit ?? parseLimit(limit); + const resultOffset = result?.offset ?? 0; + const keyTotalPages = result + ? Math.max(1, Math.ceil(result.total / Math.max(1, resultLimit))) + : 1; + const keyCurrentPage = + Math.floor(resultOffset / Math.max(1, resultLimit)) + 1; + const canGoToPreviousKeyPage = Boolean(result && resultOffset > 0); + const canGoToNextKeyPage = Boolean( + result && resultOffset + result.keys.length < result.total, + ); + + return ( + <> +
+
+ + + + + + + + + + + + +
+
+ + + + 必须提供 owner、公开用户编号、keyId 或精确前缀;只返回安全元数据。 + +
+
+ + {errorMessage ? ( +
+ {errorMessage} +
+ ) : null} + +
+
+

External API Key 安全视图

+ {result ? `匹配 ${result.total} 条` : '尚未查询'} +
+
+ + + + + + + + + + + + + + + + {result?.keys.length ? ( + result.keys.map((key) => ( + + + + + + + + + + + + )) + ) : ( + + + + )} + +
Key IDOwner名称前缀用途Scope创建时间状态详情
{key.keyId}{key.ownerUserId}{key.name}{key.keyPrefix}{key.purpose}{key.scopes.join(', ')}{formatSafeDate(key.createdAt)}{key.status} + +
+ {result ? '暂无数据' : '请先提供精确范围并查询'} +
+
+ {result?.scanLimitReached ? ( +
+ 当前查询达到 {result.scanLimit} 条安全扫描上限,已扫描{' '} + {result.scannedCount} 条; 总数和分页可能不完整,请缩小 owner、Key + ID 或精确前缀范围。 +
+ ) : null} + {result ? ( + + ) : null} +
+ + {selectedKey ? ( +
+
+
+

API Key 安全详情

+ +
+
+
Key ID
+
{selectedKey.keyId}
+
Owner
+
{selectedKey.ownerUserId}
+
名称
+
{selectedKey.name}
+
前缀
+
{selectedKey.keyPrefix}
+
用途
+
{selectedKey.purpose}
+
Scope
+
{selectedKey.scopes.join(', ') || '-'}
+
创建时间
+
{formatSafeDate(selectedKey.createdAt)}
+
最近使用
+
{formatSafeDate(selectedKey.lastUsedAt)}
+
撤销时间
+
{formatSafeDate(selectedKey.revokedAt)}
+
更新时间
+
{formatSafeDate(selectedKey.updatedAt)}
+
状态
+
{selectedKey.status}
+
+
+
+ ) : null} + + ); +} + +function formatSafeDate(value: string | null) { + return value ? value : '-'; +} + function readHashTableName() { const hash = window.location.hash; const queryIndex = hash.indexOf('?'); @@ -744,13 +1220,10 @@ export function resolveAdminDatabaseUserReference( normalizedColumn === 'public_user_code' || normalizedColumn.endsWith('_public_user_code') ) { - return {publicUserCode: normalizedValue}; + return { publicUserCode: normalizedValue }; } - if ( - normalizedColumn === 'user_id' || - normalizedColumn.endsWith('_user_id') - ) { - return {userId: normalizedValue}; + if (normalizedColumn === 'user_id' || normalizedColumn.endsWith('_user_id')) { + return { userId: normalizedValue }; } return null; } diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server.rs index 5a1d30e9f..729fdebf8 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server.rs @@ -50,6 +50,18 @@ struct CodexPendingRpc { } enum CodexAppServerCredential { + PlatformSession { + api_base_url: String, + access_token: String, + fingerprint: String, + }, + AccountKey { + route_origin: String, + api_key: String, + key_id: String, + storage_path: std::path::PathBuf, + fingerprint: String, + }, AppDataKey { fingerprint: String, }, @@ -63,7 +75,10 @@ enum CodexAppServerCredential { impl CodexAppServerCredential { fn fingerprint(&self) -> &str { match self { - Self::AppDataKey { fingerprint } | Self::AuthBridge { fingerprint, .. } => fingerprint, + Self::PlatformSession { fingerprint, .. } + | Self::AccountKey { fingerprint, .. } + | Self::AppDataKey { fingerprint } + | Self::AuthBridge { fingerprint, .. } => fingerprint, } } @@ -89,6 +104,12 @@ impl CodexAppServerCredential { llm: &'a GameCreatorLlmConfig, ) -> Option<(&'a str, &'a str)> { match self { + Self::PlatformSession { .. } => None, + Self::AccountKey { + route_origin, + api_key, + .. + } => Some((route_origin.as_str(), api_key.as_str())), Self::AppDataKey { .. } => (!llm.api_key.trim().is_empty()) .then_some((llm.base_url.trim_end_matches('/'), llm.api_key.trim())), Self::AuthBridge { api_key, .. } => api_key @@ -437,7 +458,9 @@ pub(super) fn resolve_direct_codex_project_authority( if !project_root.is_absolute() { return Err("AGC 直连项目根目录必须是绝对路径".to_string()); } - let project_metadata = std::fs::metadata(project_root) + crate::prepare_game_creator_private_path_for_read(project_root, true, "AGC 直连项目根目录") + .map_err(|error| format!("AGC 直连项目根目录无法安全访问:{error}"))?; + let project_metadata = std::fs::symlink_metadata(project_root) .map_err(|_| "AGC 直连项目根目录不存在或无法读取".to_string())?; if !project_metadata.is_dir() { return Err("AGC 直连项目根目录不是目录".to_string()); @@ -778,17 +801,52 @@ async fn stage_codex_app_server_image( )); } let image_dir = workspace_path.join("input-images"); - tokio::fs::create_dir_all(&image_dir) - .await - .map_err(|error| { - platform_llm::LlmError::Transport(format!("创建 app-server 图片暂存目录失败:{error}")) - })?; + crate::ensure_game_creator_private_directory_tree(&image_dir, "app-server 图片暂存目录") + .map_err(platform_llm::LlmError::Transport)?; + crate::prepare_game_creator_private_path_for_read(&image_dir, true, "app-server 图片暂存目录") + .map_err(platform_llm::LlmError::Transport)?; let digest = Sha256::digest(&bytes); let path = image_dir.join(format!("{:x}-{image_index}.{extension}", digest)); - if !path.exists() { - tokio::fs::write(&path, bytes).await.map_err(|error| { - platform_llm::LlmError::Transport(format!("写入 app-server 图片暂存文件失败:{error}")) - })?; + let mut image_options = tokio::fs::OpenOptions::new(); + image_options.write(true).create_new(true); + #[cfg(windows)] + { + use std::os::windows::fs::OpenOptionsExt; + image_options.custom_flags(crate::PROJECT_FILE_FLAG_OPEN_REPARSE_POINT); + } + match image_options.open(&path).await { + Ok(mut file) => { + if let Err(error) = + crate::harden_new_game_creator_private_path(&path, false, "app-server 图片暂存文件") + { + drop(file); + let _ = std::fs::remove_file(&path); + return Err(platform_llm::LlmError::Transport(error)); + } + file.write_all(&bytes).await.map_err(|error| { + platform_llm::LlmError::Transport(format!( + "写入 app-server 图片暂存文件失败:{error}" + )) + })?; + file.sync_all().await.map_err(|error| { + platform_llm::LlmError::Transport(format!( + "同步 app-server 图片暂存文件失败:{error}" + )) + })?; + } + Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => { + crate::prepare_game_creator_private_path_for_read( + &path, + false, + "app-server 图片暂存文件", + ) + .map_err(platform_llm::LlmError::Transport)?; + } + Err(error) => { + return Err(platform_llm::LlmError::Transport(format!( + "写入 app-server 图片暂存文件失败:{error}" + ))); + } } Ok(path) } @@ -975,6 +1033,8 @@ fn find_game_creator_codex_auth_path() -> Option { fn read_game_creator_codex_auth_bridge( source_auth: &std::path::Path, ) -> Result { + crate::prepare_game_creator_private_path_for_read(source_auth, false, "Codex CLI 登录态") + .map_err(platform_llm::LlmError::InvalidConfig)?; let mut auth_json = Vec::new(); { use std::io::Read; @@ -1279,6 +1339,8 @@ fn prepare_isolated_game_creator_codex_home( std::fs::create_dir(&isolated_home).map_err(|error| { platform_llm::LlmError::Transport(format!("创建隔离 Codex app-server HOME 失败:{error}")) })?; + crate::harden_new_game_creator_private_path(&isolated_home, true, "隔离 Codex app-server HOME") + .map_err(platform_llm::LlmError::Transport)?; let CodexAppServerCredential::AuthBridge { auth_json, .. } = credential else { return Ok(isolated_home); }; @@ -1286,20 +1348,8 @@ fn prepare_isolated_game_creator_codex_home( return Ok(isolated_home); } let target_auth = isolated_home.join("auth.json"); - std::fs::write(&target_auth, auth_json).map_err(|error| { - platform_llm::LlmError::Transport(format!( - "桥接 Codex 登录态到隔离 app-server 失败:{error}" - )) - })?; - #[cfg(unix)] - { - use std::os::unix::fs::PermissionsExt; - std::fs::set_permissions(&target_auth, std::fs::Permissions::from_mode(0o600)).map_err( - |error| { - platform_llm::LlmError::Transport(format!("收紧隔离 Codex 登录态权限失败:{error}")) - }, - )?; - } + crate::write_game_creator_private_file(&target_auth, auth_json.as_slice(), "隔离 Codex 登录态") + .map_err(platform_llm::LlmError::Transport)?; Ok(isolated_home) } @@ -1310,11 +1360,13 @@ fn trust_isolated_game_creator_codex_workspace( let workspace = workspace.to_string_lossy(); let quoted_workspace = quoted_toml_string(&workspace)?; let config = format!("[projects.{quoted_workspace}]\ntrust_level = \"trusted\"\n"); - std::fs::write(codex_home.join("config.toml"), config).map_err(|error| { - platform_llm::LlmError::Transport(format!( - "写入隔离 Codex app-server 项目信任配置失败:{error}" - )) - }) + let config_path = codex_home.join("config.toml"); + crate::write_game_creator_private_file( + &config_path, + config.as_bytes(), + "隔离 Codex 项目信任配置", + ) + .map_err(platform_llm::LlmError::Transport) } impl CodexAppServerConnection { @@ -1337,9 +1389,35 @@ impl CodexAppServerConnection { } let codex_cli_version = game_creator_codex_cli_version_identity() .map_err(platform_llm::LlmError::InvalidConfig)?; - let credential = resolve_game_creator_codex_app_server_credential(llm)?; + let mut effective_llm = llm.clone(); + let credential = if game_creator_official_llm_route_locked() { + let session = current_platform_session().ok_or_else(|| { + platform_llm::LlmError::InvalidConfig( + "authentication-required: 请先登录陶泥儿账号".to_string(), + ) + })?; + effective_llm.base_url = + format!("{}/api/llm", session.api_base_url.trim_end_matches('/')); + effective_llm.api_key.clear(); + effective_llm.model = OFFICIAL_AGC_LLM_MODEL.to_string(); + CodexAppServerCredential::PlatformSession { + fingerprint: format!( + "platform-session:{}:{}:{}", + session.user_id, + session.api_base_url, + Sha256::digest(session.access_token.as_bytes()) + .iter() + .map(|byte| format!("{byte:02x}")) + .collect::() + ), + api_base_url: session.api_base_url, + access_token: session.access_token, + } + } else { + resolve_game_creator_codex_app_server_credential(llm)? + }; let key = game_creator_codex_app_server_pool_key( - llm, + &effective_llm, &codex_cli_version, snapshot, credential.fingerprint(), @@ -1383,7 +1461,7 @@ impl CodexAppServerConnection { let executable = game_creator_codex_cli_executable_path() .map_err(platform_llm::LlmError::InvalidConfig)?; Self::spawn_with_executable_and_credential_at_workspace( - llm, + &effective_llm, &credential, executable.as_os_str(), Some(workspace), @@ -1395,7 +1473,7 @@ impl CodexAppServerConnection { let executable = game_creator_codex_cli_executable_path() .map_err(platform_llm::LlmError::InvalidConfig)?; Self::spawn_with_executable_and_credential_at_workspace( - llm, + &effective_llm, &credential, executable.as_os_str(), None, @@ -1455,10 +1533,29 @@ impl CodexAppServerConnection { "创建 Codex app-server 临时目录失败:{error}" )) })?; - let direct_provider_route = (workspace_mode == CodexAppServerWorkspaceMode::DirectProject) - .then(|| credential.direct_provider_route(llm)) - .flatten() - .map(|(base_url, api_key)| (base_url.to_string(), api_key.to_string())); + crate::harden_new_game_creator_private_path( + working_dir.path(), + true, + "Codex app-server 临时目录", + ) + .map_err(platform_llm::LlmError::Transport)?; + let direct_provider_route = match credential { + CodexAppServerCredential::PlatformSession { + api_base_url, + access_token, + .. + } => Some(( + format!("{}/api/llm", api_base_url.trim_end_matches('/')), + access_token.clone(), + )), + CodexAppServerCredential::AccountKey { .. } => credential + .direct_provider_route(llm) + .map(|(base_url, api_key)| (base_url.to_string(), api_key.to_string())), + _ => (workspace_mode == CodexAppServerWorkspaceMode::DirectProject) + .then(|| credential.direct_provider_route(llm)) + .flatten() + .map(|(base_url, api_key)| (base_url.to_string(), api_key.to_string())), + }; let remote_control_disable_reason = credential.remote_control_disable_reason(direct_provider_route.is_some()); let isolated_codex_home = prepare_isolated_game_creator_codex_home( @@ -1473,11 +1570,24 @@ impl CodexAppServerConnection { "创建 Codex app-server 隔离工作目录失败:{error}" )) })?; - std::fs::create_dir(isolated_workspace.join(".git")).map_err(|error| { + crate::harden_new_game_creator_private_path( + &isolated_workspace, + true, + "Codex app-server 隔离工作目录", + ) + .map_err(platform_llm::LlmError::Transport)?; + let isolated_git = isolated_workspace.join(".git"); + std::fs::create_dir(&isolated_git).map_err(|error| { platform_llm::LlmError::Transport(format!( "创建 Codex app-server 隔离仓库边界失败:{error}" )) })?; + crate::harden_new_game_creator_private_path( + &isolated_git, + true, + "Codex app-server 隔离仓库边界", + ) + .map_err(platform_llm::LlmError::Transport)?; } let (tool_bridge_root, workspace_path) = if workspace_mode == CodexAppServerWorkspaceMode::DirectProject { @@ -1502,16 +1612,21 @@ impl CodexAppServerConnection { let isolated_os_home = working_dir.path().join("home"); let isolated_app_data = isolated_os_home.join("appdata"); let isolated_local_app_data = isolated_os_home.join("local-appdata"); - for path in [ - &isolated_os_home, - &isolated_app_data, - &isolated_local_app_data, + for (path, label) in [ + (&isolated_os_home, "Codex app-server 隔离用户目录"), + (&isolated_app_data, "Codex app-server 隔离 AppData 目录"), + ( + &isolated_local_app_data, + "Codex app-server 隔离 LocalAppData 目录", + ), ] { - std::fs::create_dir_all(path).map_err(|error| { + std::fs::create_dir(path).map_err(|error| { platform_llm::LlmError::Transport(format!( "创建 Codex app-server 隔离用户目录失败:{error}" )) })?; + crate::harden_new_game_creator_private_path(path, true, label) + .map_err(platform_llm::LlmError::Transport)?; } let skill_root = if workspace_mode == CodexAppServerWorkspaceMode::DirectProject { install_agc_skill_pack(&isolated_os_home) @@ -1520,9 +1635,7 @@ impl CodexAppServerConnection { } else { None }; - let provider_proxy = if workspace_mode != CodexAppServerWorkspaceMode::DirectProject { - None - } else if let Some((base_url, api_key)) = direct_provider_route.as_ref() { + let provider_proxy = if let Some((base_url, api_key)) = direct_provider_route.as_ref() { Some( start_codex_provider_proxy(base_url, api_key) .await diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/codex_cli.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/codex_cli.rs index 15d7d45c0..1418e9afa 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/codex_cli.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/codex_cli.rs @@ -608,6 +608,8 @@ async fn request_game_creator_agent_codex_cli_with_executable( .map_err(|error| { platform_llm::LlmError::Transport(format!("创建 Codex CLI Agent 临时目录失败:{error}")) })?; + crate::harden_new_game_creator_private_path(temp_dir.path(), true, "Codex CLI Agent 临时目录") + .map_err(platform_llm::LlmError::Transport)?; let schema_path = if let Some(schema) = game_creator_codex_cli_tool_output_schema(&request) { let path = temp_dir.path().join("tool-output.schema.json"); let content = serde_json::to_vec(&schema).map_err(|error| { @@ -615,11 +617,8 @@ async fn request_game_creator_agent_codex_cli_with_executable( "序列化 Codex CLI Agent output schema 失败:{error}" )) })?; - std::fs::write(&path, content).map_err(|error| { - platform_llm::LlmError::Transport(format!( - "写入 Codex CLI Agent output schema 失败:{error}" - )) - })?; + crate::write_game_creator_private_file(&path, &content, "Codex CLI Agent output schema") + .map_err(platform_llm::LlmError::Transport)?; Some(path) } else { None diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime.rs index e515babc1..69210a45f 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime.rs @@ -1136,6 +1136,7 @@ fn direct_taonier_manifest_entry_at( fn direct_taonier_asset_sha256_at(root: &Path, local_path: &str) -> Result { let path = resolve_local_project_path(root, local_path)?; + prepare_game_creator_private_path_for_read(&path, false, "整包重生成素材")?; let bytes = std::fs::read(&path) .map_err(|error| format!("读取整包重生成素材失败:{}: {error}", path.display()))?; Ok(format!("{:x}", Sha256::digest(bytes))) @@ -1146,6 +1147,11 @@ fn direct_taonier_optional_contract_file_sha256_at( local_path: &str, ) -> Result, String> { let path = resolve_local_project_path(root, local_path)?; + let prepared = + prepare_game_creator_private_path_for_read(&path, false, "整包重生成严格合同文件")?; + if !prepared { + return Ok(None); + } let metadata = match std::fs::symlink_metadata(&path) { Ok(metadata) => metadata, Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None), @@ -1198,6 +1204,7 @@ impl DirectTaonierRegenerationRollback { .into_iter() .map(|local_path| { let path = resolve_local_project_path(root, local_path)?; + prepare_game_creator_private_path_for_read(&path, false, "整包重生成旧素材")?; let previous_bytes = std::fs::read(&path).map_err(|error| { format!("读取整包重生成旧素材失败:{}: {error}", path.display()) })?; @@ -1909,7 +1916,8 @@ fn direct_taonier_art_asset_identity( return None; } let asset_path = resolve_local_project_path(root, &asset.local_path).ok()?; - if !asset_path.is_file() { + if !prepare_game_creator_private_path_for_read(&asset_path, false, "陶泥儿平台素材").ok()? + { return None; } let bytes = std::fs::read(asset_path).ok()?; @@ -2061,13 +2069,16 @@ fn direct_taonier_strict_art_package_is_valid(root: &Path) -> bool { return false; } let expected_art_manifest = art_manifest_content(); - if std::fs::read(root.join("assets/manifest.art.json")) + let art_manifest_path = root.join("assets/manifest.art.json"); + if !prepare_game_creator_private_path_for_read(&art_manifest_path, false, "美术 manifest") .ok() - .as_deref() - != Some(expected_art_manifest.as_bytes()) + .unwrap_or(false) { return false; } + if std::fs::read(&art_manifest_path).ok().as_deref() != Some(expected_art_manifest.as_bytes()) { + return false; + } let Ok(manifest) = read_manifest_for_project(root) else { return false; }; @@ -2177,9 +2188,7 @@ fn direct_registered_taonier_slice_paths(root: &Path) -> Vec { fn direct_game_sources_referenced_taonier_assets(root: &Path) -> Vec { let sources = direct_codex_game_outputs(root) .into_iter() - .filter_map(|(relative_path, _, _)| { - std::fs::read_to_string(root.join(relative_path)).ok() - }) + .filter_map(|(relative_path, _, _)| std::fs::read_to_string(root.join(relative_path)).ok()) .collect::>(); let mut available_paths = Vec::new(); if direct_taonier_art_base_is_valid(root) { @@ -2267,9 +2276,7 @@ fn direct_browser_evidence_needs_art_repair( fn direct_game_output_completion_error(root: &Path) -> Option { let entry = agent_runtime_game_entry_relative_path(root); if !root.join(entry).is_file() { - return Some(format!( - "Codex 返回后未找到 {entry},项目未进入可运行状态" - )); + return Some(format!("Codex 返回后未找到 {entry},项目未进入可运行状态")); } if !direct_game_sources_reference_taonier_art_package(root) { return Some( @@ -2450,6 +2457,7 @@ async fn recover_direct_taonier_spritesheet_read_only_at( .find(|asset| asset.local_path == DIRECT_CODEX_ART_SPEC_ASSET_PATH) .ok_or_else(|| "陶泥儿规范图 manifest 记录缺失".to_string())?; let source_path = resolve_local_project_path(root, &source_asset.local_path)?; + prepare_game_creator_private_path_for_read(&source_path, false, "陶泥儿规范图")?; let source_bytes = fs::read(source_path).map_err(|error| format!("读取陶泥儿规范图失败:{error}"))?; let source_identity = new_external_editor_source_identity( @@ -2572,14 +2580,25 @@ async fn recover_direct_taonier_spritesheet_read_only_at( return Err("本地图集文件已存在但合同不完整,已拒绝覆盖并保持失败状态".to_string()); } if let Some(parent) = output.parent() { - std::fs::create_dir_all(parent) - .map_err(|error| format!("创建陶泥儿图集目录失败:{error}"))?; + ensure_game_creator_private_directory_tree(parent, "陶泥儿图集目录")?; + prepare_game_creator_private_path_for_read(parent, true, "陶泥儿图集目录")?; } - let mut output_file = std::fs::OpenOptions::new() - .write(true) - .create_new(true) + let mut output_options = std::fs::OpenOptions::new(); + output_options.write(true).create_new(true); + #[cfg(windows)] + { + use std::os::windows::fs::OpenOptionsExt; + output_options.custom_flags(crate::PROJECT_FILE_FLAG_OPEN_REPARSE_POINT); + } + let mut output_file = output_options .open(&output) .map_err(|error| format!("创建恢复的陶泥儿图集文件失败:{error}"))?; + if let Err(error) = harden_new_game_creator_private_path(&output, false, "恢复的陶泥儿图集") + { + drop(output_file); + let _ = std::fs::remove_file(&output); + return Err(error); + } output_file .write_all(&download.bytes) .and_then(|_| output_file.sync_all()) @@ -3169,7 +3188,9 @@ fn direct_codex_output_fingerprint(root: &Path) -> String { for (local_path, _, _) in direct_codex_game_outputs(root) { hasher.update(local_path.as_bytes()); hasher.update([0]); - match std::fs::read(root.join(local_path)) { + let path = root.join(local_path); + let _ = prepare_game_creator_private_path_for_read(&path, false, "游戏输出文件"); + match std::fs::read(path) { Ok(bytes) => { hasher.update([1]); hasher.update((bytes.len() as u64).to_le_bytes()); diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tool_bridge.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tool_bridge.rs index 7330dd43b..516302e80 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tool_bridge.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tool_bridge.rs @@ -6,6 +6,7 @@ use base64::{engine::general_purpose::STANDARD as BASE64_STANDARD, Engine as _}; use serde::Deserialize; use serde_json::{json, Value}; use std::collections::BTreeMap; +use std::io::Read; use std::path::{Path, PathBuf}; use std::sync::{Arc, Mutex as StdMutex}; use unicode_normalization::UnicodeNormalization; @@ -1073,6 +1074,10 @@ fn bridge_art_preparation_mode( } fn bridge_png_content(root: &Path, path: &Path) -> Result { + crate::validate_game_creator_private_path_ancestors(root, "工具桥项目根")?; + crate::prepare_game_creator_private_path_for_read(root, true, "工具桥项目根")?; + crate::validate_game_creator_private_path_ancestors(path, "工具桥图片")?; + crate::prepare_game_creator_private_path_for_read(path, false, "工具桥图片")?; let root = root .canonicalize() .map_err(|_| "工具桥项目根无法安全解析".to_string())?; @@ -1082,6 +1087,7 @@ fn bridge_png_content(root: &Path, path: &Path) -> Result { if !path.starts_with(&root) { return Err("工具桥图片越出当前项目边界".to_string()); } + crate::prepare_game_creator_private_path_for_read(&path, false, "工具桥图片")?; let metadata = std::fs::symlink_metadata(&path).map_err(|_| "读取工具桥图片失败".to_string())?; if metadata.file_type().is_symlink() @@ -1090,7 +1096,10 @@ fn bridge_png_content(root: &Path, path: &Path) -> Result { { return Err("工具桥图片不满足普通文件或大小边界".to_string()); } - let bytes = std::fs::read(&path).map_err(|_| "读取工具桥图片失败".to_string())?; + let (mut file, _) = open_project_private_regular_file(&path, "工具桥图片")?; + let mut bytes = Vec::new(); + file.read_to_end(&mut bytes) + .map_err(|_| "读取工具桥图片失败".to_string())?; if !bytes.starts_with(b"\x89PNG\r\n\x1a\n") { return Err("工具桥图片不是有效 PNG".to_string()); } diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tools_mcp.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tools_mcp.rs index 7c896bd3a..6006dac4d 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tools_mcp.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tools_mcp.rs @@ -1178,8 +1178,7 @@ mod tests { #[test] fn tool_catalog_preserves_reviewed_resource_contracts() { assert!( - DIRECT_TOOLS_MCP_MAX_REQUEST_BYTES - > DIRECT_TOOLS_MCP_MAX_WRITE_CONTENT_BYTES + 1024, + DIRECT_TOOLS_MCP_MAX_REQUEST_BYTES > DIRECT_TOOLS_MCP_MAX_WRITE_CONTENT_BYTES + 1024, "MCP request envelope must fit the advertised file-write payload" ); let specs = direct_tools_mcp_specs(); diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/generation.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/generation.rs index 11fb9e900..f9c5a4ab3 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/generation.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/generation.rs @@ -24,8 +24,7 @@ pub(crate) use canvas_generation::{ ExternalGenerationInitialResponse, }; pub(in crate::agent) use canvas_generation::{ - commit_prepared_platform_art_asset_at, - commit_prepared_platform_art_asset_strict_slices_at, + commit_prepared_platform_art_asset_at, commit_prepared_platform_art_asset_strict_slices_at, generate_platform_art_asset_with_retained_runtime_options_at, generate_platform_art_asset_with_runtime_options_at, platform_art_generation_error_result_unknown, register_existing_platform_art_slices_at, diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/generation/canvas_generation.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/generation/canvas_generation.rs index ee9b32f60..4c8fd67ef 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/generation/canvas_generation.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/generation/canvas_generation.rs @@ -475,7 +475,11 @@ fn prepare_platform_art_asset_output_path_for_mode( return Err("图片生成 outputPath 只允许 png、jpg、jpeg 或 webp 文件".to_string()); } let absolute = resolve_local_project_path(root, &normalized)?; - let existing = if absolute.exists() { + let existing = if crate::prepare_game_creator_private_path_for_read( + &absolute, + false, + "图片生成 outputPath", + )? { if !replace_existing { return Err(format!( "图片生成 outputPath 已存在,禁止静默覆盖:{normalized}" @@ -3118,6 +3122,10 @@ fn cleanup_interrupted_platform_art_contract_files_at(root: &Path) -> Result<(), } fn write_new_platform_art_slice(path: &Path, bytes: &[u8]) -> Result { + if let Some(parent) = path.parent() { + crate::ensure_game_creator_private_directory_tree(parent, "平台图集切片目录")?; + crate::prepare_game_creator_private_path_for_read(parent, true, "平台图集切片目录")?; + } let mut output = fs::OpenOptions::new(); output.write(true).create_new(true); #[cfg(unix)] @@ -3126,9 +3134,15 @@ fn write_new_platform_art_slice(path: &Path, bytes: &[u8]) -> Result output, Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => { + crate::prepare_game_creator_private_path_for_read(path, false, "平台图集切片")?; let existing = fs::read(path) .map_err(|read_error| format!("回读既有平台图集切片失败:{read_error}"))?; if existing == bytes { @@ -3143,6 +3157,12 @@ fn write_new_platform_art_slice(path: &Path, bytes: &[u8]) -> Result Result Result<(), String> { + if let Some(parent) = path.parent() { + crate::ensure_game_creator_private_directory_tree(parent, "平台图集切片目录")?; + crate::prepare_game_creator_private_path_for_read(parent, true, "平台图集切片目录")?; + } let file_name = path .file_name() .and_then(|value| value.to_str()) @@ -3158,8 +3182,10 @@ fn replace_platform_art_slice_file(path: &Path, bytes: &[u8], suffix: &str) -> R let temporary = path.with_file_name(format!(".{file_name}.replacement.{suffix}")); let backup = path.with_file_name(format!(".{file_name}.previous.{suffix}")); write_new_platform_art_slice(&temporary, bytes)?; - let had_previous = path.is_file(); + let had_previous = + crate::prepare_game_creator_private_path_for_read(path, false, "平台图集切片")?; if had_previous { + crate::prepare_game_creator_private_path_for_read(path, false, "平台图集切片")?; if let Err(error) = fs::rename(path, &backup) { let _ = fs::remove_file(&temporary); return Err(format!("准备替换平台图集切片文件失败:{error}")); @@ -3239,6 +3265,10 @@ fn write_durable_platform_art_transaction_file( bytes: &[u8], label: &str, ) -> Result<(), String> { + if let Some(parent) = path.parent() { + crate::ensure_game_creator_private_directory_tree(parent, "平台图集事务目录")?; + crate::prepare_game_creator_private_path_for_read(parent, true, "平台图集事务目录")?; + } let mut options = fs::OpenOptions::new(); options.write(true).create_new(true); #[cfg(unix)] @@ -3247,12 +3277,23 @@ fn write_durable_platform_art_transaction_file( options.custom_flags(libc::O_NOFOLLOW); options.mode(0o600); } + #[cfg(windows)] + { + use std::os::windows::fs::OpenOptionsExt; + options.custom_flags(crate::PROJECT_FILE_FLAG_OPEN_REPARSE_POINT); + } let mut file = options .open(path) .map_err(|error| format!("创建{label}失败:{}: {error}", path.display()))?; + if let Err(error) = crate::harden_new_game_creator_private_path(path, false, label) { + drop(file); + let _ = fs::remove_file(path); + return Err(error); + } file.write_all(bytes) .and_then(|_| file.sync_all()) - .map_err(|error| format!("持久化{label}失败:{}: {error}", path.display())) + .map_err(|error| format!("持久化{label}失败:{}: {error}", path.display()))?; + Ok(()) } #[cfg(test)] @@ -6857,12 +6898,12 @@ fn commit_strict_platform_art_slices_at( )?); let absolute_path = resolve_local_project_path(root, &local_path)?; if let Some(parent) = absolute_path.parent() { - fs::create_dir_all(parent).map_err(|error| { - format!( - "创建正式平台图集切片目录失败:{}: {error}", - parent.display() - ) - })?; + crate::ensure_game_creator_private_directory_tree(parent, "正式平台图集切片目录")?; + crate::prepare_game_creator_private_path_for_read( + parent, + true, + "正式平台图集切片目录", + )?; } replace_platform_art_slice_file(&absolute_path, &slice.download.bytes, suffix)?; content_sha256s.push(slice.content_sha256.clone()); @@ -6931,12 +6972,8 @@ fn commit_prepared_platform_art_slices_at( sanitize_file_name(generation_key) ); let directory_path = resolve_local_project_path(root, &directory)?; - fs::create_dir_all(&directory_path).map_err(|error| { - format!( - "创建平台图集切片目录失败:{}: {error}", - directory_path.display() - ) - })?; + crate::ensure_game_creator_private_directory_tree(&directory_path, "平台图集切片目录")?; + crate::prepare_game_creator_private_path_for_read(&directory_path, true, "平台图集切片目录")?; let mut generated = Vec::with_capacity(slices.len()); let mut created_paths = Vec::with_capacity(slices.len()); let mut slice_paths = Vec::with_capacity(slices.len()); @@ -7131,8 +7168,8 @@ fn commit_prepared_platform_art_asset_with_before_replace_hook( } }; if let Some(parent) = absolute_path.parent() { - fs::create_dir_all(parent) - .map_err(|error| format!("创建平台生成素材目录失败:{}: {error}", parent.display()))?; + crate::ensure_game_creator_private_directory_tree(parent, "平台生成素材目录")?; + crate::prepare_game_creator_private_path_for_read(parent, true, "平台生成素材目录")?; } absolute_path = resolve_local_project_path(root, &local_path)?; let replacement_suffix = format!( @@ -7185,13 +7222,29 @@ fn commit_prepared_platform_art_asset_with_before_replace_hook( output.custom_flags(libc::O_NOFOLLOW); output.mode(0o600); } + #[cfg(windows)] + { + use std::os::windows::fs::OpenOptionsExt; + output.custom_flags(crate::PROJECT_FILE_FLAG_OPEN_REPARSE_POINT); + } let mut output = output.open(&output_path).map_err(|error| { format!("创建平台生成素材失败:{}: {error}", output_path.display()) })?; + if let Err(error) = + crate::harden_new_game_creator_private_path(&output_path, false, "平台生成素材") + { + drop(output); + let _ = fs::remove_file(&output_path); + return Err(error); + } output.write_all(&download.bytes).map_err(|error| { let _ = fs::remove_file(&output_path); format!("写入平台生成素材失败:{}: {error}", output_path.display()) })?; + output.sync_all().map_err(|error| { + let _ = fs::remove_file(&output_path); + format!("同步平台生成素材失败:{}: {error}", output_path.display()) + })?; drop(output); } let replacement_backup_path = @@ -7306,12 +7359,15 @@ fn commit_prepared_platform_art_asset_with_before_replace_hook( let receipt_path = resolve_local_project_path(root, ".agent/runtime/art-spritesheet-contract.json")?; if let Some(parent) = receipt_path.parent() { - fs::create_dir_all(parent).map_err(|error| { - format!( - "创建平台图集私有合同回执目录失败:{}: {error}", - parent.display() - ) - })?; + crate::ensure_game_creator_private_directory_tree( + parent, + "平台图集私有合同回执目录", + )?; + crate::prepare_game_creator_private_path_for_read( + parent, + true, + "平台图集私有合同回执目录", + )?; } let receipt_bytes = strict_game_art_contract_receipt_bytes( resource_id diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/generation/draft_writer.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/generation/draft_writer.rs index b3a84948e..037cb2d2e 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/generation/draft_writer.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/generation/draft_writer.rs @@ -37,63 +37,56 @@ pub(crate) fn write_local_game_draft_at( &format!("- {timestamp}: {prompt}\n"), "写入长期记忆失败", )?; - fs::write( + write_game_creator_private_file( &design_path, format!( "# 游戏设计草案\n\n## 原始想法\n\n{prompt}\n\n## Agent 协作交接\n\n{handoff_summary}\n\n## LLM 生成草案\n\n{}\n", draft.design_markdown.trim() - ), - ) - .map_err(|error| format!("写入游戏设计失败:{}: {error}", design_path.display()))?; - fs::write( + ) + .as_bytes(), + "游戏设计", + )?; + write_game_creator_private_file( &balance_path, serde_json::to_string_pretty(&draft.balance) - .map_err(|error| format!("生成数值配置失败:{error}"))?, - ) - .map_err(|error| format!("写入数值配置失败:{}: {error}", balance_path.display()))?; - fs::write( + .map_err(|error| format!("生成数值配置失败:{error}"))? + .as_bytes(), + "数值配置", + )?; + write_game_creator_private_file( &art_manifest_path, serde_json::to_string_pretty(&draft.art_manifest) - .map_err(|error| format!("生成美术清单失败:{error}"))?, - ) - .map_err(|error| format!("写入美术清单失败:{}: {error}", art_manifest_path.display()))?; - fs::write( + .map_err(|error| format!("生成美术清单失败:{error}"))? + .as_bytes(), + "美术清单", + )?; + write_game_creator_private_file( &audio_manifest_path, serde_json::to_string_pretty(&draft.audio_manifest) - .map_err(|error| format!("生成音乐音效清单失败:{error}"))?, - ) - .map_err(|error| { - format!( - "写入音乐音效清单失败:{}: {error}", - audio_manifest_path.display() - ) - })?; - fs::write( + .map_err(|error| format!("生成音乐音效清单失败:{error}"))? + .as_bytes(), + "音乐音效清单", + )?; + write_game_creator_private_file( &publish_readme_path, format!( "# 发布包装草案\n\n## 标题\n\n{title}\n\n## 简介\n\n{prompt}\n\n## Agent 协作交接\n\n{handoff_summary}\n\n{}\n", draft.publish_readme.trim() - ), - ) - .map_err(|error| { - format!( - "写入发布包装草案失败:{}: {error}", - publish_readme_path.display() ) - })?; + .as_bytes(), + "发布包装草案", + )?; - fs::write(&game_index_path, draft.game_html.trim()) - .map_err(|error| format!("写入游戏入口失败:{}: {error}", game_index_path.display()))?; - fs::OpenOptions::new() - .create(true) - .append(true) - .open(&agent_log_path) - .and_then(|mut file| { - file.write_all( - format!("{timestamp} game.generate_draft llm\n{handoff_summary}\n").as_bytes(), - ) - }) - .map_err(|error| format!("写入 Agent 日志失败:{}: {error}", agent_log_path.display()))?; + write_game_creator_private_file( + &game_index_path, + draft.game_html.trim().as_bytes(), + "游戏入口", + )?; + append_game_creator_private_file( + &agent_log_path, + format!("{timestamp} game.generate_draft llm\n{handoff_summary}\n").as_bytes(), + "Agent 日志", + )?; append_agent_db_record( root, serde_json::json!({ diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/generation/loop_orchestration.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/generation/loop_orchestration.rs index 43a6aa0ed..9e9909787 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/generation/loop_orchestration.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/generation/loop_orchestration.rs @@ -61,8 +61,7 @@ pub(crate) async fn run_game_creator_agent_loop_at( ) .await .map(|spec| render_planner_spec(prompt, &spec))?; - fs::write(&spec_path, &planner_spec) - .map_err(|error| format!("写入 Planner 规格失败:{}: {error}", spec_path.display()))?; + write_game_creator_private_file(&spec_path, planner_spec.as_bytes(), "Planner 规格")?; emit_agent_progress(progress, "llm.planner.done", "Planner 规格已生成"); steps.push(with_task_context( agent_trace_step( @@ -89,12 +88,7 @@ pub(crate) async fn run_game_creator_agent_loop_at( let mut latest_findings = render_evaluator_findings(0, &["暂无上一轮问题,Generator 可开始首轮实现。"]); - fs::write(&findings_path, &latest_findings).map_err(|error| { - format!( - "写入 Evaluator 结果失败:{}: {error}", - findings_path.display() - ) - })?; + write_game_creator_private_file(&findings_path, latest_findings.as_bytes(), "Evaluator 结果")?; steps.push(agent_trace_step( 0, "Evaluator", @@ -169,12 +163,11 @@ pub(crate) async fn run_game_creator_agent_loop_at( "agent.role.brief", )); latest_findings = render_evaluator_findings(pass, &issues); - fs::write(&findings_path, &latest_findings).map_err(|write_error| { - format!( - "写入 Evaluator 结果失败:{}: {write_error}", - findings_path.display() - ) - })?; + write_game_creator_private_file( + &findings_path, + latest_findings.as_bytes(), + "Evaluator 结果", + )?; steps.push(with_task_context( agent_trace_step( pass, @@ -271,12 +264,11 @@ pub(crate) async fn run_game_creator_agent_loop_at( append_collaboration_steps(pass, &draft, &pass_artifacts, &mut steps); let issues = evaluate_game_draft(prompt, &draft); latest_findings = render_evaluator_findings(pass, &issues); - fs::write(&findings_path, &latest_findings).map_err(|error| { - format!( - "写入 Evaluator 结果失败:{}: {error}", - findings_path.display() - ) - })?; + write_game_creator_private_file( + &findings_path, + latest_findings.as_bytes(), + "Evaluator 结果", + )?; steps.push(with_task_context( agent_trace_step_owned( pass, @@ -344,12 +336,11 @@ pub(crate) async fn run_game_creator_agent_loop_at( "llm.chat.generator", )); latest_findings = render_evaluator_findings(pass, &issues); - fs::write(&findings_path, &latest_findings).map_err(|write_error| { - format!( - "写入 Evaluator 结果失败:{}: {write_error}", - findings_path.display() - ) - })?; + write_game_creator_private_file( + &findings_path, + latest_findings.as_bytes(), + "Evaluator 结果", + )?; steps.push(with_task_context( agent_trace_step( pass, diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/generation/pass_artifacts.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/generation/pass_artifacts.rs index bdc1046b5..cea5cf4f9 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/generation/pass_artifacts.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/generation/pass_artifacts.rs @@ -186,8 +186,8 @@ pub(crate) fn write_agent_pass_artifacts( ) -> Result { let relative_dir = format!(".agent/passes/pass-{pass}"); let pass_dir = root.join(&relative_dir); - fs::create_dir_all(&pass_dir) - .map_err(|error| format!("创建 Agent pass 目录失败:{}: {error}", pass_dir.display()))?; + ensure_game_creator_private_directory_tree(&pass_dir, "Agent pass 目录")?; + prepare_game_creator_private_path_for_read(&pass_dir, true, "Agent pass 目录")?; let paths = AgentPassArtifactPaths { draft_json: format!("{relative_dir}/draft.json"), @@ -262,16 +262,7 @@ pub(crate) fn write_agent_pass_file( content: &str, ) -> Result<(), String> { let path = root.join(relative_path); - if let Some(parent) = path.parent() { - fs::create_dir_all(parent).map_err(|error| { - format!( - "创建 Agent pass 文件目录失败:{}: {error}", - parent.display() - ) - })?; - } - fs::write(&path, content) - .map_err(|error| format!("写入 Agent pass 文件失败:{}: {error}", path.display())) + write_game_creator_private_file(&path, content.as_bytes(), "Agent pass 文件") } pub(crate) fn write_agent_group_brief( diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/generation/role_briefs.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/generation/role_briefs.rs index 2139ddfe1..516680147 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/generation/role_briefs.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/generation/role_briefs.rs @@ -714,22 +714,17 @@ pub(crate) fn append_agent_loop_log( ) -> Result<(), String> { let agent_log_path = root.join(".agent/logs/agent.log"); let timestamp = unix_timestamp(); - fs::OpenOptions::new() - .create(true) - .append(true) - .open(&agent_log_path) - .and_then(|mut file| { - file.write_all( - format!( - "{timestamp} agent.loop passes={}\nPlanner -> .agent/spec.md\n组内角色 briefs -> .agent/passes/pass-*/groups//*.md\n专业组汇总 -> .agent/passes/pass-*/groups/*.md\nGenerator -> .agent/passes/pass-*/draft.json\n专业组 handoffs -> .agent/passes/pass-*/handoff.md\nEvaluator -> .agent/findings.md\n{}\n{}\n", - loop_result.passes, - loop_result.spec_markdown.trim(), - loop_result.findings_markdown.trim() - ) - .as_bytes(), - ) - }) - .map_err(|error| format!("写入 Agent loop 日志失败:{}: {error}", agent_log_path.display()))?; + append_game_creator_private_file( + &agent_log_path, + format!( + "{timestamp} agent.loop passes={}\nPlanner -> .agent/spec.md\n组内角色 briefs -> .agent/passes/pass-*/groups//*.md\n专业组汇总 -> .agent/passes/pass-*/groups/*.md\nGenerator -> .agent/passes/pass-*/draft.json\n专业组 handoffs -> .agent/passes/pass-*/handoff.md\nEvaluator -> .agent/findings.md\n{}\n{}\n", + loop_result.passes, + loop_result.spec_markdown.trim(), + loop_result.findings_markdown.trim() + ) + .as_bytes(), + "Agent loop 日志", + )?; append_agent_loop_memory(root, loop_result) } diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/generation/run_lifecycle.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/generation/run_lifecycle.rs index 697fd2c81..93765f080 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/generation/run_lifecycle.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/generation/run_lifecycle.rs @@ -191,26 +191,12 @@ pub(crate) fn write_agent_run_trace_payload( let payload = serde_json::to_string_pretty(&trace) .map_err(|error| format!("生成 Agent run trace 失败:{error}"))?; let latest_path = root.join(".agent/run.latest.json"); - fs::write(&latest_path, &payload).map_err(|error| { - format!( - "写入 Agent run trace 失败:{}: {error}", - latest_path.display() - ) - })?; + write_game_creator_private_file(&latest_path, payload.as_bytes(), "Agent run trace")?; let run_dir = root.join(".agent/runs"); - fs::create_dir_all(&run_dir).map_err(|error| { - format!( - "创建 Agent run history 目录失败:{}: {error}", - run_dir.display() - ) - })?; + ensure_game_creator_private_directory_tree(&run_dir, "Agent run history 目录")?; + prepare_game_creator_private_path_for_read(&run_dir, true, "Agent run history 目录")?; let run_path = run_dir.join(format!("{}.json", trace.run_id)); - fs::write(&run_path, payload).map_err(|error| { - format!( - "写入 Agent run history 失败:{}: {error}", - run_path.display() - ) - })?; + write_game_creator_private_file(&run_path, payload.as_bytes(), "Agent run history")?; prune_agent_run_history(&run_dir) } diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/generation/trace.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/generation/trace.rs index 4b172857c..2a7f54ea8 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/generation/trace.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/generation/trace.rs @@ -309,20 +309,11 @@ pub(crate) fn append_preview_log( url: Option<&str>, ) -> Result<(), String> { let log_path = root.join(".agent/logs/preview.log"); - if let Some(parent) = log_path.parent() { - fs::create_dir_all(parent) - .map_err(|error| format!("创建预览日志目录失败:{}: {error}", parent.display()))?; - } let line = match url { Some(url) => format!("{} preview.{status} {url}\n", unix_timestamp()), None => format!("{} preview.{status}\n", unix_timestamp()), }; - fs::OpenOptions::new() - .create(true) - .append(true) - .open(&log_path) - .and_then(|mut file| file.write_all(line.as_bytes())) - .map_err(|error| format!("写入预览日志失败:{}: {error}", log_path.display())) + append_game_creator_private_file(&log_path, line.as_bytes(), "预览日志") } pub(crate) fn record_replaced_preview_stop(preview: &LocalPreviewResult) { @@ -340,6 +331,7 @@ pub(crate) fn append_agent_run_trace_step( error: Option<&str>, ) -> Result<(), String> { let trace_path = root.join(".agent/run.latest.json"); + prepare_game_creator_private_path_for_read(&trace_path, false, "Agent run trace")?; let content = match fs::read_to_string(&trace_path) { Ok(content) => content, Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(()), @@ -781,8 +773,8 @@ pub(crate) fn append_agent_run_jsonl( ) -> Result<(), String> { let path = root.join(relative_path); if let Some(parent) = path.parent() { - fs::create_dir_all(parent) - .map_err(|error| format!("创建 Agent 事件目录失败:{}: {error}", parent.display()))?; + ensure_game_creator_private_directory_tree(parent, "Agent 事件目录")?; + prepare_game_creator_private_path_for_read(parent, true, "Agent 事件目录")?; } let line = serde_json::to_string(value).map_err(|error| format!("序列化 Agent 事件失败:{error}"))?; @@ -795,12 +787,8 @@ pub(crate) fn write_agent_run_context_bundle( ) -> Result<(), String> { let bundle_path = root.join(".agent/context.bundle.json"); if let Some(parent) = bundle_path.parent() { - fs::create_dir_all(parent).map_err(|error| { - format!( - "创建 Agent context bundle 目录失败:{}: {error}", - parent.display() - ) - })?; + ensure_game_creator_private_directory_tree(parent, "Agent context bundle 目录")?; + prepare_game_creator_private_path_for_read(parent, true, "Agent context bundle 目录")?; } let manifest = read_manifest_for_project(root).ok(); let payload = serde_json::json!({ @@ -825,10 +813,5 @@ pub(crate) fn write_agent_run_context_bundle( }); let content = serde_json::to_string_pretty(&payload) .map_err(|error| format!("生成 Agent context bundle 失败:{error}"))?; - fs::write(&bundle_path, content).map_err(|error| { - format!( - "写入 Agent context bundle 失败:{}: {error}", - bundle_path.display() - ) - }) + write_game_creator_private_file(&bundle_path, content.as_bytes(), "Agent context bundle") } diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/autonomous_policy.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/autonomous_policy.rs index eeb2f00ff..3fa69f520 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/autonomous_policy.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/autonomous_policy.rs @@ -48,7 +48,9 @@ pub(in crate::agent) fn validate_agent_runtime_autonomous_complete_game_index_wr ) -> Result<(), String> { if path .and_then(serde_json::Value::as_str) - .map_or(true, |path| !is_agent_runtime_game_entry_relative_path(path)) + .map_or(true, |path| { + !is_agent_runtime_game_entry_relative_path(path) + }) { return Ok(()); } @@ -793,7 +795,7 @@ fn autonomous_manifest_parent_has_active_ready_task_at( let records = latest_game_creator_agent_runtime_tasks(read_all_game_creator_agent_runtime_tasks( &game_creator_agent_runtime_task_path(root, task_id), - )?); + )?); for record in records { if record.source != "agent-ready-task-scheduler" || game_creator_agent_runtime_terminal_status(&record).is_some() @@ -806,10 +808,9 @@ fn autonomous_manifest_parent_has_active_ready_task_at( // binding's root id solely to associate an in-flight child with // this root; never reject or block the child for a mismatch. if record.run_profile == AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD { - let parent_hint_matches = - record.parent_agent_id.as_deref() - == Some(GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID) - && record.parent_run_id.as_deref() == Some(parent_run_id); + let parent_hint_matches = record.parent_agent_id.as_deref() + == Some(GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID) + && record.parent_run_id.as_deref() == Some(parent_run_id); let binding_root_matches = read_game_creator_agent_runtime_run_profile_binding( root, &record.agent_id, @@ -821,8 +822,7 @@ fn autonomous_manifest_parent_has_active_ready_task_at( } continue; } - if record.parent_agent_id.as_deref() - != Some(GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID) + if record.parent_agent_id.as_deref() != Some(GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID) || record.parent_run_id.as_deref() != Some(parent_run_id) { continue; diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/pending_confirmation_ledger.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/pending_confirmation_ledger.rs index c1376c327..da897e1b8 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/pending_confirmation_ledger.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/pending_confirmation_ledger.rs @@ -338,7 +338,8 @@ pub(in crate::agent) fn validate_agent_runtime_pending_tool_action_record( validate_agent_runtime_pending_goal_binding(pending)?; match pending.planning_session_binding.as_ref() { Some(binding) => { - validate_plan_provider_session_binding(binding).map_err(|error| error.to_string())?; + validate_plan_provider_session_binding(binding) + .map_err(|error| error.to_string())?; if pending.action.tool.trim() != PLAN_SUBMIT_GDD_TOOL || binding.agent_id != pending.agent_id || binding.task_id != pending.task_id @@ -652,12 +653,8 @@ pub(crate) fn write_game_creator_agent_runtime_tool_confirmation( let path = game_creator_agent_runtime_tool_confirmation_path(root, agent_id, run_id, command_id); if let Some(parent) = path.parent() { - fs::create_dir_all(parent).map_err(|error| { - format!( - "创建 Agent Runtime 工具确认目录失败:{}: {error}", - parent.display() - ) - })?; + ensure_game_creator_private_directory_tree(parent, "Agent Runtime 工具确认目录")?; + prepare_game_creator_private_path_for_read(parent, true, "Agent Runtime 工具确认目录")?; } let payload = serde_json::json!({ "schemaVersion": AGENT_RUNTIME_SCHEMA_VERSION, @@ -670,12 +667,8 @@ pub(crate) fn write_game_creator_agent_runtime_tool_confirmation( }); let content = serde_json::to_string_pretty(&payload) .map_err(|error| format!("序列化 Agent Runtime 工具确认失败:{error}"))?; - fs::write(&path, content).map_err(|error| { - format!( - "写入 Agent Runtime 工具确认失败:{}: {error}", - path.display() - ) - }) + crate::write_game_creator_private_file(&path, content.as_bytes(), "Agent Runtime 工具确认")?; + Ok(()) } pub(in crate::agent) fn consume_game_creator_agent_runtime_tool_confirmation( @@ -690,6 +683,7 @@ pub(in crate::agent) fn consume_game_creator_agent_runtime_tool_confirmation( } let path = game_creator_agent_runtime_tool_confirmation_path(root, agent_id, run_id, command_id); + prepare_game_creator_private_path_for_read(&path, false, "Agent Runtime 工具确认")?; let content = match fs::read_to_string(&path) { Ok(content) => content, Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(false), diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/project_gates.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/project_gates.rs index 10b36970e..8f5b6eb8d 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/project_gates.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/project_gates.rs @@ -195,9 +195,9 @@ pub(crate) fn finish_agent_runtime_project_verification_locked( }; if passed && !html.contains(" 1); for tool in [ diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/provider_tool_plan.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/provider_tool_plan.rs index d2c75a829..e63d79b96 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/provider_tool_plan.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/provider_tool_plan.rs @@ -439,19 +439,18 @@ pub(in crate::agent) async fn request_game_creator_agent_background_tool_plan_at let code_prototype_requires_static_smoke = !relaxed_autonomous && run_profile == AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD && agent_id == "code-prototype"; - let verified_delivery = if !relaxed_autonomous - && run_profile == AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD - { - let verification_gate = - read_game_creator_agent_runtime_verification_gate(root, agent_id, run_id)?; - runtime_owner_artifact_validation_available - || agent_runtime_autonomous_verified_delivery_allows_plan_completion( - agent_id, - &verification_gate, - ) - } else { - false - }; + let verified_delivery = + if !relaxed_autonomous && run_profile == AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD { + let verification_gate = + read_game_creator_agent_runtime_verification_gate(root, agent_id, run_id)?; + runtime_owner_artifact_validation_available + || agent_runtime_autonomous_verified_delivery_allows_plan_completion( + agent_id, + &verification_gate, + ) + } else { + false + }; let allow_runtime_plan_completion = read_only_delivery || verified_delivery; let autonomous_project_verify_available = relaxed_autonomous || run_profile != AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD @@ -758,8 +757,7 @@ pub(in crate::agent) async fn request_game_creator_agent_background_tool_plan_at }; let parsed = match parsed { Ok((parsed, source_payload)) - if agent_id == GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID - && !relaxed_autonomous => + if agent_id == GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID && !relaxed_autonomous => { let collaboration_policy = resolve_supervisor_collaboration_policy_for_run_at(root, agent_id, run_id)? diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/finalization.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/finalization.rs index 5f146400a..6dae3634e 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/finalization.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/finalization.rs @@ -245,36 +245,39 @@ pub(in crate::agent) fn resume_game_creator_agent_finalization_at( let assistant_exists = game_creator_agent_runtime_finalization_assistant_exists(root, &journal)?; if !relaxed_autonomous { - match classify_game_creator_agent_runtime_finalization_goal_snapshot_at(root, &journal, &state)? - { - AgentRuntimeFinalizationGoalSnapshotRelation::Matches => {} - AgentRuntimeFinalizationGoalSnapshotRelation::StaleRevision { - journal_revision, - current_revision, - } if journal.status == AGENT_RUNTIME_FINALIZATION_STATUS_PREPARED && !assistant_exists => { - remove_game_creator_agent_runtime_finalization_recovery_sidecars( - root, - &journal.agent_id, - &journal.run_id, - )?; - let _ = append_agent_db_record( - root, - serde_json::json!({ - "recordType": "agent.runtime.background_task.finalization_stale_recovered", - "agentId": journal.agent_id, - "taskId": journal.task_id, - "sessionId": journal.session_id, - "runId": journal.run_id, - "source": journal.source, - "summary": "Goal revision 已更新,旧最终回复已丢弃", - "journalGoalRevision": journal_revision, - "currentGoalRevision": current_revision, - }), - ); - return Ok(AgentRuntimeFinalizationResume::NotFound(runtime_lock)); - } - relation => { - let detail = match relation { + match classify_game_creator_agent_runtime_finalization_goal_snapshot_at( + root, &journal, &state, + )? { + AgentRuntimeFinalizationGoalSnapshotRelation::Matches => {} + AgentRuntimeFinalizationGoalSnapshotRelation::StaleRevision { + journal_revision, + current_revision, + } if journal.status == AGENT_RUNTIME_FINALIZATION_STATUS_PREPARED + && !assistant_exists => + { + remove_game_creator_agent_runtime_finalization_recovery_sidecars( + root, + &journal.agent_id, + &journal.run_id, + )?; + let _ = append_agent_db_record( + root, + serde_json::json!({ + "recordType": "agent.runtime.background_task.finalization_stale_recovered", + "agentId": journal.agent_id, + "taskId": journal.task_id, + "sessionId": journal.session_id, + "runId": journal.run_id, + "source": journal.source, + "summary": "Goal revision 已更新,旧最终回复已丢弃", + "journalGoalRevision": journal_revision, + "currentGoalRevision": current_revision, + }), + ); + return Ok(AgentRuntimeFinalizationResume::NotFound(runtime_lock)); + } + relation => { + let detail = match relation { AgentRuntimeFinalizationGoalSnapshotRelation::StaleRevision { journal_revision, current_revision, @@ -284,14 +287,16 @@ pub(in crate::agent) fn resume_game_creator_agent_finalization_at( AgentRuntimeFinalizationGoalSnapshotRelation::Conflict(detail) => detail, AgentRuntimeFinalizationGoalSnapshotRelation::Matches => unreachable!(), }; - let error = format!("Agent Runtime finalization 恢复已阻断:Goal 快照冲突:{detail}"); - record_game_creator_agent_runtime_finalization_pending(root, &state, &error); - return read_game_creator_agent_runtime_at(root, agent_id) - .map(AgentRuntimeFinalizationResume::Blocked); - } + let error = + format!("Agent Runtime finalization 恢复已阻断:Goal 快照冲突:{detail}"); + record_game_creator_agent_runtime_finalization_pending(root, &state, &error); + return read_game_creator_agent_runtime_at(root, agent_id) + .map(AgentRuntimeFinalizationResume::Blocked); + } } } - if !relaxed_autonomous && assistant_exists + if !relaxed_autonomous + && assistant_exists && !state_reconstructed_from_task && !game_creator_agent_runtime_finalization_plan_matches_state(&journal, &state) { @@ -415,11 +420,13 @@ pub(in crate::agent) fn resume_game_creator_agent_finalization_at( goal_contract_acceptance_completion_blocker_at_locked(&root, &state) { Some(blocker) - } else if let Some(blocker) = agent_runtime_non_verification_completion_blocker_at_locked( - &root, - &journal.agent_id, - &journal.run_id, - ) { + } else if let Some(blocker) = + agent_runtime_non_verification_completion_blocker_at_locked( + &root, + &journal.agent_id, + &journal.run_id, + ) + { Some(blocker) } else if let Some(blocker) = autonomous_game_build_completion_blocker_at_locked(&root, &state) diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/main_loop.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/main_loop.rs index 611cfdc15..abcd741a8 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/main_loop.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/main_loop.rs @@ -810,101 +810,15 @@ async fn run_game_creator_agent_background_task_pass_without_deadline( // impossible for a relaxed run to read the DAG and accidentally // re-enter `waiting-for-manifest-tasks`. if !relaxed_autonomous { - let autonomous_root_goal_contract_persisted = - if agent_id == GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID - && runtime.run_profile == AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD - { - match autonomous_root_goal_contract_persisted_at( - &root, - &agent_id, - &runtime.run_id, - ) { - Ok(value) => value, - Err(error) => { - return fail_game_creator_agent_background_context_at( - &root, - &agent_id, - &session_id, - runtime, - &format!("读取自主构建根 Goal Contract 门失败:{error}"), - ); - } - } - } else { - false - }; - let autonomous_manifest_parent_can_wait = - autonomous_root_goal_contract_persisted - && agent_id == GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID - && runtime.run_profile == AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD - && !game_creator_agent_runtime_provider_action_batch_exists( - &root, - &agent_id, - &runtime.run_id, - ) - && supervisor_collaboration_policy_completion_blocker_at_locked( - &root, - &agent_id, - &runtime.run_id, - ) - .is_none() - && isolated_join_completion_blocker_at(&root, &agent_id, &runtime.run_id) - .is_none() - && static_delegate_completion_blocker_at(&root, &agent_id, &runtime.run_id) - .is_none(); - if autonomous_manifest_parent_can_wait { - let manifest_state_before_schedule = match autonomous_manifest_dag_state_at(&root) { - Ok(value) => value, - Err(error) => { - return fail_game_creator_agent_background_context_at( - &root, - &agent_id, - &session_id, - runtime, - &format!("读取自主构建 manifest 等待屏障失败:{error}"), - ); - } - }; - // 已登记但损坏的派生视觉需要先由父 Run 规划修复,因此此时不再调度新的 - // manifest child;但已经持久化并运行的 child 仍是当前 DAG 的活跃工作, - // 父 Run 必须继续等待,不能提前结束。 - // 同理,已有失败任务时只能等待已在途 child 收束或立即安全失败,不能再 - // 启动新的 pending sibling 并用它遮蔽原始失败。 - let manifest_scheduler_blocked = matches!( - &manifest_state_before_schedule, - AutonomousManifestDagState::Completed - | AutonomousManifestDagState::Failed { .. } - ) || autonomous_registered_derived_visuals_block_manifest_scheduler_at( - &root, &runtime, - ); - let scheduled_ready_tasks = if manifest_scheduler_blocked { - Vec::new() - } else { - match schedule_autonomous_game_build_ready_tasks_at( + let autonomous_root_goal_contract_persisted = if agent_id + == GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID + && runtime.run_profile == AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD + { + match autonomous_root_goal_contract_persisted_at( &root, &agent_id, &runtime.run_id, - 3, ) { - Ok(tasks) => tasks, - Err(error) => { - return fail_game_creator_agent_background_context_at( - &root, - &agent_id, - &session_id, - runtime, - &format!("调度自主构建 manifest 任务失败:{error}"), - ); - } - } - }; - let manifest_state = if matches!( - &manifest_state_before_schedule, - AutonomousManifestDagState::Failed { .. } - ) { - manifest_state_before_schedule - } else if scheduled_ready_tasks.is_empty() { - match autonomous_manifest_dag_state_at(&root) { Ok(value) => value, Err(error) => { return fail_game_creator_agent_background_context_at( @@ -912,68 +826,155 @@ async fn run_game_creator_agent_background_task_pass_without_deadline( &agent_id, &session_id, runtime, - &format!("读取自主构建 manifest 等待屏障失败:{error}"), + &format!("读取自主构建根 Goal Contract 门失败:{error}"), ); } } } else { - AutonomousManifestDagState::InProgress + false }; - let manifest_parent_must_wait = matches!( - &manifest_state, - AutonomousManifestDagState::InProgress - | AutonomousManifestDagState::Failed { - has_active_children: true, - .. - } - ); - if manifest_parent_must_wait { - let blocker = - autonomous_game_build_completion_blocker_at_locked(&root, &runtime) - .unwrap_or_else(|| AgentRuntimeToolObservation { - tool: "runtime.autonomous_completion".to_string(), - status: "blocked".to_string(), - summary: "自主构建 manifest DAG 尚未完成".to_string(), - detail: Some("manifest 子任务仍在运行".to_string()), - }); - if let Err(error) = persist_waiting_autonomous_manifest_parent_context_at( + let autonomous_manifest_parent_can_wait = autonomous_root_goal_contract_persisted + && agent_id == GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID + && runtime.run_profile == AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD + && !game_creator_agent_runtime_provider_action_batch_exists( &root, - &mut runtime, - &task, - &plan, - &mut observations, - loop_index, - &mut context_tracker, - blocker, + &agent_id, + &runtime.run_id, + ) + && supervisor_collaboration_policy_completion_blocker_at_locked( + &root, + &agent_id, + &runtime.run_id, + ) + .is_none() + && isolated_join_completion_blocker_at(&root, &agent_id, &runtime.run_id) + .is_none() + && static_delegate_completion_blocker_at(&root, &agent_id, &runtime.run_id) + .is_none(); + if autonomous_manifest_parent_can_wait { + let manifest_state_before_schedule = + match autonomous_manifest_dag_state_at(&root) { + Ok(value) => value, + Err(error) => { + return fail_game_creator_agent_background_context_at( + &root, + &agent_id, + &session_id, + runtime, + &format!("读取自主构建 manifest 等待屏障失败:{error}"), + ); + } + }; + // 已登记但损坏的派生视觉需要先由父 Run 规划修复,因此此时不再调度新的 + // manifest child;但已经持久化并运行的 child 仍是当前 DAG 的活跃工作, + // 父 Run 必须继续等待,不能提前结束。 + // 同理,已有失败任务时只能等待已在途 child 收束或立即安全失败,不能再 + // 启动新的 pending sibling 并用它遮蔽原始失败。 + let manifest_scheduler_blocked = matches!( + &manifest_state_before_schedule, + AutonomousManifestDagState::Completed + | AutonomousManifestDagState::Failed { .. } + ) + || autonomous_registered_derived_visuals_block_manifest_scheduler_at( + &root, &runtime, + ); + let scheduled_ready_tasks = if manifest_scheduler_blocked { + Vec::new() + } else { + match schedule_autonomous_game_build_ready_tasks_at( + &root, + &agent_id, + &runtime.run_id, + 3, + ) { + Ok(tasks) => tasks, + Err(error) => { + return fail_game_creator_agent_background_context_at( + &root, + &agent_id, + &session_id, + runtime, + &format!("调度自主构建 manifest 任务失败:{error}"), + ); + } + } + }; + let manifest_state = if matches!( + &manifest_state_before_schedule, + AutonomousManifestDagState::Failed { .. } ) { + manifest_state_before_schedule + } else if scheduled_ready_tasks.is_empty() { + match autonomous_manifest_dag_state_at(&root) { + Ok(value) => value, + Err(error) => { + return fail_game_creator_agent_background_context_at( + &root, + &agent_id, + &session_id, + runtime, + &format!("读取自主构建 manifest 等待屏障失败:{error}"), + ); + } + } + } else { + AutonomousManifestDagState::InProgress + }; + let manifest_parent_must_wait = matches!( + &manifest_state, + AutonomousManifestDagState::InProgress + | AutonomousManifestDagState::Failed { + has_active_children: true, + .. + } + ); + if manifest_parent_must_wait { + let blocker = + autonomous_game_build_completion_blocker_at_locked(&root, &runtime) + .unwrap_or_else(|| AgentRuntimeToolObservation { + tool: "runtime.autonomous_completion".to_string(), + status: "blocked".to_string(), + summary: "自主构建 manifest DAG 尚未完成".to_string(), + detail: Some("manifest 子任务仍在运行".to_string()), + }); + if let Err(error) = persist_waiting_autonomous_manifest_parent_context_at( + &root, + &mut runtime, + &task, + &plan, + &mut observations, + loop_index, + &mut context_tracker, + blocker, + ) { + return fail_game_creator_agent_background_context_at( + &root, + &agent_id, + &session_id, + runtime, + &format!("持久化 manifest 父 run 等待状态失败:{error}"), + ); + } + return AgentBackgroundTaskOutcome::WaitingForManifestTasks; + } + if let AutonomousManifestDagState::Failed { + failed_task_ids, .. + } = manifest_state + { return fail_game_creator_agent_background_context_at( &root, &agent_id, &session_id, runtime, - &format!("持久化 manifest 父 run 等待状态失败:{error}"), + &format!( + "自主构建 manifest 专业任务失败;failedTaskIds={}", + failed_task_ids.join(",") + ), ); } - return AgentBackgroundTaskOutcome::WaitingForManifestTasks; - } - if let AutonomousManifestDagState::Failed { - failed_task_ids, .. - } = manifest_state - { - return fail_game_creator_agent_background_context_at( - &root, - &agent_id, - &session_id, - runtime, - &format!( - "自主构建 manifest 专业任务失败;failedTaskIds={}", - failed_task_ids.join(",") - ), - ); } } } - } let mut resumed_provider_batch = if game_creator_agent_runtime_provider_action_batch_exists( &root, &agent_id, @@ -1706,7 +1707,9 @@ async fn run_game_creator_agent_background_task_pass_without_deadline( &agent_id, &runtime.run_id, ) - .or_else(|| process_session_completion_blocker_at(&root, &agent_id, &runtime.run_id)) + .or_else(|| { + process_session_completion_blocker_at(&root, &agent_id, &runtime.run_id) + }) .or_else(|| autonomous_game_build_completion_blocker_at_locked(&root, &runtime)) } else { structured_plan_completion_blocker(&runtime) @@ -1731,7 +1734,9 @@ async fn run_game_creator_agent_background_task_pass_without_deadline( .or_else(|| { game_creator_agent_goal_completion_blocker_at_locked(&root, &runtime) }) - .or_else(|| goal_contract_acceptance_completion_blocker_at_locked(&root, &runtime)) + .or_else(|| { + goal_contract_acceptance_completion_blocker_at_locked(&root, &runtime) + }) .or_else(|| { supervisor_collaboration_policy_completion_blocker_at_locked( &root, @@ -1790,26 +1795,22 @@ async fn run_game_creator_agent_background_task_pass_without_deadline( match art_wait_state { AutonomousCodePrototypeArtAssetWaitState::Waiting => { let next_loop_index = loop_index.saturating_add(1); - if let Err(error) = - persist_waiting_autonomous_manifest_child_context_at( - &root, - &mut runtime, - &task, - &plan, - &mut observations, - next_loop_index, - &mut context_tracker, - blocker, - ) - { + if let Err(error) = persist_waiting_autonomous_manifest_child_context_at( + &root, + &mut runtime, + &task, + &plan, + &mut observations, + next_loop_index, + &mut context_tracker, + blocker, + ) { return fail_game_creator_agent_background_context_at( &root, &agent_id, &session_id, runtime, - &format!( - "持久化 code-prototype 美术依赖等待状态失败:{error}" - ), + &format!("持久化 code-prototype 美术依赖等待状态失败:{error}"), ); } return AgentBackgroundTaskOutcome::WaitingForManifestTasks; diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/provider_recovery.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/provider_recovery.rs index 8e3368d3e..bc2ffdc35 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/provider_recovery.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/provider_recovery.rs @@ -178,13 +178,13 @@ async fn drive_waiting_autonomous_manifest_parent_wake_pass_with_budget( reconciliation_delay_ms: u64, request_deferred_rerun: bool, ) -> Result<(), String> { - if read_latest_game_creator_agent_runtime_task_by_run_id(root, agent_id, run_id)? - .is_some_and(|task| { + if read_latest_game_creator_agent_runtime_task_by_run_id(root, agent_id, run_id)?.is_some_and( + |task| { task.status == "running" && task.phase == "waiting-for-manifest-tasks" && autonomous_relaxed_run_profile(&task.run_profile) - }) - { + }, + ) { // Free-form autonomous runs never reconcile a manifest barrier. The // phase can only be legacy durable state, so make a bounded attempt to // resume it and leave any lane contention for the ordinary recovery @@ -317,7 +317,10 @@ pub(in crate::agent) fn autonomous_manifest_ready_task_waiting_child_record( && task.source == "agent-ready-task-scheduler" && task.run_profile == AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD && task.parent_agent_id.as_deref() == Some(GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID) - && task.parent_run_id.as_deref().is_some_and(|value| !value.trim().is_empty()) + && task + .parent_run_id + .as_deref() + .is_some_and(|value| !value.trim().is_empty()) && task.delegation_id.is_none() } @@ -327,9 +330,9 @@ async fn drive_waiting_autonomous_manifest_child_wake_pass( run_id: &str, ) -> Result<(), String> { for _ in 0..AUTONOMOUS_MANIFEST_PARENT_WAKE_MAX_ATTEMPTS { - let Some(task) = read_latest_game_creator_agent_runtime_task_by_run_id( - root, agent_id, run_id, - )? else { + let Some(task) = + read_latest_game_creator_agent_runtime_task_by_run_id(root, agent_id, run_id)? + else { return Ok(()); }; if !autonomous_manifest_ready_task_waiting_child_record(&task) { @@ -1025,6 +1028,7 @@ fn read_raw_autonomous_manifest_parent_runtime_state_at( agent_id: &str, ) -> Result { let path = game_creator_agent_runtime_session_path(root, agent_id); + prepare_game_creator_private_path_for_read(&path, false, "项目任务图父 Runtime 原始状态")?; let content = fs::read_to_string(&path).map_err(|error| { format!( "读取项目任务图父 Runtime 原始状态失败:{}: {error}", @@ -1047,12 +1051,8 @@ fn write_raw_autonomous_manifest_parent_runtime_state_at( let parent = path .parent() .ok_or_else(|| "项目任务图父 Runtime 状态路径缺少父目录".to_string())?; - fs::create_dir_all(parent).map_err(|error| { - format!( - "创建项目任务图父 Runtime 状态目录失败:{}: {error}", - parent.display() - ) - })?; + ensure_game_creator_private_directory_tree(parent, "项目任务图父 Runtime 状态目录")?; + prepare_game_creator_private_path_for_read(parent, true, "项目任务图父 Runtime 状态目录")?; let content = serde_json::to_string_pretty(state) .map_err(|error| format!("序列化项目任务图父 Runtime 状态失败:{error}"))?; let temp_path = path.with_file_name(format!( @@ -1063,12 +1063,11 @@ fn write_raw_autonomous_manifest_parent_runtime_state_at( std::process::id(), unix_timestamp_nanos() )); - fs::write(&temp_path, format!("{content}\n")).map_err(|error| { - format!( - "写入项目任务图父 Runtime 临时状态失败:{}: {error}", - temp_path.display() - ) - })?; + crate::write_game_creator_private_file( + &temp_path, + format!("{content}\n").as_bytes(), + "项目任务图父 Runtime 临时状态", + )?; fs::rename(&temp_path, &path).map_err(|error| { let _ = fs::remove_file(&temp_path); format!( @@ -1076,7 +1075,9 @@ fn write_raw_autonomous_manifest_parent_runtime_state_at( temp_path.display(), path.display() ) - }) + })?; + prepare_game_creator_private_path_for_read(&path, false, "项目任务图父 Runtime 原始状态")?; + Ok(()) } fn append_autonomous_manifest_parent_wake_reconciliation_task_at_locked( diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/recovery_scan.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/recovery_scan.rs index 97b563b19..4d06d8a7a 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/recovery_scan.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/recovery_scan.rs @@ -1628,23 +1628,24 @@ pub(in crate::agent) fn resume_game_creator_agent_background_tasks_unredacted_at continue; } AutonomousCodePrototypeArtAssetWaitState::NotWaiting => { - let parent_agent_id = task.parent_agent_id.as_deref().ok_or_else(|| { - "code-prototype 等待态缺少 parentAgentId".to_string() - })?; - let parent_run_id = task.parent_run_id.as_deref().ok_or_else(|| { - "code-prototype 等待态缺少 parentRunId".to_string() - })?; + let parent_agent_id = task + .parent_agent_id + .as_deref() + .ok_or_else(|| "code-prototype 等待态缺少 parentAgentId".to_string())?; + let parent_run_id = task + .parent_run_id + .as_deref() + .ok_or_else(|| "code-prototype 等待态缺少 parentRunId".to_string())?; // The child execution lane is held by this recovery // scan. Release it before the parent scheduler tries // to reacquire the deterministic child lane. drop(runtime_lock); - let scheduled_ready_tasks = - schedule_autonomous_game_build_ready_tasks_at( - root, - parent_agent_id, - parent_run_id, - 3, - )?; + let scheduled_ready_tasks = schedule_autonomous_game_build_ready_tasks_at( + root, + parent_agent_id, + parent_run_id, + 3, + )?; if !scheduled_ready_tasks.is_empty() { resumed.push(read_game_creator_agent_runtime_at(root, &agent_id)?); } diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/task_start.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/task_start.rs index b2bf942d2..45571d8c9 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/task_start.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/task_start.rs @@ -817,12 +817,10 @@ fn autonomous_root_goal_contract_persisted_for_binding_at( root: &Path, binding: &AgentRuntimeRunProfileBinding, ) -> Result { - Ok(read_game_creator_agent_runtime_goal_contract_at( - root, - &binding.agent_id, - &binding.run_id, - )? - .is_some()) + Ok( + read_game_creator_agent_runtime_goal_contract_at(root, &binding.agent_id, &binding.run_id)? + .is_some(), + ) } /// Return whether the trusted autonomous root has a valid, persisted Goal @@ -1099,11 +1097,7 @@ fn queue_or_recover_autonomous_manifest_ready_task_at( result.accepted_run_id = Some(record.run_id.clone()); return Ok((result, (*record).clone(), true)); } - let run_id = format!( - "autonomous-ready-{}-{}", - task.id, - unix_timestamp_nanos() - ); + let run_id = format!("autonomous-ready-{}-{}", task.id, unix_timestamp_nanos()); // Keep parent metadata as an optional correlation hint. Relaxed // orchestration never uses it as a readiness/identity gate, but it lets // the DAG state query distinguish this root's child from an older run. @@ -1222,16 +1216,13 @@ pub(crate) fn schedule_autonomous_game_build_ready_tasks_at( // status change makes subsequent passes ignore it. match existing { None => candidates.push((task.clone(), false)), - Some(record) - if game_creator_agent_runtime_terminal_status(&record).is_some() => - { + Some(record) if game_creator_agent_runtime_terminal_status(&record).is_some() => { terminal_records.push(record); } Some(_) => {} } } - for task_id in autonomous_manifest_ready_task_ids(&manifest.tasks, &parent_binding.source) - { + for task_id in autonomous_manifest_ready_task_ids(&manifest.tasks, &parent_binding.source) { if let Some(task) = manifest.tasks.iter().find(|task| task.id == task_id) { candidates.push((task.clone(), true)); } @@ -1504,7 +1495,9 @@ pub(in crate::agent) fn project_autonomous_manifest_ready_task_terminal_at_locke let manifest = read_manifest_for_project(root)?; let task_id = [state.task_id.trim(), state.agent_id.trim()] .into_iter() - .find(|candidate| !candidate.is_empty() && manifest.tasks.iter().any(|task| task.id == *candidate)); + .find(|candidate| { + !candidate.is_empty() && manifest.tasks.iter().any(|task| task.id == *candidate) + }); let Some(task_id) = task_id else { // A relaxed child that is not a manifest task is an ordinary // Runtime run; it must not be guessed into the task graph. @@ -1571,9 +1564,7 @@ pub(in crate::agent) fn project_autonomous_manifest_ready_task_terminal_at_locke { let status = match state.phase.as_str() { "completed" => GameCreationAppTaskStatus::Completed, - "failed" | "cancelled" | "budget-exhausted" => { - GameCreationAppTaskStatus::Failed - } + "failed" | "cancelled" | "budget-exhausted" => GameCreationAppTaskStatus::Failed, _ => return Ok(false), }; let manifest = read_manifest_for_project(root)?; @@ -1775,7 +1766,8 @@ pub(super) fn autonomous_manifest_ready_task_requires_visual_asset(task_id: &str fn render_autonomous_manifest_ready_task_owner_prompt(task: &GameCreationAppTaskState) -> String { let base = render_manifest_ready_task_background_prompt(task); let paths = autonomous_manifest_owner_artifact_paths(&task.id).join(", "); - let visual_usage_requirement = if task.id == "code-prototype" && editor_api_key_is_configured() { + let visual_usage_requirement = if task.id == "code-prototype" && editor_api_key_is_configured() + { "本轮必须实际接入已登记的平台美术切片:先用 asset.list 读取 assets/art-spritesheet-slices/manifest.json,再在 game/index.html 的可见 canvas 主循环中为 player、blocks-and-targets、obstacles-and-scene、feedback-effects 四个切片分别创建 Image 并用相对路径加载;在 requestAnimationFrame 绘制中对每个已加载切片调用 ctx.drawImage(image, dx, dy, dw, dh) 或九参数裁剪形式,目标区域必须可见且至少 32×32。只放置 /、只展示整张 assets/art-spritesheet.png、只写路径或只在注释中引用都不满足完成合同。" } else { "" @@ -1981,10 +1973,9 @@ mod tests { "art-director", &prompt )); - let art_asset_prompt = - render_autonomous_manifest_ready_task_background_prompt(&seed_task( - "art-asset-plan", - )); + let art_asset_prompt = render_autonomous_manifest_ready_task_background_prompt( + &seed_task("art-asset-plan"), + ); assert!(art_asset_prompt.contains("canvas.asset_generate")); assert!(art_asset_prompt.contains("asset.list")); assert!(art_asset_prompt.contains("file.write 写入 assets/manifest.art.json")); diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/autonomous_completion.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/autonomous_completion.rs index 455b56916..a1451e93a 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/autonomous_completion.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/autonomous_completion.rs @@ -60,8 +60,10 @@ pub(in crate::agent) fn autonomous_relaxed_run_at( { return Ok(autonomous_relaxed_run_profile(&binding.profile)); } - Ok(read_latest_game_creator_agent_runtime_task_by_run_id(root, agent_id, run_id)? - .is_some_and(|task| autonomous_relaxed_run_profile(&task.run_profile))) + Ok( + read_latest_game_creator_agent_runtime_task_by_run_id(root, agent_id, run_id)? + .is_some_and(|task| autonomous_relaxed_run_profile(&task.run_profile)), + ) } pub(crate) fn autonomous_game_build_root_run_active_at(root: &Path) -> bool { @@ -669,11 +671,11 @@ pub(in crate::agent) fn autonomous_code_prototype_art_asset_wait_state_at( .map(|task| task.status.clone()) .ok_or_else(|| "当前 seed manifest 缺少 art-asset-plan".to_string())?; match art_status { - GameCreationAppTaskStatus::Failed => Ok( - AutonomousCodePrototypeArtAssetWaitState::DependencyFailed( + GameCreationAppTaskStatus::Failed => { + Ok(AutonomousCodePrototypeArtAssetWaitState::DependencyFailed( "art-asset-plan 已失败,code-prototype 不能继续等待美术回执".to_string(), - ), - ), + )) + } GameCreationAppTaskStatus::Pending | GameCreationAppTaskStatus::Running | GameCreationAppTaskStatus::WaitingForConfirmation => { @@ -5398,12 +5400,10 @@ impl<'a> VisitJavascript<'a> for JavascriptCanvasVisualCollector<'_> { .static_property_name() .is_some_and(|name| name == "src") { - let Some((root, path)) = - javascript_expression_root_symbol_and_member_path( - member.object(), - self.scoping, - ) - else { + let Some((root, path)) = javascript_expression_root_symbol_and_member_path( + member.object(), + self.scoping, + ) else { oxc_ast_visit::walk::walk_assignment_expression(self, assignment); return; }; @@ -6655,7 +6655,7 @@ pub(in crate::agent) fn read_autonomous_evidence_file_at( Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None), Err(error) => return Err(format!("读取{label}元数据失败:{error}")), } - let (mut file, metadata) = open_project_snapshot_regular_file(&path, label)?; + let (mut file, metadata) = open_project_private_regular_file(&path, label)?; if metadata.len() > max_bytes { return Err(format!("{label}超过 {max_bytes} 字节上限")); } diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/context_bundle.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/context_bundle.rs index f10caee1d..75ed45731 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/context_bundle.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/context_bundle.rs @@ -343,6 +343,7 @@ pub(crate) fn write_game_creator_agent_runtime_context_bundle( pub(in crate::agent) fn read_game_creator_agent_runtime_context_bundle_content( path: &Path, ) -> Result { + prepare_game_creator_private_path_for_read(path, false, "Agent Runtime context bundle")?; let mut options = fs::OpenOptions::new(); options.read(true); #[cfg(unix)] diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/json_sidecar.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/json_sidecar.rs index fa526579e..7b22c601d 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/json_sidecar.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/json_sidecar.rs @@ -17,8 +17,11 @@ pub(crate) fn remove_agent_runtime_json_sidecar_backup( Ok(metadata) if metadata.file_type().is_symlink() || !metadata.is_file() => { Err(format!("{label} 恢复副本必须是普通文件")) } - Ok(_) => fs::remove_file(path) - .map_err(|error| format!("删除 {label} 恢复副本失败:{}: {error}", path.display())), + Ok(_) => { + crate::prepare_game_creator_private_path_for_read(path, false, label)?; + fs::remove_file(path) + .map_err(|error| format!("删除 {label} 恢复副本失败:{}: {error}", path.display())) + } Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()), Err(error) => Err(format!( "读取 {label} 恢复副本元数据失败:{}: {error}", @@ -53,10 +56,16 @@ where let primary_path = resolve_local_project_path(root, relative_path)?; let backup_path = agent_runtime_json_sidecar_backup_path(&primary_path); let (path, metadata) = match fs::symlink_metadata(&primary_path) { - Ok(metadata) => (primary_path, metadata), + Ok(metadata) => { + crate::prepare_game_creator_private_path_for_read(&primary_path, false, label)?; + (primary_path, metadata) + } Err(error) if error.kind() == std::io::ErrorKind::NotFound => { match fs::symlink_metadata(&backup_path) { - Ok(metadata) => (backup_path, metadata), + Ok(metadata) => { + crate::prepare_game_creator_private_path_for_read(&backup_path, false, label)?; + (backup_path, metadata) + } Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None), Err(error) => { return Err(format!( @@ -137,10 +146,11 @@ where } let mut path = resolve_local_project_path(root, relative_path)?; if let Some(parent) = path.parent() { - fs::create_dir_all(parent) - .map_err(|error| format!("创建 {label} 目录失败:{}: {error}", parent.display()))?; + crate::ensure_game_creator_private_directory_tree(parent, label)?; + crate::prepare_game_creator_private_path_for_read(parent, true, label)?; } path = resolve_local_project_path(root, relative_path)?; + crate::prepare_game_creator_private_path_for_read(&path, false, label)?; if let Ok(metadata) = fs::symlink_metadata(&path) { if metadata.file_type().is_symlink() || !metadata.is_file() { return Err(format!("{label} 必须是普通文件")); @@ -163,12 +173,22 @@ where temp_options.custom_flags(libc::O_NOFOLLOW); temp_options.mode(0o600); } + #[cfg(windows)] + { + use std::os::windows::fs::OpenOptionsExt; + temp_options.custom_flags(crate::PROJECT_FILE_FLAG_OPEN_REPARSE_POINT); + } let mut temp_file = temp_options.open(&temp_path).map_err(|error| { format!( "创建 {label} 临时文件失败:{}: {error}", temp_path.display() ) })?; + if let Err(error) = crate::harden_new_game_creator_private_path(&temp_path, false, label) { + drop(temp_file); + let _ = fs::remove_file(&temp_path); + return Err(error); + } temp_file .write_all(content.as_bytes()) .and_then(|_| temp_file.sync_data()) @@ -184,6 +204,7 @@ where Ok(()) => { remove_agent_runtime_json_sidecar_backup(&backup_path, label)?; sync_agent_runtime_sidecar_parent(&path, label)?; + crate::prepare_game_creator_private_path_for_read(&path, false, label)?; Ok(()) } Err(replace_error) => { @@ -192,7 +213,13 @@ where Err(error) if error.kind() == std::io::ErrorKind::NotFound => { return match fs::rename(&temp_path, &path) { Ok(()) => remove_agent_runtime_json_sidecar_backup(&backup_path, label) - .and_then(|_| sync_agent_runtime_sidecar_parent(&path, label)), + .and_then(|_| sync_agent_runtime_sidecar_parent(&path, label)) + .and_then(|_| { + crate::prepare_game_creator_private_path_for_read( + &path, false, label, + ) + .map(|_| ()) + }), Err(retry_error) => { let _ = fs::remove_file(&temp_path); Err(format!( @@ -231,6 +258,7 @@ where Ok(()) => { remove_agent_runtime_json_sidecar_backup(&backup_path, label)?; sync_agent_runtime_sidecar_parent(&path, label)?; + crate::prepare_game_creator_private_path_for_read(&path, false, label)?; Ok(()) } Err(error) => { diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/planning_storage.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/planning_storage.rs index fba95f5d6..cfecae6f4 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/planning_storage.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/planning_storage.rs @@ -3555,6 +3555,10 @@ fn ensure_planning_parent(path: &Path) -> Result<&Path, PlanningStorageError> { let parent = path .parent() .ok_or_else(|| PlanningStorageError::new("PLAN_INVALID_PATH", "规划文件缺少父目录"))?; + crate::ensure_game_creator_private_directory_tree(parent, "planning 父目录") + .map_err(|error| PlanningStorageError::new("PLAN_UNTRUSTED_PATH", error))?; + crate::prepare_game_creator_private_path_for_read(parent, true, "planning 父目录") + .map_err(|error| PlanningStorageError::new("PLAN_UNTRUSTED_PATH", error))?; // Create missing components one at a time. `create_dir_all` can follow a // directory symlink inserted between its internal component checks; the // explicit loop lets us reject every component immediately after creation. @@ -3650,6 +3654,8 @@ fn verify_regular_planning_file( path: &Path, label: &str, ) -> Result { + crate::prepare_game_creator_private_path_for_read(path, false, label) + .map_err(|error| PlanningStorageError::new("PLAN_UNTRUSTED_PATH", error))?; let metadata = fs::symlink_metadata(path) .map_err(|error| io_error(&format!("读取 {label} 元数据失败"), error))?; if planning_metadata_is_link_or_reparse(&metadata) || !metadata.is_file() { @@ -3810,11 +3816,17 @@ fn write_sync_new_file(path: &Path, bytes: &[u8], label: &str) -> Result<(), Pla .open(path) .map_err(|error| io_error(&format!("创建 {label} 临时文件失败"), error))?; #[cfg(windows)] + crate::prepare_game_creator_private_path_for_read(path, false, label) + .map_err(|error| PlanningStorageError::new("PLAN_UNTRUSTED_PATH", error))?; + #[cfg(windows)] crate::runner::validate_windows_regular_file_handle(&file, label) .map_err(|error| PlanningStorageError::new("PLAN_UNTRUSTED_PATH", error))?; file.write_all(bytes) .and_then(|_| file.sync_all()) .map_err(|error| io_error(&format!("写入 {label} 临时文件失败"), error))?; + #[cfg(windows)] + crate::prepare_game_creator_private_path_for_read(path, false, label) + .map_err(|error| PlanningStorageError::new("PLAN_UNTRUSTED_PATH", error))?; #[cfg(unix)] { use std::os::unix::fs::MetadataExt; diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/provider_control.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/provider_control.rs index 7d7ebc0f0..df2af105f 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/provider_control.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/provider_control.rs @@ -496,12 +496,13 @@ fn write_provider_reconciliation_diagnostic_in_dir( let directory = config_dir .join(PROVIDER_RECONCILIATION_DIAGNOSTIC_RELATIVE_ROOT) .join(&project_key); - fs::create_dir_all(&directory) - .map_err(|error| format!("创建本地 Provider 诊断目录失败:{error}"))?; + crate::ensure_game_creator_private_directory_tree(&directory, "本地 Provider 诊断目录")?; + crate::prepare_game_creator_private_path_for_read(&directory, true, "本地 Provider 诊断目录")?; let relative_path = format!( "{PROVIDER_RECONCILIATION_DIAGNOSTIC_RELATIVE_ROOT}/{project_key}/{request_key}.json" ); let path = directory.join(format!("{request_key}.json")); + crate::prepare_game_creator_private_path_for_read(&path, false, "本地 Provider 诊断")?; if let Ok(metadata) = fs::symlink_metadata(&path) { if metadata.file_type().is_symlink() || !metadata.is_file() { return Err("本地 Provider 诊断目标必须是普通文件".to_string()); @@ -550,6 +551,11 @@ fn write_provider_reconciliation_diagnostic_in_dir( )); } let temporary = path.with_file_name(format!(".{request_key}.tmp.{}", unix_timestamp_nanos())); + crate::prepare_game_creator_private_path_for_read( + &temporary, + false, + "本地 Provider 诊断临时文件", + )?; let mut options = fs::OpenOptions::new(); options.write(true).create_new(true); #[cfg(unix)] @@ -557,9 +563,21 @@ fn write_provider_reconciliation_diagnostic_in_dir( use std::os::unix::fs::OpenOptionsExt; options.mode(0o600); } + #[cfg(windows)] + { + use std::os::windows::fs::OpenOptionsExt; + options.custom_flags(crate::PROJECT_FILE_FLAG_OPEN_REPARSE_POINT); + } let mut file = options .open(&temporary) .map_err(|error| format!("创建本地 Provider 诊断临时文件失败:{error}"))?; + if let Err(error) = + crate::harden_new_game_creator_private_path(&temporary, false, "本地 Provider 诊断临时文件") + { + drop(file); + let _ = fs::remove_file(&temporary); + return Err(error); + } if let Err(error) = file .write_all(content.as_bytes()) .and_then(|_| file.sync_data()) @@ -572,6 +590,7 @@ fn write_provider_reconciliation_diagnostic_in_dir( let _ = fs::remove_file(&temporary); return Err(format!("安装本地 Provider 诊断失败:{error}")); } + crate::prepare_game_creator_private_path_for_read(&path, false, "本地 Provider 诊断")?; Ok(relative_path) } diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/real_e2e_checkpoint.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/real_e2e_checkpoint.rs index 16b519b90..154ba513a 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/real_e2e_checkpoint.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/real_e2e_checkpoint.rs @@ -305,8 +305,10 @@ impl AgentRuntimeRealE2eCheckpointAppData { .map_err(|_| ())?; crate::runner::validate_windows_regular_file_handle(&file, "real E2E 私有文件") .map_err(|_| ())?; - secure_windows_game_creator_path_for_current_user(&path, false, false) - .map_err(|_| ()) + secure_windows_game_creator_path_for_current_user_with_auto_elevation( + &path, false, false, + ) + .map_err(|_| ()) })(); if secured.is_err() { drop(file); diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/steering.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/steering.rs index 6ec369a4e..2a4831d9c 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/steering.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/steering.rs @@ -334,12 +334,15 @@ pub(in crate::agent) fn append_game_creator_agent_runtime_steer_record( ) -> Result<(), String> { let path = game_creator_agent_runtime_steer_ledger_path(root, &record.agent_id, &record.run_id); if let Some(parent) = path.parent() { - fs::create_dir_all(parent).map_err(|error| { - format!( - "创建 Agent Runtime steer ledger 目录失败:{}: {error}", - parent.display() - ) - })?; + crate::ensure_game_creator_private_directory_tree( + parent, + "Agent Runtime steer ledger 目录", + )?; + crate::prepare_game_creator_private_path_for_read( + parent, + true, + "Agent Runtime steer ledger 目录", + )?; } if fs::symlink_metadata(&path) .ok() diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_state.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_state.rs index 2321d48a6..314de29b6 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_state.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_state.rs @@ -2181,12 +2181,8 @@ pub(crate) fn write_game_creator_agent_runtime_cancel_request( ) -> Result<(), String> { let path = game_creator_agent_runtime_cancel_path(root, agent_id, run_id); if let Some(parent) = path.parent() { - fs::create_dir_all(parent).map_err(|error| { - format!( - "创建 Agent Runtime 取消目录失败:{}: {error}", - parent.display() - ) - })?; + ensure_game_creator_private_directory_tree(parent, "Agent Runtime 取消目录")?; + prepare_game_creator_private_path_for_read(parent, true, "Agent Runtime 取消目录")?; } let payload = serde_json::json!({ "agentId": agent_id, @@ -2196,12 +2192,12 @@ pub(crate) fn write_game_creator_agent_runtime_cancel_request( }); let content = serde_json::to_string_pretty(&payload) .map_err(|error| format!("序列化 Agent Runtime 取消请求失败:{error}"))?; - fs::write(&path, format!("{content}\n")).map_err(|error| { - format!( - "写入 Agent Runtime 取消请求失败:{}: {error}", - path.display() - ) - }) + crate::write_game_creator_private_file( + &path, + format!("{content}\n").as_bytes(), + "Agent Runtime 取消请求", + )?; + Ok(()) } pub(super) fn write_non_terminal_isolated_child_cancel_tombstones_for_parent_at( @@ -2731,13 +2727,10 @@ pub(crate) fn write_game_creator_agent_runtime_state( } let path = game_creator_agent_runtime_session_path(root, &state.agent_id); if let Some(parent) = path.parent() { - fs::create_dir_all(parent).map_err(|error| { - format!( - "创建 Agent Runtime 状态目录失败:{}: {error}", - parent.display() - ) - })?; + ensure_game_creator_private_directory_tree(parent, "Agent Runtime 状态目录")?; + prepare_game_creator_private_path_for_read(parent, true, "Agent Runtime 状态目录")?; } + prepare_game_creator_private_path_for_read(&path, false, "Agent Runtime 状态")?; let content = serde_json::to_string_pretty(state) .map_err(|error| format!("序列化 Agent Runtime 状态失败:{error}"))?; let temp_path = path.with_file_name(format!( @@ -2748,12 +2741,36 @@ pub(crate) fn write_game_creator_agent_runtime_state( std::process::id(), unix_timestamp_nanos() )); - fs::write(&temp_path, format!("{content}\n")).map_err(|error| { + let mut temp_options = fs::OpenOptions::new(); + temp_options.write(true).create_new(true); + #[cfg(windows)] + { + use std::os::windows::fs::OpenOptionsExt; + temp_options.custom_flags(crate::PROJECT_FILE_FLAG_OPEN_REPARSE_POINT); + } + let mut temp_file = temp_options.open(&temp_path).map_err(|error| { format!( - "写入 Agent Runtime 临时状态失败:{}: {error}", + "创建 Agent Runtime 临时状态失败:{}: {error}", temp_path.display() ) })?; + if let Err(error) = + harden_new_game_creator_private_path(&temp_path, false, "Agent Runtime 临时状态") + { + drop(temp_file); + let _ = fs::remove_file(&temp_path); + return Err(error); + } + temp_file + .write_all(format!("{content}\n").as_bytes()) + .and_then(|_| temp_file.sync_all()) + .map_err(|error| { + format!( + "写入 Agent Runtime 临时状态失败:{}: {error}", + temp_path.display() + ) + })?; + drop(temp_file); fs::rename(&temp_path, &path).map_err(|error| { let _ = fs::remove_file(&temp_path); format!( @@ -2761,7 +2778,9 @@ pub(crate) fn write_game_creator_agent_runtime_state( temp_path.display(), path.display() ) - }) + })?; + prepare_game_creator_private_path_for_read(&path, false, "Agent Runtime 状态")?; + Ok(()) } pub(super) fn append_game_creator_agent_runtime_event( @@ -2812,12 +2831,8 @@ pub(super) fn append_game_creator_agent_runtime_event_with_action( ) -> Result<(), String> { let path = game_creator_agent_runtime_event_path(root, &state.agent_id); if let Some(parent) = path.parent() { - fs::create_dir_all(parent).map_err(|error| { - format!( - "创建 Agent Runtime 事件目录失败:{}: {error}", - parent.display() - ) - })?; + ensure_game_creator_private_directory_tree(parent, "Agent Runtime 事件目录")?; + prepare_game_creator_private_path_for_read(parent, true, "Agent Runtime 事件目录")?; } let failure_detail = matches!( event_type, @@ -3342,18 +3357,18 @@ fn append_unique_game_creator_agent_runtime_task_with_initial_state( // strict/legacy profiles preserves their existing recovery behavior. if !autonomous_relaxed_run_profile(&record.run_profile) { if let Err(error) = ensure_autonomous_completion_contract_for_task_at(root, &record) { - let public_error = redact_agent_runtime_project_paths(root, &error, 500); - let failed = AgentRuntimeTaskRecord { - status: "failed".to_string(), - phase: "completion-contract-failed".to_string(), - current_action: "自主构建完成合同未能建立,任务未执行".to_string(), - terminal_detail: Some(public_error.clone()), - error: Some(public_error), - updated_at: unix_timestamp(), - ..record.clone() - }; - append_game_creator_agent_runtime_task_record_unlocked(root, &failed)?; - return Err(format!("自主构建完成合同建立失败,任务未执行:{error}")); + let public_error = redact_agent_runtime_project_paths(root, &error, 500); + let failed = AgentRuntimeTaskRecord { + status: "failed".to_string(), + phase: "completion-contract-failed".to_string(), + current_action: "自主构建完成合同未能建立,任务未执行".to_string(), + terminal_detail: Some(public_error.clone()), + error: Some(public_error), + updated_at: unix_timestamp(), + ..record.clone() + }; + append_game_creator_agent_runtime_task_record_unlocked(root, &failed)?; + return Err(format!("自主构建完成合同建立失败,任务未执行:{error}")); } } Ok(record) @@ -3489,12 +3504,8 @@ pub(super) fn append_game_creator_agent_runtime_task_record_unlocked( } let path = game_creator_agent_runtime_task_path(root, &record.agent_id); if let Some(parent) = path.parent() { - fs::create_dir_all(parent).map_err(|error| { - format!( - "创建 Agent Runtime 任务目录失败:{}: {error}", - parent.display() - ) - })?; + ensure_game_creator_private_directory_tree(parent, "Agent Runtime 任务目录")?; + prepare_game_creator_private_path_for_read(parent, true, "Agent Runtime 任务目录")?; } let line = serde_json::to_string(&record) .map_err(|error| format!("序列化 Agent Runtime 任务失败:{error}"))?; diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/skill_pack.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/skill_pack.rs index b8ec6932a..ee516df4e 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/skill_pack.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/skill_pack.rs @@ -275,8 +275,8 @@ pub(crate) fn read_agc_skill_resource( pub(crate) fn install_agc_skill_pack(isolated_os_home: &Path) -> Result { let manifest = validated_skill_pack_manifest()?; let skills_root = isolated_os_home.join(".agents").join("skills"); - std::fs::create_dir_all(&skills_root) - .map_err(|error| format!("创建隔离 AGC Skill 目录失败:{error}"))?; + crate::ensure_game_creator_private_directory_tree(&skills_root, "隔离 AGC Skill 目录")?; + crate::prepare_game_creator_private_path_for_read(&skills_root, true, "隔离 AGC Skill 目录")?; for entry in &manifest.skills { for relative in &entry.files { let bundled_path = format!("{}/{}", entry.name, relative.replace('\\', "/")); @@ -285,11 +285,18 @@ pub(crate) fn install_agc_skill_pack(isolated_os_home: &Path) -> Result Result { fn private_external_editor_api_credentials_from_file_at( path: &Path, ) -> Result, String> { - let metadata = match fs::symlink_metadata(path) { - Ok(metadata) => metadata, - Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None), - Err(error) => { - return Err(format!("读取本机陶泥儿开发者 Key 配置失败:{error}")); - } - }; - if metadata.file_type().is_symlink() || !metadata.is_file() { - return Err("本机陶泥儿开发者 Key 配置必须是普通文件".to_string()); + if !crate::prepare_game_creator_private_path_for_read(path, false, "本机陶泥儿开发者 Key 文件")? + { + return Ok(None); } + let metadata = fs::symlink_metadata(path) + .map_err(|error| format!("读取本机陶泥儿开发者 Key 配置失败:{error}"))?; if metadata.len() > PRIVATE_EXTERNAL_EDITOR_API_KEY_MAX_BYTES { return Err("本机陶泥儿开发者 Key 配置过大,已拒绝读取".to_string()); } - #[cfg(windows)] - secure_windows_game_creator_path_for_current_user(path, false, false)?; - let content = fs::read_to_string(path) - .map_err(|error| format!("读取本机陶泥儿开发者 Key 配置失败:{error}"))?; + let content = crate::read_game_creator_private_file_to_string( + path, + "本机陶泥儿开发者 Key 配置", + PRIVATE_EXTERNAL_EDITOR_API_KEY_MAX_BYTES, + )?; let parsed = serde_json::from_str::(&content) .map_err(|_| "本机陶泥儿开发者 Key 配置格式无效,请重新登录客户端后重试".to_string())?; let api_key = normalize_external_editor_api_key(&parsed.api_key)?; @@ -162,6 +159,22 @@ fn private_external_editor_api_credentials_from_file_at( .as_deref() .unwrap_or(DEFAULT_CANVAS_SYNC_API_BASE_URL), )?; + let expected_fingerprint = format!("{:x}", Sha256::digest(api_base_url.as_bytes())); + let actual_fingerprint = path + .file_name() + .and_then(|value| value.to_str()) + .and_then(|value| { + value + .strip_prefix(PRIVATE_EXTERNAL_EDITOR_API_KEY_FILE_PREFIX) + .and_then(|value| value.strip_suffix(".json")) + }) + .filter(|value| value.len() == 16 && value.bytes().all(|byte| byte.is_ascii_hexdigit())) + .ok_or_else(|| "本机陶泥儿开发者 Key 文件名身份无效,请重新登录客户端后重试".to_string())?; + if !actual_fingerprint.eq_ignore_ascii_case(&expected_fingerprint[..16]) { + return Err( + "本机陶泥儿开发者 Key 文件身份与服务器地址不一致,请重新登录客户端后重试".to_string(), + ); + } Ok(Some(ExternalEditorApiCredentials { api_base_url, api_key, @@ -171,18 +184,13 @@ fn private_external_editor_api_credentials_from_file_at( fn unique_private_external_editor_api_credentials_at( directory: &Path, ) -> Result, String> { - let metadata = match fs::symlink_metadata(directory) { - Ok(metadata) => metadata, - Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None), - Err(error) => { - return Err(format!("读取本机陶泥儿开发者 Key 目录失败:{error}")); - } - }; - if metadata.file_type().is_symlink() || !metadata.is_dir() { - return Err("本机陶泥儿开发者 Key 目录必须是普通目录".to_string()); + if !crate::prepare_game_creator_private_path_for_read( + directory, + true, + "本机陶泥儿开发者 Key 目录", + )? { + return Ok(None); } - #[cfg(windows)] - secure_windows_game_creator_path_for_current_user(directory, true, false)?; let mut candidates = fs::read_dir(directory) .map_err(|error| format!("读取本机陶泥儿开发者 Key 目录失败:{error}"))? .filter_map(Result::ok) @@ -219,35 +227,15 @@ fn ensure_plain_private_external_editor_directory( path: &Path, label: &str, ) -> Result { - match fs::symlink_metadata(path) { - Ok(metadata) => { - if metadata.file_type().is_symlink() || !metadata.is_dir() { - return Err(format!("本机陶泥儿开发者凭据{label}必须是普通目录")); - } - Ok(false) - } - Err(error) if error.kind() == std::io::ErrorKind::NotFound => { - let created = match fs::create_dir(path) { - Ok(()) => true, - Err(create_error) if create_error.kind() == std::io::ErrorKind::AlreadyExists => { - false - } - Err(create_error) => { - return Err(format!( - "创建本机陶泥儿开发者凭据{label}失败:{create_error}" - )); - } - }; - let metadata = fs::symlink_metadata(path).map_err(|metadata_error| { - format!("读取本机陶泥儿开发者凭据{label}失败:{metadata_error}") - })?; - if metadata.file_type().is_symlink() || !metadata.is_dir() { - return Err(format!("本机陶泥儿开发者凭据{label}必须是普通目录")); - } - Ok(created) - } - Err(error) => Err(format!("读取本机陶泥儿开发者凭据{label}失败:{error}")), + let label = format!("本机陶泥儿开发者凭据{label}"); + let created = crate::ensure_game_creator_private_directory_tree(path, &label)?; + #[cfg(windows)] + if !created { + crate::secure_windows_game_creator_path_for_current_user_with_auto_elevation( + path, true, true, + )?; } + Ok(created) } /// Prepares the exact private directory before a one-time remote developer key @@ -264,15 +252,11 @@ fn prepare_private_external_editor_api_credentials_parent_dir_at( .parent() .ok_or_else(|| "本机陶泥儿开发者凭据配置缺少上级目录".to_string())?; ensure_plain_private_external_editor_directory(container, "上级目录")?; - let parent_created = ensure_plain_private_external_editor_directory(parent, "目录")?; + ensure_plain_private_external_editor_directory(parent, "目录")?; #[cfg(windows)] - if parent_created { - initialize_windows_game_creator_directory_owner_for_current_user(parent)?; - } else { - secure_windows_game_creator_path_for_current_user(parent, true, true)?; - } + secure_windows_game_creator_path_for_current_user_with_auto_elevation(parent, true, true)?; #[cfg(unix)] - if parent_created { + { use std::os::unix::fs::PermissionsExt; fs::set_permissions(parent, fs::Permissions::from_mode(0o700)) .map_err(|error| format!("收紧本机陶泥儿开发者凭据目录权限失败:{error}"))?; @@ -301,8 +285,19 @@ fn write_private_external_editor_api_credentials_at( if parent_metadata.file_type().is_symlink() || !parent_metadata.is_dir() { return Err("本机陶泥儿开发者 Key 目录必须是普通目录".to_string()); } - if path.exists() { - return Err("本机陶泥儿开发者 Key 已存在,拒绝覆盖".to_string()); + match fs::symlink_metadata(path) { + Ok(metadata) => { + if metadata.file_type().is_symlink() || !metadata.is_file() { + return Err( + "本机陶泥儿开发者 Key 目标必须是普通文件,不能是链接或其他对象".to_string(), + ); + } + return Err("本机陶泥儿开发者 Key 已存在,拒绝覆盖".to_string()); + } + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + Err(error) => { + return Err(format!("读取本机陶泥儿开发者 Key 目标失败:{error}")); + } } let body = serde_json::to_string_pretty(&PrivateExternalEditorApiKeyFile { api_key: credentials.api_key.clone(), @@ -325,9 +320,19 @@ fn write_private_external_editor_api_credentials_at( use std::os::unix::fs::OpenOptionsExt; options.mode(0o600); } + #[cfg(windows)] + { + use std::os::windows::fs::OpenOptionsExt; + options.custom_flags(crate::PROJECT_FILE_FLAG_OPEN_REPARSE_POINT); + } let mut file = options .open(&temporary) .map_err(|error| format!("创建本机陶泥儿开发者 Key 临时文件失败:{error}"))?; + crate::harden_new_game_creator_private_path(&temporary, false, "本机陶泥儿开发者 Key 临时文件") + .map_err(|error| { + let _ = fs::remove_file(&temporary); + format!("初始化本机陶泥儿开发者 Key 临时文件安全权限失败:{error}") + })?; let write_result = file .write_all(format!("{body}\n").as_bytes()) .and_then(|_| file.sync_all()); @@ -336,8 +341,6 @@ fn write_private_external_editor_api_credentials_at( let _ = fs::remove_file(&temporary); return Err(format!("写入本机陶泥儿开发者 Key 临时文件失败:{error}")); } - #[cfg(windows)] - initialize_windows_game_creator_file_owner_for_current_user(&temporary)?; match fs::hard_link(&temporary, path) { Ok(()) => { let _ = fs::remove_file(&temporary); @@ -353,7 +356,14 @@ fn write_private_external_editor_api_credentials_at( } } #[cfg(windows)] - secure_windows_game_creator_path_for_current_user(path, false, true)?; + if let Err(error) = + secure_windows_game_creator_path_for_current_user_with_auto_elevation(path, false, true) + { + let _ = fs::remove_file(path); + return Err(format!( + "复核本机陶泥儿开发者 Key 文件安全权限失败:{error}" + )); + } Ok(()) } @@ -474,11 +484,10 @@ pub(crate) fn upload_local_asset_at( let relative_path = format!("assets/uploads/{asset_id}-{safe_name}"); let absolute_path = root.join(&relative_path); if let Some(parent) = absolute_path.parent() { - fs::create_dir_all(parent) - .map_err(|error| format!("创建上传目录失败:{}: {error}", parent.display()))?; + ensure_game_creator_private_directory_tree(parent, "上传目录")?; + prepare_game_creator_private_path_for_read(parent, true, "上传目录")?; } - fs::write(&absolute_path, bytes) - .map_err(|error| format!("写入上传文件失败:{}: {error}", absolute_path.display()))?; + crate::write_game_creator_private_file(&absolute_path, bytes, "上传文件")?; register_local_asset_entry( root, @@ -578,13 +587,11 @@ pub(crate) fn import_canvas_export_at( if !export_path.is_absolute() { return Err("画板导出 ZIP 路径必须是绝对路径".to_string()); } + crate::prepare_game_creator_user_selected_path_for_read(export_path, false, "画板导出 ZIP")?; let metadata = fs::symlink_metadata(export_path) .map_err(|error| format!("读取画板导出 ZIP 失败:{}: {error}", export_path.display()))?; - if metadata.file_type().is_symlink() { - return Err("画板导出 ZIP 不能是符号链接".to_string()); - } - if !metadata.is_file() { - return Err("画板导出路径必须是 ZIP 文件".to_string()); + if metadata.file_type().is_symlink() || !metadata.is_file() { + return Err("画板导出路径必须是普通 ZIP 文件".to_string()); } init_local_game_project_at(root, "local-project-draft", "未命名游戏原型")?; @@ -765,12 +772,10 @@ pub(crate) async fn sync_canvas_project_assets_at( ); let absolute_path = root.join(&local_path); if let Some(parent) = absolute_path.parent() { - fs::create_dir_all(parent) - .map_err(|error| format!("创建画板同步目录失败:{}: {error}", parent.display()))?; + ensure_game_creator_private_directory_tree(parent, "画板同步目录")?; + prepare_game_creator_private_path_for_read(parent, true, "画板同步目录")?; } - fs::write(&absolute_path, &download.bytes).map_err(|error| { - format!("写入画板同步资产失败:{}: {error}", absolute_path.display()) - })?; + crate::write_game_creator_private_file(&absolute_path, &download.bytes, "画板同步资产")?; assets.push(register_local_asset_entry( root, &local_path, @@ -1523,13 +1528,18 @@ pub(crate) fn extract_canvas_export_zip_files( let local_relative_path = format!("{import_relative_root}/{normalized_relative}"); let target_path = resolve_local_project_path(root, &local_relative_path)?; if let Some(parent) = target_path.parent() { - fs::create_dir_all(parent) - .map_err(|error| format!("创建画板导入目录失败:{}: {error}", parent.display()))?; + ensure_game_creator_private_directory_tree(parent, "画板导入目录")?; + prepare_game_creator_private_path_for_read(parent, true, "画板导入目录")?; } - let mut output = File::create(&target_path) - .map_err(|error| format!("写入画板导入文件失败:{}: {error}", target_path.display()))?; - std::io::copy(&mut entry, &mut output) + let entry_size = entry.size(); + let mut bytes = Vec::with_capacity(entry_size.min(MAX_CANVAS_EXPORT_BYTES as u64) as usize); + std::io::Read::take(&mut entry, entry_size + 1) + .read_to_end(&mut bytes) .map_err(|error| format!("解压画板导出文件失败:{}: {error}", target_path.display()))?; + if bytes.len() as u64 != entry_size { + return Err("画板导出 ZIP 条目读取长度不一致".to_string()); + } + crate::write_game_creator_private_file(&target_path, &bytes, "画板导入文件")?; copied_files.push(normalized_relative); } if copied_files.is_empty() { @@ -1807,11 +1817,16 @@ mod tests { { let root = tempfile::tempdir().expect("temp dir"); let directory = root.path().join("config").join("genarrative"); - let first_path = directory.join("external-editor-api-0000000000000001.json"); let first = ExternalEditorApiCredentials { api_base_url: "https://dev.genarrative.world".to_string(), api_key: "tnr_sk_headless_fixture_1".to_string(), }; + let first_path = directory.join( + private_external_editor_api_key_path_for_base_url(&first.api_base_url) + .expect("first credential path") + .file_name() + .expect("first credential filename"), + ); write_private_external_editor_api_credentials_at(&first_path, &first) .expect("write first private credential"); let recovered = unique_private_external_editor_api_credentials_at(&directory) @@ -1820,11 +1835,16 @@ mod tests { assert_eq!(recovered.api_base_url, first.api_base_url); assert_eq!(recovered.api_key, first.api_key); - let second_path = directory.join("external-editor-api-0000000000000002.json"); let second = ExternalEditorApiCredentials { api_base_url: "https://www.genarrative.world".to_string(), api_key: "tnr_sk_headless_fixture_2".to_string(), }; + let second_path = directory.join( + private_external_editor_api_key_path_for_base_url(&second.api_base_url) + .expect("second credential path") + .file_name() + .expect("second credential filename"), + ); write_private_external_editor_api_credentials_at(&second_path, &second) .expect("write second private credential"); let error = match unique_private_external_editor_api_credentials_at(&directory) { @@ -1856,11 +1876,17 @@ mod tests { #[test] fn newly_created_private_external_editor_credentials_directory_is_owned_by_token_user() { let root = tempfile::tempdir().expect("temp dir"); + let credential_file_name = + private_external_editor_api_key_path_for_base_url("https://dev.genarrative.world") + .expect("credential path") + .file_name() + .expect("credential filename") + .to_owned(); let path = root .path() .join("config") .join("genarrative") - .join("external-editor-api-test.json"); + .join(credential_file_name); prepare_private_external_editor_api_credentials_parent_dir_at(&path) .expect("prepare private credential directory"); @@ -1889,7 +1915,13 @@ mod tests { "fixture must reproduce the inherited ACL rejection" ); - let path = parent.join("external-editor-api-test.json"); + let credential_file_name = + private_external_editor_api_key_path_for_base_url("https://dev.genarrative.world") + .expect("credential path") + .file_name() + .expect("credential filename") + .to_owned(); + let path = parent.join(credential_file_name); prepare_private_external_editor_api_credentials_parent_dir_at(&path) .expect("current-user directory should be tightened locally before remote creation"); secure_windows_game_creator_path_for_current_user(&parent, true, false) diff --git a/apps/ai-game-creator-shell/src-tauri/src/browser/evidence.rs b/apps/ai-game-creator-shell/src-tauri/src/browser/evidence.rs index 080606981..2413530d0 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/browser/evidence.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/browser/evidence.rs @@ -1,4 +1,3 @@ -use std::fs; use std::io::Write; use std::path::{Component, Path, PathBuf}; use std::time::{SystemTime, UNIX_EPOCH}; @@ -46,27 +45,20 @@ pub(super) fn validate_evidence_path(path: &Path) -> Result<(), String> { pub(super) fn prepare_evidence_root(path: &Path) -> Result<(), String> { validate_evidence_path(path)?; - if let Ok(metadata) = fs::symlink_metadata(path) { - if metadata.file_type().is_symlink() || !metadata.is_dir() { - return Err("evidenceRoot 必须是真实目录且不能是符号链接".to_string()); - } - } else { - fs::create_dir_all(path) - .map_err(|error| format!("创建浏览器证据目录失败:{}: {error}", path.display()))?; - } - let metadata = fs::symlink_metadata(path) - .map_err(|error| format!("读取浏览器证据目录失败:{}: {error}", path.display()))?; - if metadata.file_type().is_symlink() || !metadata.is_dir() { - return Err("evidenceRoot 必须是真实目录且不能是符号链接".to_string()); - } + crate::ensure_game_creator_private_directory_tree(path, "浏览器证据目录")?; + crate::prepare_game_creator_private_path_for_read(path, true, "浏览器证据目录")?; Ok(()) } pub(super) fn write_atomic(path: &Path, bytes: &[u8]) -> Result<(), String> { let parent = path .parent() .ok_or_else(|| format!("证据文件缺少父目录:{}", path.display()))?; + crate::ensure_game_creator_private_directory_tree(parent, "浏览器证据目录")?; + crate::prepare_game_creator_private_path_for_read(parent, true, "浏览器证据目录")?; + crate::prepare_game_creator_private_path_for_read(path, false, "浏览器证据文件")?; let mut temporary = NamedTempFile::new_in(parent).map_err(|error| format!("创建证据临时文件失败:{error}"))?; + crate::harden_new_game_creator_private_path(temporary.path(), false, "浏览器证据临时文件")?; temporary .write_all(bytes) .map_err(|error| format!("写入证据临时文件失败:{error}"))?; @@ -77,6 +69,7 @@ pub(super) fn write_atomic(path: &Path, bytes: &[u8]) -> Result<(), String> { temporary .persist(path) .map_err(|error| format!("保存证据文件失败:{}: {}", path.display(), error.error))?; + crate::prepare_game_creator_private_path_for_read(path, false, "浏览器证据文件")?; Ok(()) } diff --git a/apps/ai-game-creator-shell/src-tauri/src/cli.rs b/apps/ai-game-creator-shell/src-tauri/src/cli.rs index 662437973..cdb713d1e 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/cli.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/cli.rs @@ -4,6 +4,12 @@ const PREVIEW_SERVE_USAGE: &str = "用法:--preview-serve <本地项目绝对 #[derive(Debug, Eq, PartialEq)] pub(crate) enum CliCommand { + RepairPrivateAcl { + path: PathBuf, + target_user_sid: String, + authorization_nonce: String, + scope: String, + }, LlmStatus, PreviewServe { project_path: PathBuf, @@ -220,7 +226,10 @@ impl CliCommand { | Self::PlanGddDecide { project_path, .. } | Self::PreviewServe { project_path } | Self::AgentRun { project_path, .. } => Some((project_path, false)), - Self::LlmStatus | Self::RunnerStatus | Self::RunnerShutdownIfIdle => None, + Self::RepairPrivateAcl { .. } + | Self::LlmStatus + | Self::RunnerStatus + | Self::RunnerShutdownIfIdle => None, } } } @@ -312,7 +321,11 @@ pub(crate) fn game_creator_llm_status_lines(status: &GameCreatorLlmConfigStatus) let mut lines = vec![ format!("agent.mode={}", status.agent_mode), format!("llm.configured={}", status.configured), - format!("llm.apiKeyPresent={}", status.api_key_present), + format!( + "llm.accountCredentialState={}", + status.account_credential_state + ), + format!("llm.officialRouteLocked={}", status.official_route_locked), format!( "llm.baseUrl={}", status.base_url.as_deref().unwrap_or_default() @@ -341,8 +354,12 @@ pub(crate) fn game_creator_llm_status_lines(status: &GameCreatorLlmConfigStatus) agent.agent_id, agent.configured )); lines.push(format!( - "llm.agent.{}.apiKeyPresent={}", - agent.agent_id, agent.api_key_present + "llm.agent.{}.accountCredentialState={}", + agent.agent_id, agent.account_credential_state + )); + lines.push(format!( + "llm.agent.{}.officialRouteLocked={}", + agent.agent_id, agent.official_route_locked )); lines.push(format!( "llm.agent.{}.baseUrl={}", @@ -512,6 +529,33 @@ fn parse_cli_agent_goal_revision(value: &str, usage: &str) -> Result Result, String> { + if args.first().map(String::as_str) == Some("--repair-private-acl") { + if args.len() != 8 + || args[1].trim().is_empty() + || args[2] != "--target-user-sid" + || args[3].trim().is_empty() + || args[4] != "--authorization" + || args[5].trim().is_empty() + || args[6] != "--scope" + || !matches!(args[7].as_str(), "managed" | "user-selected") + { + return Err("用法:--repair-private-acl --target-user-sid --authorization <一次性授权票据> --scope ".to_string()); + } + let path = PathBuf::from(&args[1]); + if !path.is_absolute() { + return Err("AGC 私有 ACL 修复路径必须是绝对路径".to_string()); + } + #[cfg(not(windows))] + { + return Err("AGC 私有 ACL 修复仅支持 Windows".to_string()); + } + return Ok(Some(CliCommand::RepairPrivateAcl { + path, + target_user_sid: args[3].trim().to_string(), + authorization_nonce: args[5].trim().to_string(), + scope: args[7].trim().to_string(), + })); + } if args.first().map(String::as_str) == Some("--preview-serve") { if args.len() != 2 || args[1].trim().is_empty() { return Err(PREVIEW_SERVE_USAGE.to_string()); @@ -997,6 +1041,32 @@ fn serialize_agent_runtime_cli_payload(payload: &T) -> Resu pub(crate) fn run_cli_command(command: CliCommand) -> Result<(), String> { match command { + CliCommand::RepairPrivateAcl { + path, + target_user_sid, + authorization_nonce, + scope, + } => { + #[cfg(windows)] + { + let scope = parse_windows_acl_repair_scope(&scope)?; + consume_windows_acl_repair_authorization( + &path, + &target_user_sid, + &authorization_nonce, + scope, + )?; + repair_game_creator_private_acl_for_user_sid(&path, &target_user_sid) + } + #[cfg(not(windows))] + { + let _ = path; + let _ = target_user_sid; + let _ = authorization_nonce; + let _ = scope; + Err("AGC 私有 ACL 修复仅支持 Windows".to_string()) + } + } CliCommand::LlmStatus => { let status = check_game_creator_llm_config_from_config(); for line in game_creator_llm_status_lines(&status) { @@ -1677,6 +1747,23 @@ mod tests { assert_eq!(parsed["runtimes"][0]["state"]["status"], "running"); } + #[test] + fn repair_private_acl_requires_one_time_authorization() { + let error = parse_cli_command(&[ + "--repair-private-acl".to_string(), + if cfg!(windows) { + r"C:\Users\test\AppData\Local\Genarrative" + } else { + "/tmp/genarrative" + } + .to_string(), + "--target-user-sid".to_string(), + "S-1-5-21-test".to_string(), + ]) + .expect_err("ACL repair must not be callable without its one-time authorization"); + assert!(error.contains("--authorization"), "{error}"); + } + #[test] fn parses_preview_serve_without_runner_or_config() { let project_path = std::env::current_dir().expect("current directory"); diff --git a/apps/ai-game-creator-shell/src-tauri/src/command_exec.rs b/apps/ai-game-creator-shell/src-tauri/src/command_exec.rs index db05d7596..50a66217f 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/command_exec.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/command_exec.rs @@ -1124,15 +1124,18 @@ pub(crate) fn prepare_project_command_launch_spec( .map_err(|error| ProjectCommandError::new(ProjectCommandErrorStage::Preflight, error))?; let isolated_cache = resolve_local_project_path(root, ".agent/runtime/command-env/cache") .map_err(|error| ProjectCommandError::new(ProjectCommandErrorStage::Preflight, error))?; - fs::create_dir_all(&isolated_home) - .and_then(|()| fs::create_dir_all(&isolated_tmp)) - .and_then(|()| fs::create_dir_all(&isolated_cache)) - .map_err(|error| { - ProjectCommandError::new( - ProjectCommandErrorStage::Preflight, - format!("创建 command.exec 隔离目录失败:{error}"), - ) + for (path, label) in [ + (&isolated_home, "command.exec HOME 隔离目录"), + (&isolated_tmp, "command.exec 临时隔离目录"), + (&isolated_cache, "command.exec 缓存隔离目录"), + ] { + crate::ensure_game_creator_private_directory_tree(path, label).map_err(|error| { + ProjectCommandError::new(ProjectCommandErrorStage::Preflight, error) })?; + crate::prepare_game_creator_private_path_for_read(path, true, label).map_err(|error| { + ProjectCommandError::new(ProjectCommandErrorStage::Preflight, error) + })?; + } let mut environment = vec![ (OsString::from("CI"), OsString::from("1")), @@ -2065,13 +2068,13 @@ where let log_path = resolve_local_project_path(root, ".agent/logs/command.log") .map_err(|error| ProjectCommandError::new(ProjectCommandErrorStage::AuditLog, error))?; if let Some(parent) = log_path.parent() { - fs::create_dir_all(parent).map_err(|error| { - ProjectCommandError::new( - ProjectCommandErrorStage::AuditLog, - format!("创建 command.exec 日志目录失败:{error}"), - ) - })?; + crate::ensure_game_creator_private_directory_tree(parent, "command.exec 日志目录") + .map_err(|error| ProjectCommandError::new(ProjectCommandErrorStage::AuditLog, error))?; + crate::prepare_game_creator_private_path_for_read(parent, true, "command.exec 日志目录") + .map_err(|error| ProjectCommandError::new(ProjectCommandErrorStage::AuditLog, error))?; } + crate::prepare_game_creator_private_path_for_read(&log_path, false, "command.exec 命令日志") + .map_err(|error| ProjectCommandError::new(ProjectCommandErrorStage::AuditLog, error))?; let argument_bytes = serde_json::to_vec(&spec.arguments).unwrap_or_default(); let log_entry = format!( "{updated_at} command.exec program={} argsSha256={:x} argsCount={} cwd={} {status} exitCode={} timedOut={} durationMs={} sourceChanged={} verificationEligible={} sandboxBackend={} sandboxMode={} networkAccess={} sandboxProfileVersion={}\n{}\n", @@ -2093,17 +2096,17 @@ where launch_metadata.sandbox_profile_version, process.output, ); - fs::OpenOptions::new() - .create(true) - .append(true) - .open(&log_path) - .and_then(|mut file| file.write_all(log_entry.as_bytes())) - .map_err(|error| { - ProjectCommandError::new( - ProjectCommandErrorStage::AuditLog, - format!("command.exec 执行后写入命令日志失败,需要人工核对:{error}"), - ) - })?; + crate::append_game_creator_private_file( + &log_path, + log_entry.as_bytes(), + "command.exec 命令日志", + ) + .map_err(|error| { + ProjectCommandError::new( + ProjectCommandErrorStage::AuditLog, + format!("command.exec 执行后写入命令日志失败,需要人工核对:{error}"), + ) + })?; record_command_run( root, GameCreationAppCommandRunState { diff --git a/apps/ai-game-creator-shell/src-tauri/src/command_output.rs b/apps/ai-game-creator-shell/src-tauri/src/command_output.rs index 568c1d8f3..30ecac174 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/command_output.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/command_output.rs @@ -145,12 +145,8 @@ pub(crate) fn write_command_output_transcript_at( } let path = resolve_local_project_path(root, &transcript.output_ref)?; if let Some(parent) = path.parent() { - fs::create_dir_all(parent).map_err(|error| { - format!( - "创建 command.exec 输出 sidecar 目录失败:{}: {error}", - parent.display() - ) - })?; + ensure_game_creator_private_directory_tree(parent, "command.exec 输出 sidecar 目录")?; + prepare_game_creator_private_path_for_read(parent, true, "command.exec 输出 sidecar 目录")?; } let path = resolve_local_project_path(root, &transcript.output_ref)?; match fs::symlink_metadata(&path) { @@ -166,10 +162,13 @@ pub(crate) fn write_command_output_transcript_at( } Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} Err(error) => { - return Err(format!( - "读取 command.exec 输出 sidecar 元数据失败:{}: {error}", - path.display() - )); + prepare_game_creator_private_path_for_read(&path, false, "command.exec 输出 sidecar") + .map_err(|repair_error| { + format!( + "读取 command.exec 输出 sidecar 元数据失败:{}: {error};自动提权修复未完成:{repair_error}", + path.display() + ) + })?; } } @@ -202,6 +201,13 @@ pub(crate) fn write_command_output_transcript_at( )); } }; + if let Err(error) = + harden_new_game_creator_private_path(&path, false, "command.exec 输出 sidecar") + { + drop(file); + let _ = fs::remove_file(&path); + return Err(error); + } file.write_all(&content).map_err(|error| { format!( "写入 command.exec 输出 sidecar 失败:{}: {error}", @@ -317,8 +323,9 @@ pub(crate) fn read_command_output_page_at( } fn read_command_output_transcript_file(path: &Path) -> Result { + prepare_game_creator_private_path_for_read(path, false, "command.exec 输出 sidecar")?; let (mut file, metadata) = - open_project_snapshot_regular_file(path, "command.exec 输出 sidecar")?; + open_project_private_regular_file(path, "command.exec 输出 sidecar")?; if metadata.len() > COMMAND_OUTPUT_TRANSCRIPT_MAX_BYTES as u64 { return Err(format!( "command.exec 输出 sidecar 超过 {} 字节上限", diff --git a/apps/ai-game-creator-shell/src-tauri/src/commands.rs b/apps/ai-game-creator-shell/src-tauri/src/commands.rs index 73221b283..5c564e9ca 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/commands.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/commands.rs @@ -165,8 +165,14 @@ fn validate_local_asset_import_requirements( } let mut total_size = 0u64; for source in source_paths { - let metadata = - fs::symlink_metadata(source.trim()).map_err(|_| "读取本地文件失败".to_string())?; + let path = Path::new(source.trim()); + // Run the explicit user-selection ACL preparation before any size/type + // preflight. On Windows, metadata traversal can itself fail with + // ERROR_ACCESS_DENIED; doing this only in the later import worker would + // leave the early validation path unable to reach the one-shot UAC + // repair entry. + crate::prepare_game_creator_user_selected_path_for_read(path, false, "本地导入文件")?; + let metadata = fs::symlink_metadata(path).map_err(|_| "读取本地文件失败".to_string())?; if metadata.file_type().is_symlink() || !metadata.is_file() { return Err("只能导入普通文件".to_string()); } @@ -282,12 +288,8 @@ pub(crate) fn create_automatic_local_game_project_at( if projects_root.as_os_str().is_empty() || !projects_root.is_absolute() { return Err("自动工作区根目录必须是绝对路径".to_string()); } - fs::create_dir_all(projects_root).map_err(|error| { - format!( - "创建自动工作区根目录失败:{}: {error}", - projects_root.display() - ) - })?; + ensure_game_creator_private_directory_tree(projects_root, "自动工作区根目录")?; + prepare_game_creator_private_path_for_read(projects_root, true, "自动工作区根目录")?; let metadata = fs::symlink_metadata(projects_root).map_err(|error| { format!( "读取自动工作区根目录失败:{}: {error}", @@ -306,6 +308,11 @@ pub(crate) fn create_automatic_local_game_project_at( match fs::create_dir(&project_root) { Ok(()) => { let result = (|| { + prepare_game_creator_private_path_for_read( + &project_root, + true, + "自动项目目录", + )?; enforce_project_permission_policy(&project_root, "project.create")?; let _lock = acquire_project_write_lock(&project_root, "project.create")?; init_local_game_project_at( @@ -377,6 +384,7 @@ pub(crate) fn is_local_project_directory_non_empty(project_path: String) -> Resu if project_path_has_control_chars(root) { return Err("项目目录不能包含控制字符".to_string()); } + crate::prepare_game_creator_project_root_for_read(root, true, "项目目录")?; if !root.exists() { return Ok(false); } @@ -405,6 +413,7 @@ pub(crate) fn inspect_local_project_directory( if project_path_has_control_chars(root) { return Err("项目目录不能包含控制字符".to_string()); } + crate::prepare_game_creator_project_root_for_read(root, true, "项目目录")?; let recent_run_trace = recent_game_creator_run_trace(root); let godot_project_root = discover_local_godot_project_root(root)?; Ok(LocalProjectDirectoryStatus { @@ -500,9 +509,14 @@ pub(crate) async fn pick_local_project_directory( else { return Ok(None); }; - path.into_path() - .map(|path| Some(path.to_string_lossy().into_owned())) - .map_err(|error| format!("读取项目目录失败:{error}")) + let path = path + .into_path() + .map_err(|error| format!("读取项目目录失败:{error}"))?; + // The native picker is the explicit user-selection boundary. Prepare the + // selected root before returning it so inspect/create/open never races the + // first ACL read. + crate::prepare_game_creator_project_root_for_read(&path, true, "用户选择项目目录")?; + Ok(Some(path.to_string_lossy().into_owned())) } #[tauri::command] @@ -521,9 +535,11 @@ pub(crate) async fn pick_local_file(app: tauri::AppHandle) -> Result Result<(), String> { + shutdown_game_creator_codex_app_servers()?; clear_external_agent_runner_platform_session(generation)?; clear_platform_session(generation); Ok(()) @@ -1901,14 +1919,30 @@ pub(crate) fn create_ui_design_resource( let relative_path = format!("ui/{resource_name}.json"); let absolute_path = resolve_local_project_path(root, &relative_path)?; if let Some(parent) = absolute_path.parent() { - fs::create_dir_all(parent) - .map_err(|error| format!("创建 UI 资源目录失败:{}: {error}", parent.display()))?; + ensure_game_creator_private_directory_tree(parent, "UI 资源目录")?; + prepare_game_creator_private_path_for_read(parent, true, "UI 资源目录")?; } - if absolute_path.exists() { + if prepare_game_creator_private_path_for_read(&absolute_path, false, "UI 资源")? { return Err("UI 设计资源路径已存在,拒绝覆盖".to_string()); } - fs::write(&absolute_path, "") + let mut options = fs::OpenOptions::new(); + options.write(true).create_new(true); + #[cfg(windows)] + { + use std::os::windows::fs::OpenOptionsExt; + options.custom_flags(crate::PROJECT_FILE_FLAG_OPEN_REPARSE_POINT); + } + let mut file = options + .open(&absolute_path) .map_err(|error| format!("创建 UI 资源失败:{}: {error}", absolute_path.display()))?; + if let Err(error) = harden_new_game_creator_private_path(&absolute_path, false, "UI 资源") { + drop(file); + let _ = fs::remove_file(&absolute_path); + return Err(error); + } + file.sync_all() + .map_err(|error| format!("同步 UI 资源失败:{}: {error}", absolute_path.display()))?; + drop(file); let asset = match register_local_asset_at( root, &relative_path, @@ -2123,6 +2157,7 @@ pub(crate) fn import_ui_editor_local_files( let mut total_size = 0u64; for source in source_paths { let path = Path::new(source.trim()); + crate::prepare_game_creator_user_selected_path_for_read(path, false, "本地图片")?; let metadata = fs::symlink_metadata(path).map_err(|e| format!("读取本地图片失败:{e}"))?; if metadata.file_type().is_symlink() || !metadata.is_file() { return Err("只能导入普通图片文件".to_string()); @@ -2203,6 +2238,7 @@ fn read_registered_ui_editor_font( return Err("项目资产不是受支持的字体候选".to_string()); } let target = resolve_local_project_path(root, &asset.local_path)?; + prepare_game_creator_private_path_for_read(&target, false, "项目字体")?; let metadata = fs::symlink_metadata(&target).map_err(|_| "读取项目字体失败".to_string())?; if metadata.file_type().is_symlink() || !metadata.is_file() { return Err("项目字体必须是普通文件".to_string()); @@ -2279,6 +2315,7 @@ pub(crate) fn import_ui_editor_local_fonts( let mut input_hashes = std::collections::BTreeSet::new(); for source in source_paths { let path = Path::new(source.trim()); + crate::prepare_game_creator_user_selected_path_for_read(path, false, "本地字体")?; let metadata = fs::symlink_metadata(path).map_err(|_| "读取本地字体失败".to_string())?; if metadata.file_type().is_symlink() || !metadata.is_file() { return Err("只能导入普通字体文件".to_string()); @@ -2337,7 +2374,8 @@ pub(crate) fn import_ui_editor_local_fonts( if !new_inputs.is_empty() { let font_root = root.join("assets/fonts"); - fs::create_dir_all(&font_root).map_err(|_| "创建项目字体目录失败".to_string())?; + ensure_game_creator_private_directory_tree(&font_root, "项目字体目录")?; + prepare_game_creator_private_path_for_read(&font_root, true, "项目字体目录")?; } // 字体批次同样是增量提交合同:已经复制并登记的字体在后续失败时保留。 let mut result = Vec::with_capacity(inputs.len()); @@ -2358,7 +2396,28 @@ pub(crate) fn import_ui_editor_local_fonts( validated.metadata.format.extension() ); let target = resolve_local_project_path(root, &relative_path)?; - fs::write(&target, &bytes).map_err(|_| "写入项目字体失败".to_string())?; + if prepare_game_creator_private_path_for_read(&target, false, "项目字体")? { + return Err(format!("项目字体目标已存在但未登记:{}", target.display())); + } + let mut options = fs::OpenOptions::new(); + options.write(true).create_new(true); + #[cfg(windows)] + { + use std::os::windows::fs::OpenOptionsExt; + options.custom_flags(crate::PROJECT_FILE_FLAG_OPEN_REPARSE_POINT); + } + let mut file = options + .open(&target) + .map_err(|error| format!("写入项目字体失败:{}: {error}", target.display()))?; + if let Err(error) = harden_new_game_creator_private_path(&target, false, "项目字体") { + drop(file); + let _ = fs::remove_file(&target); + return Err(error); + } + file.write_all(&bytes) + .and_then(|_| file.sync_all()) + .map_err(|error| format!("写入项目字体失败:{}: {error}", target.display()))?; + drop(file); let registered = register_local_asset_entry( root, &relative_path, @@ -3380,6 +3439,7 @@ pub(crate) fn import_local_project_image_assets_for_agent( reject_sensitive_project_file_read(&normalized)?; reject_agent_local_image_source_path(&normalized)?; let source = resolve_local_project_path(root, &normalized)?; + prepare_game_creator_private_path_for_read(&source, false, "本地图片")?; let metadata = fs::symlink_metadata(&source).map_err(|_| format!("本地图片不存在:{normalized}"))?; if metadata.file_type().is_symlink() || !metadata.is_file() { @@ -3430,16 +3490,37 @@ pub(crate) fn import_local_project_image_assets_for_agent( }); continue; } - if target.exists() && source_path != local_path { + if target.exists() { + prepare_game_creator_private_path_for_read(&target, false, "目标图片")?; let existing_bytes = fs::read(&target).map_err(|_| "读取目标图片失败".to_string())?; if existing_bytes != bytes { return Err(format!("本地图片目标已存在且内容不同:{local_path}")); } } else { if let Some(parent) = target.parent() { - fs::create_dir_all(parent).map_err(|_| "创建本地图片导入目录失败".to_string())?; + ensure_game_creator_private_directory_tree(parent, "本地图片导入目录")?; + prepare_game_creator_private_path_for_read(parent, true, "本地图片导入目录")?; } - fs::write(&target, &bytes).map_err(|_| "写入本地图片失败".to_string())?; + let mut options = fs::OpenOptions::new(); + options.write(true).create_new(true); + #[cfg(windows)] + { + use std::os::windows::fs::OpenOptionsExt; + options.custom_flags(crate::PROJECT_FILE_FLAG_OPEN_REPARSE_POINT); + } + let mut file = options + .open(&target) + .map_err(|error| format!("写入本地图片失败:{}: {error}", target.display()))?; + if let Err(error) = harden_new_game_creator_private_path(&target, false, "目标图片") + { + drop(file); + let _ = fs::remove_file(&target); + return Err(error); + } + file.write_all(&bytes) + .and_then(|_| file.sync_all()) + .map_err(|error| format!("写入本地图片失败:{}: {error}", target.display()))?; + drop(file); } let registered = register_local_asset_entry( root, @@ -3588,12 +3669,33 @@ pub(crate) async fn import_account_editor_assets_for_agent( continue; } if target.exists() { + prepare_game_creator_private_path_for_read(&target, false, "账户图片目标")?; return Err(format!("账户图片目标已存在但尚未登记:{local_path}")); } if let Some(parent) = target.parent() { - fs::create_dir_all(parent).map_err(|_| "创建账户图片导入目录失败".to_string())?; + ensure_game_creator_private_directory_tree(parent, "账户图片导入目录")?; + prepare_game_creator_private_path_for_read(parent, true, "账户图片导入目录")?; } - fs::write(&target, &bytes).map_err(|_| "写入账户图片失败".to_string())?; + let mut options = fs::OpenOptions::new(); + options.write(true).create_new(true); + #[cfg(windows)] + { + use std::os::windows::fs::OpenOptionsExt; + options.custom_flags(crate::PROJECT_FILE_FLAG_OPEN_REPARSE_POINT); + } + let mut file = options + .open(&target) + .map_err(|error| format!("写入账户图片失败:{}: {error}", target.display()))?; + if let Err(error) = harden_new_game_creator_private_path(&target, false, "账户图片目标") + { + drop(file); + let _ = fs::remove_file(&target); + return Err(error); + } + file.write_all(&bytes) + .and_then(|_| file.sync_all()) + .map_err(|error| format!("写入账户图片失败:{}: {error}", target.display()))?; + drop(file); let (source_kind, canvas_project_id, generation_route) = match record.origin { AgentEditorAssetOrigin::AccountLibrary => ( GameCreationAppAssetSourceKind::Canvas, @@ -3695,9 +3797,31 @@ pub(crate) async fn import_ui_editor_remote_assets( for (asset, asset_id, media_type, local_path, bytes) in downloads { let target = resolve_local_project_path(root, &local_path)?; if let Some(parent) = target.parent() { - fs::create_dir_all(parent).map_err(|e| format!("创建导入目录失败:{e}"))?; + ensure_game_creator_private_directory_tree(parent, "平台素材导入目录")?; + prepare_game_creator_private_path_for_read(parent, true, "平台素材导入目录")?; } - fs::write(&target, &bytes).map_err(|e| format!("写入平台素材失败:{e}"))?; + if prepare_game_creator_private_path_for_read(&target, false, "平台素材")? { + return Err(format!("平台素材目标已存在但尚未登记:{local_path}")); + } + let mut options = fs::OpenOptions::new(); + options.write(true).create_new(true); + #[cfg(windows)] + { + use std::os::windows::fs::OpenOptionsExt; + options.custom_flags(crate::PROJECT_FILE_FLAG_OPEN_REPARSE_POINT); + } + let mut file = options + .open(&target) + .map_err(|error| format!("写入平台素材失败:{e}", e = error))?; + if let Err(error) = harden_new_game_creator_private_path(&target, false, "平台素材") { + drop(file); + let _ = fs::remove_file(&target); + return Err(error); + } + file.write_all(&bytes) + .and_then(|_| file.sync_all()) + .map_err(|error| format!("写入平台素材失败:{error}"))?; + drop(file); let registered = register_local_asset_entry( root, &local_path, diff --git a/apps/ai-game-creator-shell/src-tauri/src/config.rs b/apps/ai-game-creator-shell/src-tauri/src/config.rs index 89d87e38b..c5160b1c6 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/config.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/config.rs @@ -1,5 +1,8 @@ use super::*; +pub(crate) const OFFICIAL_AGC_LLM_BASE_URL: &str = "https://router.genarrative.world/v1"; +pub(crate) const OFFICIAL_AGC_LLM_MODEL: &str = "gpt-5.6-sol"; + pub(crate) const GAME_CREATOR_LLM_AGENT_REASONING_EFFORT_DEFAULTS: [(&str, &str); 21] = [ (GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, "high"), ("planner", "high"), @@ -53,6 +56,9 @@ fn build_game_creator_platform_llm_config( llm: &GameCreatorLlmConfig, config_path: &str, ) -> Result { + if game_creator_official_llm_route_locked() { + return build_game_creator_official_platform_llm_config(llm); + } let api_kind = validate_game_creator_llm_web_search_config(llm, config_path)?; let api_key = trim_config_string(&llm.api_key).ok_or_else(|| llm_api_key_config_error(config_path))?; @@ -76,6 +82,33 @@ fn build_game_creator_platform_llm_config( .map_err(|error| format!("LLM 配置无效:{error}")) } +/// Normal AGC builds never receive a Router key. Direct Rust LLM helpers +/// (for example the UI editor/resource editor paths) therefore use the same +/// authenticated API-server proxy as the Codex app-server bridge. The +/// platform access token remains in this process only; it is never serialized +/// into the AGC config or passed to child processes. +fn build_game_creator_official_platform_llm_config( + llm: &GameCreatorLlmConfig, +) -> Result { + let session = current_platform_session() + .ok_or_else(|| "authentication-required: 请先登录陶泥儿账号".to_string())?; + let api_base_url = session.api_base_url.trim_end_matches('/'); + if api_base_url.is_empty() { + return Err("登录态缺少 API Server 地址".to_string()); + } + let proxy_base_url = format!("{api_base_url}/api/llm"); + LlmConfig::new( + LlmProvider::OpenAiCompatible, + proxy_base_url, + session.access_token, + OFFICIAL_AGC_LLM_MODEL.to_string(), + llm.request_timeout_ms, + llm.max_retries, + llm.retry_backoff_ms, + ) + .map_err(|error| format!("官方 AGC LLM 代理配置无效:{error}")) +} + pub(crate) fn game_creator_supports_anthropic_strict_tools( api_kind: LlmApiKind, base_url: &str, @@ -208,7 +241,8 @@ pub(crate) fn check_game_creator_llm_config_from_config() -> GameCreatorLlmConfi return GameCreatorLlmConfigStatus { agent_mode: default_game_creator_agent_mode(), configured: false, - api_key_present: false, + account_credential_state: "unavailable".to_string(), + official_route_locked: game_creator_official_llm_route_locked(), base_url: None, model: None, api_kind: DEFAULT_GAME_CREATOR_LLM_API_KIND.to_string(), @@ -304,8 +338,28 @@ fn check_game_creator_codex_config( ); let mut status = check_game_creator_llm_config_values(&app_config.llm, "llm"); status.agent_mode = app_config.agent_mode.clone(); - status.configured = cli_error.is_none() && global_route_error.is_none(); - status.error = cli_error.clone().or(global_route_error); + if game_creator_official_llm_route_locked() { + let account_ready = current_platform_session().is_some(); + status.account_credential_state = if account_ready { + "ready".to_string() + } else { + "login_required".to_string() + }; + status.official_route_locked = true; + // The status endpoint is safe metadata only. Never echo a legacy + // user-supplied URL/model that was present before the official route + // migration; expose the immutable route instead. + status.base_url = Some(OFFICIAL_AGC_LLM_BASE_URL.to_string()); + status.model = Some(OFFICIAL_AGC_LLM_MODEL.to_string()); + status.api_kind = DEFAULT_GAME_CREATOR_LLM_API_KIND.to_string(); + status.configured = cli_error.is_none() && global_route_error.is_none() && account_ready; + status.error = cli_error.clone().or(global_route_error).or_else(|| { + (!account_ready).then(|| "authentication-required: 请先登录陶泥儿账号".to_string()) + }); + } else { + status.configured = cli_error.is_none() && global_route_error.is_none(); + status.error = cli_error.clone().or(global_route_error); + } status.agents = game_creator_llm_agent_status_definitions() .iter() .map(|definition| { @@ -323,6 +377,15 @@ fn check_game_creator_codex_config( ); agent.configured = cli_error.is_none() && route_error.is_none(); agent.error = cli_error.clone().or(route_error); + if game_creator_official_llm_route_locked() { + agent.account_credential_state = status.account_credential_state.clone(); + agent.official_route_locked = true; + agent.base_url = Some(OFFICIAL_AGC_LLM_BASE_URL.to_string()); + agent.model = Some(OFFICIAL_AGC_LLM_MODEL.to_string()); + agent.api_kind = DEFAULT_GAME_CREATOR_LLM_API_KIND.to_string(); + agent.configured = status.configured; + agent.error = status.error.clone(); + } agent }) .collect(); @@ -396,9 +459,6 @@ pub(crate) fn check_game_creator_llm_config_values( let api_key = trim_config_string(&config.api_key); let base_url = trim_config_string(&config.base_url); let model = trim_config_string(&config.model); - let api_key_present = api_key - .as_ref() - .is_some_and(|value| !value.trim().is_empty()); let api_kind = validate_game_creator_llm_web_search_config(config, config_path); let error = api_kind.as_ref().err().cloned().or_else(|| { match (api_key.as_deref(), base_url.as_deref(), model.as_deref()) { @@ -429,7 +489,13 @@ pub(crate) fn check_game_creator_llm_config_values( GameCreatorLlmConfigStatus { agent_mode: GAME_CREATOR_AGENT_MODE_PROVIDER.to_string(), configured: error.is_none(), - api_key_present, + account_credential_state: if error.is_none() { + "not_required" + } else { + "unavailable" + } + .to_string(), + official_route_locked: game_creator_official_llm_route_locked(), base_url, model, api_kind: api_kind @@ -475,7 +541,8 @@ pub(crate) fn check_game_creator_agent_llm_config_values( agent_id: agent_id.to_string(), label: label.to_string(), configured: status.configured, - api_key_present: status.api_key_present, + account_credential_state: status.account_credential_state.clone(), + official_route_locked: status.official_route_locked, base_url: status.base_url, model: status.model, api_kind: status.api_kind, @@ -638,6 +705,1025 @@ fn validate_game_creator_runtime_config_dir_metadata( Ok(()) } +/// Checks every already-existing path component without following links. +/// `canonicalize` alone is insufficient here because it resolves a junction +/// before the caller gets a chance to apply the owner/DACL policy. +pub(crate) fn validate_game_creator_private_path_ancestors( + path: &Path, + label: &str, +) -> Result<(), String> { + if !path.is_absolute() { + return Err(format!("{label}必须是绝对路径")); + } + for ancestor in path.ancestors().collect::>().into_iter().rev() { + match fs::symlink_metadata(ancestor) { + Ok(metadata) => { + if metadata.file_type().is_symlink() { + return Err(format!( + "{label} 路径不能包含符号链接:{}", + ancestor.display() + )); + } + #[cfg(windows)] + { + use std::os::windows::fs::MetadataExt; + const FILE_ATTRIBUTE_REPARSE_POINT: u32 = 0x0000_0400; + if metadata.file_attributes() & FILE_ATTRIBUTE_REPARSE_POINT != 0 { + return Err(format!( + "{label} 路径不能包含 Windows reparse point:{}", + ancestor.display() + )); + } + } + if ancestor != path && !metadata.is_dir() { + return Err(format!( + "{label} 父路径必须是普通目录:{}", + ancestor.display() + )); + } + } + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + Err(error) => { + return Err(format!( + "读取 {label} 路径元数据失败:{}: {error}", + ancestor.display() + )); + } + } + } + Ok(()) +} + +/// Automatic ACL repair for managed paths is limited to objects AGC owns. A +/// separate, explicit user-selected scope below covers native picker/project +/// root results, including projects stored outside the current profile. +fn game_creator_private_path_allows_auto_elevation(path: &Path) -> bool { + if !path.is_absolute() + || path + .components() + .any(|component| matches!(component, std::path::Component::ParentDir)) + { + return false; + } + + let starts_with_path = |root: &Path| path == root || path.starts_with(root); + if game_creator_runtime_config_dir() + .as_deref() + .is_some_and(starts_with_path) + { + return true; + } + + if let Some(home) = std::env::var_os("USERPROFILE") + .or_else(|| std::env::var_os("HOME")) + .map(PathBuf::from) + .filter(|candidate| candidate.is_absolute()) + { + let credentials_root = home.join(".config").join("genarrative"); + if starts_with_path(&credentials_root) { + return true; + } + + // The Tauri release uses this stable per-user AppData directory. The + // elevated helper runs in a fresh process, so the in-memory runtime + // config-dir override is unavailable there; recognize the packaged + // path from the user's profile as well. + let packaged_app_data = home + .join("AppData") + .join("Local") + .join("world.genarrative.ai-game-creator"); + if starts_with_path(&packaged_app_data) { + return true; + } + } + + for environment_name in ["LOCALAPPDATA", "APPDATA"] { + if let Some(root) = std::env::var_os(environment_name) + .map(PathBuf::from) + .filter(|candidate| candidate.is_absolute()) + { + if starts_with_path(&root.join("world.genarrative.ai-game-creator")) { + return true; + } + } + } + + // A bare `.agent` component is not enough to authorize ownership repair: + // an arbitrary user-selected path can contain a directory with that name. + // An existing AGC marker establishes the managed project root, after + // which every regular descendant (for example `game/index.html`) is + // covered by the same repair boundary. New projects use the explicit + // project-root preparation entry below until their marker is written. + // Nested or unrelated `.agent` directories remain outside the boundary. + let agent_components = path + .components() + .filter_map(|component| match component { + std::path::Component::Normal(name) + if name.to_string_lossy().eq_ignore_ascii_case(".agent") => + { + Some(name.to_os_string()) + } + _ => None, + }) + .collect::>(); + if agent_components.len() > 1 { + return false; + } + + for project_root in path.ancestors() { + let root_metadata = match fs::symlink_metadata(project_root) { + Ok(metadata) => metadata, + Err(_) => continue, + }; + if root_metadata.file_type().is_symlink() || !root_metadata.is_dir() { + continue; + } + let agent_directory = project_root.join(".agent"); + let agent_metadata = match fs::symlink_metadata(&agent_directory) { + Ok(metadata) => metadata, + Err(_) => continue, + }; + let manifest_path = agent_directory.join("manifest.json"); + let manifest_metadata = match fs::symlink_metadata(&manifest_path) { + Ok(metadata) => metadata, + Err(_) => continue, + }; + if agent_metadata.file_type().is_symlink() + || !agent_metadata.is_dir() + || manifest_metadata.file_type().is_symlink() + || !manifest_metadata.is_file() + { + continue; + } + #[cfg(windows)] + { + use std::os::windows::fs::MetadataExt; + const FILE_ATTRIBUTE_REPARSE_POINT: u32 = 0x0000_0400; + if root_metadata.file_attributes() & FILE_ATTRIBUTE_REPARSE_POINT != 0 + || agent_metadata.file_attributes() & FILE_ATTRIBUTE_REPARSE_POINT != 0 + || manifest_metadata.file_attributes() & FILE_ATTRIBUTE_REPARSE_POINT != 0 + { + continue; + } + } + + if let Some(agent_name) = agent_components.first() { + let Some(actual_agent_directory) = path.ancestors().find(|candidate| { + candidate + .file_name() + .is_some_and(|name| name.to_string_lossy().eq_ignore_ascii_case(".agent")) + }) else { + continue; + }; + if actual_agent_directory != &agent_directory + || actual_agent_directory + .file_name() + .map(|name| name != agent_name) + .unwrap_or(true) + { + continue; + } + } + return true; + } + false +} + +/// A file-picker/project-root result is an explicit native user action. Once +/// the caller has crossed that boundary, a regular object may be repaired by +/// the one-shot UAC helper even when it lives outside the current profile +/// (projects are commonly stored on another drive). The normal +/// reparse/regular-object/ancestor-type checks still run before this predicate +/// is consulted, so links, junctions and non-regular objects never become +/// elevation targets. +#[cfg(windows)] +fn game_creator_user_selected_path_allows_auto_elevation(path: &Path) -> bool { + if !path.is_absolute() + || path + .components() + .any(|component| matches!(component, std::path::Component::ParentDir)) + { + return false; + } + // Never treat a drive/UNC root as a user file. This also prevents an + // unreadable ancestor walk from turning a picker selection into ownership + // repair of the entire volume root. A native picker can technically + // return Windows/Program Files paths, but taking ownership there would + // damage the operating system rather than repair an AGC user file. + if path.as_os_str().is_empty() || path.file_name().is_none() { + return false; + } + let normalized = path + .to_string_lossy() + .replace('/', "\\") + .trim_end_matches('\\') + .to_ascii_lowercase(); + for variable in [ + "WINDIR", + "PROGRAMFILES", + "PROGRAMFILES(X86)", + "PROGRAMDATA", + "COMMONPROGRAMFILES", + "COMMONPROGRAMFILES(X86)", + ] { + let Some(root) = std::env::var_os(variable).map(PathBuf::from) else { + continue; + }; + let root = root + .to_string_lossy() + .replace('/', "\\") + .trim_end_matches('\\') + .to_ascii_lowercase(); + if normalized == root || normalized.starts_with(&(root + "\\")) { + return false; + } + } + true +} + +#[cfg(windows)] +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) enum WindowsAclRepairScope { + Managed, + UserSelected, +} + +#[cfg(windows)] +impl WindowsAclRepairScope { + fn wire_name(self) -> &'static str { + match self { + Self::Managed => "managed", + Self::UserSelected => "user-selected", + } + } + + fn allows_path(self, path: &Path) -> bool { + match self { + Self::Managed => game_creator_private_path_allows_auto_elevation(path), + Self::UserSelected => game_creator_user_selected_path_allows_auto_elevation(path), + } + } +} + +#[cfg(windows)] +pub(crate) fn parse_windows_acl_repair_scope(value: &str) -> Result { + match value.trim() { + "managed" => Ok(WindowsAclRepairScope::Managed), + "user-selected" => Ok(WindowsAclRepairScope::UserSelected), + _ => Err("AGC ACL 修复 scope 无效".to_string()), + } +} + +/// Prepares a caller-selected AGC project root. The root itself has no +/// `.agent/manifest.json` yet during first initialization, so it cannot use +/// the marker-based auto-elevation predicate above. This explicit entry is +/// only called by project initialization and the Runner, where the path has +/// already been accepted as the workspace root; descendants remain subject +/// to the marker-based managed-root check. +pub(crate) fn prepare_game_creator_project_root_for_read( + path: &Path, + is_directory: bool, + label: &str, +) -> Result { + let metadata = match fs::symlink_metadata(path) { + Ok(metadata) => metadata, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(false), + Err(error) => { + let detail = format!("读取 {label} 元数据失败:{}: {error}", path.display()); + #[cfg(windows)] + { + // A selected project root may be unreadable before the leaf + // metadata can be inspected. Keep the same explicit + // user-selected scope and one-shot UAC path used after the + // metadata check, while rejecting all paths outside the + // current profile. + let scope = if game_creator_user_selected_path_allows_auto_elevation(path) { + WindowsAclRepairScope::UserSelected + } else { + WindowsAclRepairScope::Managed + }; + if scope.allows_path(path) && windows_acl_error_may_need_elevation(&detail) { + return secure_windows_game_creator_path_for_current_user_with_auto_elevation_scoped( + path, + is_directory, + true, + scope, + ) + .map(|_| true) + .map_err(|repair_error| { + format!("{detail};自动提权修复未完成:{repair_error}") + }); + } + } + return Err(detail); + } + }; + if metadata.file_type().is_symlink() + || (is_directory && !metadata.is_dir()) + || (!is_directory && !metadata.is_file()) + { + return Err(format!( + "{label} 必须是普通{},不能是链接或其他对象:{}", + if is_directory { "目录" } else { "文件" }, + path.display() + )); + } + #[cfg(windows)] + { + use std::os::windows::fs::MetadataExt; + const FILE_ATTRIBUTE_REPARSE_POINT: u32 = 0x0000_0400; + if metadata.file_attributes() & FILE_ATTRIBUTE_REPARSE_POINT != 0 { + return Err(format!( + "{label} 不能是 Windows reparse point:{}", + path.display() + )); + } + // A project root is explicitly selected by the user before the AGC + // marker exists, so the managed-path predicate cannot identify it yet. + // Use the explicit user-selected scope for the root; this allows a + // project stored on another drive to be repaired while retaining the + // strict reparse/type checks above. + let scope = if game_creator_user_selected_path_allows_auto_elevation(path) { + WindowsAclRepairScope::UserSelected + } else { + WindowsAclRepairScope::Managed + }; + secure_windows_game_creator_path_for_current_user_with_auto_elevation_scoped( + path, + is_directory, + true, + scope, + )?; + } + #[cfg(not(windows))] + { + validate_game_creator_private_path_ancestors(path, label)?; + } + Ok(true) +} + +#[cfg(windows)] +fn validate_game_creator_private_path_ancestors_with_auto_elevation( + path: &Path, + label: &str, +) -> Result<(), String> { + validate_game_creator_private_path_ancestors_with_auto_elevation_scoped( + path, + label, + WindowsAclRepairScope::Managed, + ) +} + +#[cfg(windows)] +fn validate_game_creator_private_path_ancestors_with_auto_elevation_scoped( + path: &Path, + label: &str, + scope: WindowsAclRepairScope, +) -> Result<(), String> { + #[cfg(test)] + { + return validate_game_creator_private_path_ancestors(path, label); + } + #[cfg(not(test))] + { + let target_user_sid = current_windows_token_user_sid_string()?; + let mut attempted_targets = Vec::::new(); + loop { + match validate_game_creator_private_path_ancestors(path, label) { + Ok(()) => return Ok(()), + Err(error) + if scope.allows_path(path) && windows_acl_error_may_need_elevation(&error) => + { + let repair_target = windows_acl_repair_target(path, scope); + if attempted_targets + .iter() + .any(|target| target == &repair_target) + { + return Err(format!( + "{error};自动提权修复重复命中同一目标,拒绝继续重试:{}", + repair_target.display() + )); + } + attempted_targets.push(repair_target); + attempt_elevated_windows_acl_repair(path, &target_user_sid, scope).map_err( + |repair_error| format!("{error};自动提权修复未完成:{repair_error}"), + )?; + } + Err(error) => return Err(error), + } + } + } +} + +/// Creates a private directory tree one component at a time. `create_dir_all` +/// can follow a junction that appears between components, so every existing +/// and newly-created component is checked before the next one is touched. +pub(crate) fn ensure_game_creator_private_directory_tree( + path: &Path, + label: &str, +) -> Result { + #[cfg(windows)] + validate_game_creator_private_path_ancestors_with_auto_elevation(path, label)?; + #[cfg(not(windows))] + validate_game_creator_private_path_ancestors(path, label)?; + let mut missing = Vec::new(); + let mut current = path.to_path_buf(); + loop { + match fs::symlink_metadata(¤t) { + Ok(metadata) => { + if metadata.file_type().is_symlink() { + return Err(format!("{label} 不能包含符号链接:{}", current.display())); + } + #[cfg(windows)] + { + use std::os::windows::fs::MetadataExt; + const FILE_ATTRIBUTE_REPARSE_POINT: u32 = 0x0000_0400; + if metadata.file_attributes() & FILE_ATTRIBUTE_REPARSE_POINT != 0 { + return Err(format!( + "{label} 不能包含 Windows reparse point:{}", + current.display() + )); + } + } + if !metadata.is_dir() { + return Err(format!( + "{label} 父路径必须是普通目录:{}", + current.display() + )); + } + break; + } + Err(error) if error.kind() == std::io::ErrorKind::NotFound => { + missing.push(current.clone()); + current = current + .parent() + .ok_or_else(|| { + format!("{label} 没有可用于创建的现存父目录:{}", path.display()) + })? + .to_path_buf(); + } + Err(error) => { + return Err(format!( + "读取 {label} 元数据失败:{}: {error}", + current.display() + )); + } + } + } + + let created_target = missing.first().is_some_and(|created| created == path); + for directory in missing.into_iter().rev() { + let create_result = fs::create_dir(&directory); + match create_result { + Ok(()) => { + #[cfg(windows)] + if game_creator_private_path_allows_auto_elevation(&directory) { + secure_windows_game_creator_path_for_current_user_with_auto_elevation( + &directory, true, true, + )?; + } else { + secure_windows_game_creator_path_for_current_user_with_owner_policy( + &directory, true, true, true, + )?; + } + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + fs::set_permissions(&directory, fs::Permissions::from_mode(0o700)).map_err( + |error| format!("收紧 {label} 权限失败:{}: {error}", directory.display()), + )?; + } + } + Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => { + // Another process won the create race. This object was not + // created by the current invocation, so it must go through + // the full existing-object owner/DACL gate before we touch + // any descendant. A type-only metadata check here would allow + // an attacker-created directory to become trusted. + prepare_game_creator_private_path_for_read(&directory, true, label)?; + } + #[cfg(windows)] + Err(error) + if matches!( + error.kind(), + std::io::ErrorKind::PermissionDenied | std::io::ErrorKind::WouldBlock + ) || matches!(error.raw_os_error(), Some(5)) => + { + let parent = directory.parent().ok_or_else(|| { + format!("创建 {label} 失败:{}: {error}", directory.display()) + })?; + if game_creator_private_path_allows_auto_elevation(parent) { + secure_windows_game_creator_path_for_current_user_with_auto_elevation( + parent, true, true, + )?; + } else { + secure_windows_game_creator_path_for_current_user_with_owner_policy( + parent, true, true, true, + )?; + } + fs::create_dir(&directory).map_err(|retry_error| { + format!("创建 {label} 失败:{}: {retry_error}", directory.display()) + })?; + if game_creator_private_path_allows_auto_elevation(&directory) { + secure_windows_game_creator_path_for_current_user_with_auto_elevation( + &directory, true, true, + )?; + } else { + secure_windows_game_creator_path_for_current_user_with_owner_policy( + &directory, true, true, true, + )?; + } + } + Err(error) => { + return Err(format!( + "创建 {label} 失败:{}: {error}", + directory.display() + )); + } + } + let metadata = fs::symlink_metadata(&directory) + .map_err(|error| format!("复核 {label} 失败:{}: {error}", directory.display()))?; + if metadata.file_type().is_symlink() || !metadata.is_dir() { + return Err(format!("{label} 必须是普通目录:{}", directory.display())); + } + #[cfg(windows)] + { + use std::os::windows::fs::MetadataExt; + const FILE_ATTRIBUTE_REPARSE_POINT: u32 = 0x0000_0400; + if metadata.file_attributes() & FILE_ATTRIBUTE_REPARSE_POINT != 0 { + return Err(format!( + "{label} 不能是 Windows reparse point:{}", + directory.display() + )); + } + } + } + Ok(created_target) +} + +/// Hardens a directory or file that this invocation has just created. This +/// intentionally does not adopt an existing object: callers that discover an +/// existing path must go through `prepare_game_creator_private_path_for_read`, +/// which performs the strict owner/reparse/DACL checks and the controlled UAC +/// repair flow. Keeping the two paths separate prevents a race from turning +/// an attacker-owned object into an AGC-managed credential or sidecar file. +pub(crate) fn harden_new_game_creator_private_path( + path: &Path, + is_directory: bool, + label: &str, +) -> Result<(), String> { + if !path.is_absolute() { + return Err(format!("{label}必须是绝对路径")); + } + let metadata = fs::symlink_metadata(path) + .map_err(|error| format!("读取新建 {label} 元数据失败:{}: {error}", path.display()))?; + if metadata.file_type().is_symlink() + || (is_directory && !metadata.is_dir()) + || (!is_directory && !metadata.is_file()) + { + return Err(format!( + "新建 {label} 必须是普通{},不能是链接或其他对象:{}", + if is_directory { "目录" } else { "文件" }, + path.display() + )); + } + #[cfg(windows)] + { + use std::os::windows::fs::MetadataExt; + const FILE_ATTRIBUTE_REPARSE_POINT: u32 = 0x0000_0400; + if metadata.file_attributes() & FILE_ATTRIBUTE_REPARSE_POINT != 0 { + return Err(format!( + "新建 {label} 不能是 Windows reparse point:{}", + path.display() + )); + } + // A newly-created object normally inherits the creator's security + // descriptor. AGC-owned roots may request the one-shot UAC repair; + // a user-selected path is still hardened strictly after creation so + // a race cannot turn an attacker-owned object into a credential file. + if game_creator_private_path_allows_auto_elevation(path) { + secure_windows_game_creator_path_for_current_user_with_auto_elevation( + path, + is_directory, + true, + )?; + } else { + secure_windows_game_creator_path_for_current_user_with_owner_policy( + path, + is_directory, + true, + true, + )?; + } + } + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + fs::set_permissions( + path, + fs::Permissions::from_mode(if is_directory { 0o700 } else { 0o600 }), + ) + .map_err(|error| format!("收紧新建 {label} 权限失败:{}: {error}", path.display()))?; + } + Ok(()) +} + +/// Writes a regular AGC-managed file only after its parent and any existing +/// target have passed the private-path policy. The post-write check also +/// hardens a newly-created file, so inherited Windows ACLs cannot remain on a +/// file that was created by this process. +pub(crate) fn write_game_creator_private_file( + path: &Path, + bytes: &[u8], + label: &str, +) -> Result<(), String> { + if !path.is_absolute() { + return Err(format!("{label}必须是绝对路径")); + } + let parent = path.parent().ok_or_else(|| format!("{label}缺少父目录"))?; + ensure_game_creator_private_directory_tree(parent, label)?; + prepare_game_creator_private_path_for_read(parent, true, label)?; + let existed = prepare_game_creator_private_path_for_read(path, false, label)?; + + // Never write directly through the checked pathname. A pathname can be + // replaced after the preflight by another process (or by a junction/link + // attack). Create and harden a sibling temporary inode first, then link + // it into place without replacement. Existing targets are moved to a + // unique backup only after a second strict check; if installation fails, + // the original target is restored. + let file_name = path + .file_name() + .and_then(|value| value.to_str()) + .ok_or_else(|| format!("{label}文件名无效"))?; + let temporary = (0..8) + .map(|attempt| { + path.with_file_name(format!( + ".{file_name}.tmp-{}-{}-{attempt}", + std::process::id(), + uuid::Uuid::new_v4().simple() + )) + }) + .find(|candidate| { + matches!( + fs::symlink_metadata(candidate), + Err(error) if error.kind() == std::io::ErrorKind::NotFound + ) + }) + .ok_or_else(|| format!("创建 {label} 临时文件路径失败:目录中存在冲突残留"))?; + + let mut options = fs::OpenOptions::new(); + options.write(true).create_new(true); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + options.custom_flags(libc::O_NOFOLLOW).mode(0o600); + } + #[cfg(windows)] + { + use std::os::windows::fs::OpenOptionsExt; + const FILE_FLAG_OPEN_REPARSE_POINT: u32 = 0x0020_0000; + options.custom_flags(FILE_FLAG_OPEN_REPARSE_POINT); + } + let mut file = options.open(&temporary).map_err(|error| { + format!( + "创建 {label} 临时文件失败:{}: {error}", + temporary.display() + ) + })?; + if let Err(error) = harden_new_game_creator_private_path(&temporary, false, label) { + drop(file); + let _ = fs::remove_file(&temporary); + return Err(error); + } + let write_result = std::io::Write::write_all(&mut file, bytes).and_then(|_| file.sync_all()); + drop(file); + if let Err(error) = write_result { + let _ = fs::remove_file(&temporary); + return Err(format!( + "写入 {label} 临时文件失败:{}: {error}", + temporary.display() + )); + } + let backup = path.with_file_name(format!( + ".{file_name}.previous-{}-{}", + std::process::id(), + uuid::Uuid::new_v4().simple() + )); + if existed { + // Re-check immediately before moving the old target. Links and + // reparse points remain fail-closed; only a regular managed file may + // enter the recoverable replacement path. + if let Err(error) = prepare_game_creator_private_path_for_read(path, false, label) { + let _ = fs::remove_file(&temporary); + return Err(error); + } + if fs::symlink_metadata(&backup).is_ok() { + let _ = fs::remove_file(&temporary); + return Err(format!("{label}替换备份路径已存在,已拒绝覆盖")); + } + if let Err(error) = fs::rename(path, &backup) { + let _ = fs::remove_file(&temporary); + return Err(format!("准备替换 {label} 失败:{error}")); + } + } + + let install_result = fs::hard_link(&temporary, path).and_then(|_| fs::remove_file(&temporary)); + if let Err(error) = install_result { + let _ = fs::remove_file(&temporary); + if existed { + let _ = fs::rename(&backup, path); + } + return Err(format!("原子安装 {label} 失败:{error}")); + } + if let Err(error) = prepare_game_creator_private_path_for_read(path, false, label) { + if existed { + let _ = fs::remove_file(path); + let _ = fs::rename(&backup, path); + } else { + let _ = fs::remove_file(path); + } + return Err(format!("复核 {label} 失败:{error}")); + } + if existed { + fs::remove_file(&backup) + .map_err(|error| format!("回收 {label} 旧文件备份失败:{error}"))?; + } + Ok(()) +} + +/// Appends to a regular AGC-managed file while applying the same ACL policy +/// as replacement writes. This is used for human-readable logs and memory +/// journals whose append semantics are part of their existing contract. +pub(crate) fn append_game_creator_private_file( + path: &Path, + bytes: &[u8], + label: &str, +) -> Result<(), String> { + if !path.is_absolute() { + return Err(format!("{label}必须是绝对路径")); + } + let parent = path.parent().ok_or_else(|| format!("{label}缺少父目录"))?; + ensure_game_creator_private_directory_tree(parent, label)?; + prepare_game_creator_private_path_for_read(parent, true, label)?; + let existed = prepare_game_creator_private_path_for_read(path, false, label)?; + if existed { + let metadata = fs::symlink_metadata(path) + .map_err(|error| format!("读取 {label} 元数据失败:{}: {error}", path.display()))?; + if !metadata.is_file() { + return Err(format!("{label} 必须是普通文件")); + } + } + let mut options = fs::OpenOptions::new(); + options.write(true).append(true).read(true); + if !existed { + options.create_new(true); + } + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + options.custom_flags(libc::O_NOFOLLOW).mode(0o600); + } + #[cfg(windows)] + { + use std::os::windows::fs::OpenOptionsExt; + const FILE_FLAG_OPEN_REPARSE_POINT: u32 = 0x0020_0000; + const FILE_SHARE_READ: u32 = 0x0000_0001; + const FILE_SHARE_WRITE: u32 = 0x0000_0002; + const FILE_SHARE_DELETE: u32 = 0x0000_0004; + options + .custom_flags(FILE_FLAG_OPEN_REPARSE_POINT) + .share_mode(FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE); + } + let mut file = options + .open(path) + .map_err(|error| format!("写入 {label} 失败:{}: {error}", path.display()))?; + let opened_metadata = file.metadata().map_err(|error| { + format!( + "读取 {label} 文件句柄元数据失败:{}: {error}", + path.display() + ) + })?; + if !opened_metadata.is_file() { + drop(file); + return Err(format!("{label} 必须是普通文件")); + } + #[cfg(unix)] + { + use std::os::unix::fs::MetadataExt; + if opened_metadata.nlink() != 1 { + drop(file); + return Err(format!("{label} 不能是硬链接")); + } + let path_metadata = fs::symlink_metadata(path) + .map_err(|error| format!("复核 {label} 路径失败:{}: {error}", path.display()))?; + if path_metadata.file_type().is_symlink() + || path_metadata.dev() != opened_metadata.dev() + || path_metadata.ino() != opened_metadata.ino() + { + drop(file); + return Err(format!("{label} 路径在安全打开期间发生替换")); + } + } + #[cfg(windows)] + crate::runner::validate_windows_regular_file_handle(&file, label)?; + if !existed { + if let Err(error) = harden_new_game_creator_private_path(path, false, label) { + drop(file); + let _ = fs::remove_file(path); + return Err(error); + } + } + use std::io::Write as _; + file.write_all(bytes) + .and_then(|_| file.sync_data()) + .map_err(|error| format!("写入 {label} 失败:{}: {error}", path.display()))?; + if existed { + prepare_game_creator_private_path_for_read(path, false, label)?; + } + Ok(()) +} + +/// Checks a private file/directory before the caller opens it. Windows ACL +/// failures must be handled before `OpenOptions::open`: an inherited DACL can +/// otherwise make the open fail before the strict verifier gets a chance to +/// request UAC repair. Missing leaves are returned as `false`; existing +/// objects are fully validated and, on Windows, repaired/re-validated through +/// the normal auto-elevation path. +pub(crate) fn prepare_game_creator_private_path_for_read( + path: &Path, + is_directory: bool, + label: &str, +) -> Result { + let metadata = match fs::symlink_metadata(path) { + Ok(metadata) => metadata, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(false), + Err(error) => { + let detail = format!("读取 {label} 元数据失败:{}: {error}", path.display()); + #[cfg(windows)] + { + let result = if game_creator_private_path_allows_auto_elevation(path) { + secure_windows_game_creator_path_for_current_user_with_auto_elevation( + path, + is_directory, + true, + ) + } else { + secure_windows_game_creator_path_for_current_user(path, is_directory, true) + }; + return result.map(|_| true).map_err(|repair_error| { + if game_creator_private_path_allows_auto_elevation(path) { + format!("{detail};自动提权修复未完成:{repair_error}") + } else { + format!("{detail};严格私有权限校验未通过:{repair_error}") + } + }); + } + #[cfg(not(windows))] + return Err(detail); + } + }; + if metadata.file_type().is_symlink() + || (is_directory && !metadata.is_dir()) + || (!is_directory && !metadata.is_file()) + { + return Err(format!( + "{label} 必须是普通{},不能是链接或其他对象:{}", + if is_directory { "目录" } else { "文件" }, + path.display() + )); + } + // Metadata on the leaf can be readable while traversal of an ancestor is + // blocked by a foreign owner or inherited ACL. Give the managed object a + // single explicit repair opportunity before surfacing a permission error; + // links/reparse points have already been rejected above and therefore stay + // fail-closed. + #[cfg(windows)] + validate_game_creator_private_path_ancestors_with_auto_elevation(path, label)?; + #[cfg(not(windows))] + validate_game_creator_private_path_ancestors(path, label)?; + #[cfg(windows)] + if game_creator_private_path_allows_auto_elevation(path) { + secure_windows_game_creator_path_for_current_user_with_auto_elevation( + path, + is_directory, + true, + )?; + } else { + // User-selected external files are never silently adopted. Keep the + // strict owner/DACL check, but do not escalate an arbitrary path. + secure_windows_game_creator_path_for_current_user(path, is_directory, true)?; + } + Ok(true) +} + +/// Prepares a path returned by an explicit native file picker. On Windows, +/// owner/DACL failures on a regular selected object receive the same one-shot +/// UAC repair as AGC-managed files, including projects stored outside the +/// current profile. Reparse points, links, non-regular objects and ancestor +/// type conflicts are rejected before any repair attempt. +pub(crate) fn prepare_game_creator_user_selected_path_for_read( + path: &Path, + is_directory: bool, + label: &str, +) -> Result { + let metadata = match fs::symlink_metadata(path) { + Ok(metadata) => metadata, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(false), + Err(error) => { + let detail = format!("读取 {label} 元数据失败:{}: {error}", path.display()); + #[cfg(windows)] + { + // Explicit native file-picker/project-root selections are + // bounded UAC targets even when the normal token cannot + // traverse far enough to read leaf metadata. The helper still + // re-validates owner, type and reparse state before changing + // anything. + if game_creator_user_selected_path_allows_auto_elevation(path) + && windows_acl_error_may_need_elevation(&detail) + { + return secure_windows_game_creator_path_for_current_user_with_auto_elevation_scoped( + path, + is_directory, + true, + WindowsAclRepairScope::UserSelected, + ) + .map(|_| true) + .map_err(|repair_error| { + format!("{detail};自动提权修复未完成:{repair_error}") + }); + } + } + return Err(detail); + } + }; + if metadata.file_type().is_symlink() + || (is_directory && !metadata.is_dir()) + || (!is_directory && !metadata.is_file()) + { + return Err(format!( + "{label} 必须是普通{},不能是链接或其他对象:{}", + if is_directory { "目录" } else { "文件" }, + path.display() + )); + } + #[cfg(windows)] + { + use std::os::windows::fs::MetadataExt; + const FILE_ATTRIBUTE_REPARSE_POINT: u32 = 0x0000_0400; + if metadata.file_attributes() & FILE_ATTRIBUTE_REPARSE_POINT != 0 { + return Err(format!( + "{label} 不能是 Windows reparse point:{}", + path.display() + )); + } + if game_creator_user_selected_path_allows_auto_elevation(path) { + validate_game_creator_private_path_ancestors_with_auto_elevation_scoped( + path, + label, + WindowsAclRepairScope::UserSelected, + )?; + secure_windows_game_creator_path_for_current_user_with_auto_elevation_scoped( + path, + is_directory, + true, + WindowsAclRepairScope::UserSelected, + )?; + } else { + validate_game_creator_private_path_ancestors(path, label)?; + secure_windows_game_creator_path_for_current_user(path, is_directory, true)?; + } + } + #[cfg(not(windows))] + { + validate_game_creator_private_path_ancestors(path, label)?; + } + Ok(true) +} + +/// Reads an AGC-managed private text file through a validated regular-file +/// handle. The pathname is used only for preflight/open identity checks; bytes +/// are read from the handle so a later rename cannot redirect the read. +pub(crate) fn read_game_creator_private_file_to_string( + path: &Path, + label: &str, + max_bytes: u64, +) -> Result { + let (mut file, metadata) = open_project_private_regular_file(path, label)?; + if metadata.len() > max_bytes { + return Err(format!("{label}过大,已拒绝读取:{}", path.display())); + } + let mut content = String::with_capacity(metadata.len() as usize); + file.read_to_string(&mut content) + .map_err(|error| format!("读取{label}失败:{}: {error}", path.display()))?; + let final_metadata = file + .metadata() + .map_err(|error| format!("复核{label}失败:{}: {error}", path.display()))?; + if final_metadata.len() != metadata.len() { + return Err(format!("{label}读取期间文件发生漂移:{}", path.display())); + } + Ok(content) +} + fn resolve_game_creator_runtime_config_dir( path: &Path, create_and_tighten: bool, @@ -645,18 +1731,23 @@ fn resolve_game_creator_runtime_config_dir( if !path.is_absolute() { return Err("客户端 AppData 配置目录必须是绝对路径".to_string()); } + #[cfg(windows)] + validate_game_creator_private_path_ancestors_with_auto_elevation( + path, + "客户端 AppData 配置目录", + )?; + #[cfg(not(windows))] + validate_game_creator_private_path_ancestors(path, "客户端 AppData 配置目录")?; let mut created = false; if create_and_tighten { match fs::symlink_metadata(path) { Ok(_) => {} Err(error) if error.kind() == std::io::ErrorKind::NotFound => { if let Some(parent) = path.parent() { - fs::create_dir_all(parent).map_err(|create_error| { - format!( - "创建客户端 AppData 配置父目录失败:{}: {create_error}", - parent.display() - ) - })?; + ensure_game_creator_private_directory_tree( + parent, + "客户端 AppData 配置父目录", + )?; } match fs::create_dir(path) { Ok(()) => created = true, @@ -691,8 +1782,31 @@ fn resolve_game_creator_runtime_config_dir( { Ok(()) => {} #[cfg(windows)] + Err(error) if !cfg!(test) && windows_acl_error_may_need_elevation(&error) => { + // Any existing private AppData directory that cannot be safely + // read is repaired through the one-shot elevated helper. This + // covers inherited ACLs and foreign owners while the earlier + // entry/reparse checks remain fail-closed. + let target_user_sid = current_windows_token_user_sid_string() + .map_err(|sid_error| format!("{error};读取当前用户 SID 失败:{sid_error}"))?; + attempt_elevated_windows_acl_repair( + path, + &target_user_sid, + WindowsAclRepairScope::Managed, + ) + .map_err(|repair_error| format!("{error};自动提权修复未完成:{repair_error}"))?; + validate_game_creator_runtime_config_dir_metadata(path, true, false)?; + return fs::canonicalize(path).map_err(|canonicalize_error| { + format!( + "ACL 修复后解析客户端 AppData 配置目录失败:{}: {canonicalize_error}", + path.display() + ) + }); + } + #[cfg(windows)] Err(error) - if create_and_tighten + if cfg!(test) + && create_and_tighten && !created && error.starts_with("Windows 安全对象不属于当前用户:") => { @@ -820,14 +1934,102 @@ pub(crate) fn secure_windows_game_creator_path_for_current_user( is_directory: bool, tighten: bool, ) -> Result<(), String> { - secure_windows_game_creator_path_for_current_user_with_owner_policy( + secure_windows_game_creator_path_for_user_sid_with_owner_policy( path, is_directory, tighten, false, + None, ) } +/// Strictly validates a Windows private object and, when its owner/DACL cannot +/// be used by the current account, performs one explicit UAC repair before +/// validating again. Reparse points and non-regular objects remain +/// fail-closed; a repaired regular object is reassigned to the current token +/// user and receives a private non-inherited DACL. +#[cfg(windows)] +pub(crate) fn secure_windows_game_creator_path_for_current_user_with_auto_elevation( + path: &Path, + is_directory: bool, + tighten: bool, +) -> Result<(), String> { + secure_windows_game_creator_path_for_current_user_with_auto_elevation_scoped( + path, + is_directory, + tighten, + WindowsAclRepairScope::Managed, + ) +} + +/// Same strict verifier as the managed-path entry, but with an explicit scope +/// selected by the caller. This is used by file-picker imports only; the +/// scope is included in the one-shot UAC ticket and checked again by the +/// elevated child process. +#[cfg(windows)] +fn secure_windows_game_creator_path_for_current_user_with_auto_elevation_scoped( + path: &Path, + is_directory: bool, + tighten: bool, + scope: WindowsAclRepairScope, +) -> Result<(), String> { + #[cfg(test)] + { + // Unit tests must not trigger an interactive UAC prompt. The strict + // verifier remains directly testable. For an owner-correct object + // whose only defect is an inherited DACL, emulate the formal prepare + // entry's local tightening in-process; foreign-owner fixtures still + // fail closed because they cannot be reassigned without elevation. + return match secure_windows_game_creator_path_for_current_user(path, is_directory, tighten) + { + Ok(()) => Ok(()), + Err(error) + if scope.allows_path(path) + && windows_acl_error_may_need_elevation(&error) + && !error.contains("安全对象不属于当前用户") => + { + secure_windows_game_creator_path_for_current_user_with_owner_policy( + path, + is_directory, + true, + false, + ) + } + Err(error) => Err(error), + }; + } + #[cfg(not(test))] + { + let target_user_sid = current_windows_token_user_sid_string()?; + let mut attempted_targets = Vec::::new(); + loop { + match secure_windows_game_creator_path_for_current_user(path, is_directory, tighten) { + Ok(()) => return Ok(()), + Err(error) if windows_acl_error_may_need_elevation(&error) => { + if !scope.allows_path(path) { + return Err(error); + } + let repair_target = windows_acl_repair_target(path, scope); + if attempted_targets + .iter() + .any(|target| target == &repair_target) + { + return Err(format!( + "{error};自动提权修复重复命中同一目标,拒绝继续重试:{}", + repair_target.display() + )); + } + attempted_targets.push(repair_target); + attempt_elevated_windows_acl_repair(path, &target_user_sid, scope).map_err( + |repair_error| format!("{error};自动提权修复未完成:{repair_error}"), + )?; + } + Err(error) => return Err(error), + } + } + } +} + #[cfg(windows)] pub(crate) fn initialize_windows_game_creator_file_owner_for_current_user( path: &Path, @@ -845,6 +2047,447 @@ pub(crate) fn initialize_windows_game_creator_directory_owner_for_current_user( secure_windows_game_creator_path_for_current_user_with_owner_policy(path, true, true, true) } +/// Repairs an AGC-managed private object after an explicit UAC elevation. +/// Foreign-owned regular files/directories are deliberately reassigned to the +/// current token user here. The caller has already rejected links/reparse +/// points, and the final strict verification below is mandatory. +#[cfg(windows)] +pub(crate) fn repair_game_creator_private_acl_for_current_user(path: &Path) -> Result<(), String> { + let target_user_sid = current_windows_token_user_sid_string()?; + repair_game_creator_private_acl_for_user_sid(path, &target_user_sid) +} + +#[cfg(windows)] +fn current_windows_token_user_sid_string() -> Result { + use std::ffi::c_void; + + type Handle = *mut c_void; + type Sid = *mut c_void; + + #[repr(C)] + struct SidAndAttributes { + sid: Sid, + attributes: u32, + } + + #[repr(C)] + struct TokenUser { + user: SidAndAttributes, + } + + #[link(name = "advapi32")] + unsafe extern "system" { + fn OpenProcessToken(process: Handle, access: u32, token: *mut Handle) -> i32; + fn GetTokenInformation( + token: Handle, + information_class: u32, + information: *mut c_void, + information_length: u32, + return_length: *mut u32, + ) -> i32; + fn ConvertSidToStringSidW(sid: Sid, string_sid: *mut *mut u16) -> i32; + } + + #[link(name = "kernel32")] + unsafe extern "system" { + fn GetCurrentProcess() -> Handle; + fn CloseHandle(handle: Handle) -> i32; + fn LocalFree(memory: *mut c_void) -> *mut c_void; + } + + const TOKEN_QUERY: u32 = 0x0000_0008; + const TOKEN_USER_CLASS: u32 = 1; + + let mut token = std::ptr::null_mut(); + if unsafe { OpenProcessToken(GetCurrentProcess(), TOKEN_QUERY, &mut token) } == 0 + || token.is_null() + { + return Err(format!( + "读取 Windows 当前用户 token 失败:{}", + std::io::Error::last_os_error() + )); + } + let result = (|| { + let mut required = 0_u32; + unsafe { + GetTokenInformation( + token, + TOKEN_USER_CLASS, + std::ptr::null_mut(), + 0, + &mut required, + ); + } + if required == 0 { + return Err("读取 Windows 当前用户 SID 长度失败".to_string()); + } + let word_size = std::mem::size_of::(); + let mut token_buffer = vec![0_usize; (required as usize).div_ceil(word_size)]; + if unsafe { + GetTokenInformation( + token, + TOKEN_USER_CLASS, + token_buffer.as_mut_ptr().cast(), + required, + &mut required, + ) + } == 0 + { + return Err(format!( + "读取 Windows 当前用户 SID 失败:{}", + std::io::Error::last_os_error() + )); + } + let sid = unsafe { (*(token_buffer.as_ptr().cast::())).user.sid }; + if sid.is_null() { + return Err("Windows 当前用户 SID 无效".to_string()); + } + let mut string_sid = std::ptr::null_mut(); + if unsafe { ConvertSidToStringSidW(sid, &mut string_sid) } == 0 || string_sid.is_null() { + return Err(format!( + "转换 Windows 当前用户 SID 失败:{}", + std::io::Error::last_os_error() + )); + } + let mut length = 0_usize; + while unsafe { *string_sid.add(length) } != 0 { + length = length.saturating_add(1); + if length > 256 { + unsafe { LocalFree(string_sid.cast()) }; + return Err("Windows 当前用户 SID 长度无效".to_string()); + } + } + let value = String::from_utf16(unsafe { std::slice::from_raw_parts(string_sid, length) }) + .map_err(|_| "Windows 当前用户 SID 编码无效".to_string()); + unsafe { LocalFree(string_sid.cast()) }; + value + })(); + unsafe { CloseHandle(token) }; + result +} + +/// The elevated helper may run under administrator credentials that differ +/// from the original desktop user's token. Keep ownership and the private +/// DACL bound to the original TokenUser SID passed by the caller. +#[cfg(windows)] +pub(crate) fn repair_game_creator_private_acl_for_user_sid( + path: &Path, + target_user_sid: &str, +) -> Result<(), String> { + let metadata = fs::symlink_metadata(path) + .map_err(|error| format!("读取待修复私有对象失败:{}: {error}", path.display()))?; + use std::os::windows::fs::MetadataExt; + const FILE_ATTRIBUTE_REPARSE_POINT: u32 = 0x0000_0400; + if metadata.file_attributes() & FILE_ATTRIBUTE_REPARSE_POINT != 0 + || metadata.file_type().is_symlink() + { + return Err("待修复私有对象不能是 Windows reparse point 或链接".to_string()); + } + if !metadata.is_dir() && !metadata.is_file() { + return Err("待修复私有对象必须是普通文件或目录".to_string()); + } + secure_windows_game_creator_path_for_user_sid_with_owner_policy( + path, + metadata.is_dir(), + true, + true, + Some(target_user_sid), + ) +} + +#[cfg(windows)] +pub(crate) fn windows_acl_error_may_need_elevation(error: &str) -> bool { + (error.contains("DACL") + || error.contains("权限") + || error.contains("error 5") + || error.contains("安全对象不属于当前用户") + || error.contains("启用 Windows") + || error.contains("特权") + || error.contains("1300")) + && !error.contains("链接") + && !error.contains("reparse") +} + +#[cfg(windows)] +fn windows_acl_repair_target(path: &Path, scope: WindowsAclRepairScope) -> PathBuf { + // A user-selected object is the only trusted identity in that scope. Do + // not widen it to an unreadable parent (which could be another user's + // profile or a protected system directory); the helper will validate the + // selected regular object itself and fail closed if traversal remains + // impossible. Managed paths have an established AGC root and may repair + // the first blocked ancestor so inherited ACLs can be fixed in one pass. + if matches!(scope, WindowsAclRepairScope::UserSelected) { + return path.to_path_buf(); + } + // Walk from the filesystem root towards the leaf. If traversal is denied + // on an ancestor, repairing the leaf cannot help because the elevated + // helper will hit the same ancestor before it can inspect the leaf. + for ancestor in path.ancestors().collect::>().into_iter().rev() { + match fs::symlink_metadata(ancestor) { + Ok(_) => {} + Err(error) + if error.kind() == std::io::ErrorKind::PermissionDenied + || error.raw_os_error() == Some(5) => + { + return ancestor.to_path_buf(); + } + Err(_) => {} + } + } + path.to_path_buf() +} + +#[cfg(windows)] +const WINDOWS_ACL_REPAIR_AUTHORIZATION_MAX_BYTES: u64 = 4 * 1024; + +#[cfg(windows)] +struct WindowsAclRepairAuthorizationCleanup { + path: PathBuf, + armed: bool, +} + +#[cfg(windows)] +impl WindowsAclRepairAuthorizationCleanup { + fn new(path: PathBuf) -> Self { + Self { path, armed: true } + } + + fn disarm(&mut self) { + self.armed = false; + } +} + +#[cfg(windows)] +impl Drop for WindowsAclRepairAuthorizationCleanup { + fn drop(&mut self) { + if self.armed { + let _ = fs::remove_file(&self.path); + } + } +} + +#[cfg(windows)] +#[derive(Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +struct WindowsAclRepairAuthorization { + path: String, + target_user_sid: String, + scope: String, + issued_at: u64, +} + +#[cfg(windows)] +fn windows_acl_repair_authorization_path(nonce: &str) -> Result { + let nonce = nonce.trim(); + if nonce.len() != 32 || !nonce.bytes().all(|byte| byte.is_ascii_hexdigit()) { + return Err("AGC ACL 修复授权票据无效".to_string()); + } + let temporary_directory = std::env::temp_dir(); + if !temporary_directory.is_absolute() { + return Err("AGC ACL 修复临时目录必须是绝对路径".to_string()); + } + Ok(temporary_directory.join(format!(".genarrative-acl-repair-{nonce}.json"))) +} + +#[cfg(windows)] +fn create_windows_acl_repair_authorization( + path: &Path, + target_user_sid: &str, + scope: WindowsAclRepairScope, +) -> Result { + let nonce = uuid::Uuid::new_v4().simple().to_string(); + let authorization_path = windows_acl_repair_authorization_path(&nonce)?; + let payload = serde_json::to_vec(&WindowsAclRepairAuthorization { + path: path.to_string_lossy().into_owned(), + target_user_sid: target_user_sid.to_string(), + scope: scope.wire_name().to_string(), + issued_at: unix_timestamp(), + }) + .map_err(|error| format!("创建 AGC ACL 修复授权票据失败:{error}"))?; + let mut options = fs::OpenOptions::new(); + options.write(true).create_new(true); + #[cfg(windows)] + { + use std::os::windows::fs::OpenOptionsExt; + options.custom_flags(PROJECT_FILE_FLAG_OPEN_REPARSE_POINT); + } + let mut file = options.open(&authorization_path).map_err(|error| { + format!( + "创建 AGC ACL 修复授权票据文件失败:{}: {error}", + authorization_path.display() + ) + })?; + // The ticket is created in the per-user TEMP directory while the normal + // process is not elevated. Do not call the auto-elevation wrapper here: + // that wrapper itself creates a ticket and would recurse indefinitely when + // TEMP inherits a broad DACL. The strict owner-policy path can still + // tighten an owner-correct inherited ACL and rejects a foreign TEMP owner. + if let Err(error) = secure_windows_game_creator_path_for_current_user_with_owner_policy( + &authorization_path, + false, + true, + true, + ) { + drop(file); + let _ = fs::remove_file(&authorization_path); + return Err(format!("初始化 AGC ACL 修复授权票据安全权限失败:{error}")); + } + if let Err(error) = file.write_all(&payload).and_then(|_| file.sync_all()) { + drop(file); + let _ = fs::remove_file(&authorization_path); + return Err(format!("写入 AGC ACL 修复授权票据失败:{error}")); + } + drop(file); + Ok(nonce) +} + +#[cfg(windows)] +pub(crate) fn consume_windows_acl_repair_authorization( + path: &Path, + target_user_sid: &str, + nonce: &str, + scope: WindowsAclRepairScope, +) -> Result<(), String> { + if !scope.allows_path(path) { + return Err(format!( + "AGC ACL 修复目标不在当前用户允许的 {} 范围内:{}", + scope.wire_name(), + path.display() + )); + } + let authorization_path = windows_acl_repair_authorization_path(nonce)?; + let mut cleanup = WindowsAclRepairAuthorizationCleanup::new(authorization_path.clone()); + + // Open the ticket first with no sharing. The old path-metadata-first + // sequence left a replacement window between owner/DACL validation and + // the actual read. The exclusive handle pins the object while the + // descriptor is checked below and while its bytes are consumed. + use std::os::windows::fs::MetadataExt; + use std::os::windows::fs::OpenOptionsExt; + const FILE_ATTRIBUTE_REPARSE_POINT: u32 = 0x0000_0400; + use std::io::Read as _; + const FILE_FLAG_OPEN_REPARSE_POINT: u32 = 0x0020_0000; + let mut file = fs::OpenOptions::new() + .read(true) + .share_mode(0) + .custom_flags(FILE_FLAG_OPEN_REPARSE_POINT) + .open(&authorization_path) + .map_err(|error| format!("读取 AGC ACL 修复授权票据内容失败:{error}"))?; + crate::runner::validate_windows_regular_file_handle(&file, "AGC ACL 修复授权票据")?; + let opened_metadata = file + .metadata() + .map_err(|error| format!("读取 AGC ACL 修复授权票据句柄元数据失败:{error}"))?; + if opened_metadata.file_attributes() & FILE_ATTRIBUTE_REPARSE_POINT != 0 + || !opened_metadata.is_file() + { + return Err("AGC ACL 修复授权票据必须是普通文件".to_string()); + } + if opened_metadata.len() > WINDOWS_ACL_REPAIR_AUTHORIZATION_MAX_BYTES { + return Err("AGC ACL 修复授权票据过大".to_string()); + } + // The elevated helper runs under an administrator token, so validate the + // ticket against the original desktop user's SID rather than the helper's + // current SID. This also rejects a ticket placed in TEMP by another user. + // This check deliberately happens after the exclusive open so the path + // descriptor cannot be swapped while it is being authorized. + secure_windows_game_creator_path_for_user_sid_with_owner_policy( + &authorization_path, + false, + true, + false, + Some(target_user_sid), + )?; + + let mut content = String::new(); + file.read_to_string(&mut content) + .map_err(|error| format!("读取 AGC ACL 修复授权票据内容失败:{error}"))?; + let final_metadata = file + .metadata() + .map_err(|error| format!("复核 AGC ACL 修复授权票据句柄元数据失败:{error}"))?; + if final_metadata.len() != opened_metadata.len() { + return Err("AGC ACL 修复授权票据读取期间发生漂移".to_string()); + } + let authorization = serde_json::from_str::(&content) + .map_err(|error| format!("AGC ACL 修复授权票据格式无效:{error}"))?; + if unix_timestamp().saturating_sub(authorization.issued_at) > 300 { + return Err("AGC ACL 修复授权票据已过期".to_string()); + } + if authorization.path != path.to_string_lossy() + || authorization.target_user_sid != target_user_sid + || authorization.scope != scope.wire_name() + { + return Err("AGC ACL 修复授权票据与目标不匹配".to_string()); + } + + // share_mode(0) intentionally keeps the ticket pinned during validation; + // release the handle before consuming the one-shot file. The cleanup + // guard retries removal on every failure path, while a successful removal + // disarms it to avoid a second delete attempt during unwinding. + drop(file); + fs::remove_file(&authorization_path) + .map_err(|error| format!("删除 AGC ACL 修复授权票据失败:{error}"))?; + cleanup.disarm(); + Ok(()) +} + +/// Starts a one-shot elevated copy of the current executable. The elevated +/// process performs only the allow-listed ACL repair command and exits with a +/// truthful status; UAC cancellation is never treated as success. +#[cfg(windows)] +fn attempt_elevated_windows_acl_repair( + path: &Path, + target_user_sid: &str, + scope: WindowsAclRepairScope, +) -> Result<(), String> { + if !scope.allows_path(path) { + return Err(format!( + "AGC ACL 提权目标不在当前用户允许的 {} 范围内:{}", + scope.wire_name(), + path.display() + )); + } + let executable = + std::env::current_exe().map_err(|error| format!("定位 AGC ACL 修复程序失败:{error}"))?; + if !executable.is_file() { + return Err("AGC ACL 修复程序不存在".to_string()); + } + let repair_path = windows_acl_repair_target(path, scope); + let nonce = create_windows_acl_repair_authorization(&repair_path, target_user_sid, scope)?; + let escaped_executable = executable.to_string_lossy().replace('\'', "''"); + let escaped_path = repair_path.to_string_lossy().replace('\'', "''"); + let escaped_target_user_sid = target_user_sid.replace('\'', "''"); + let escaped_nonce = nonce.replace('\'', "''"); + let script = format!( + "$ErrorActionPreference = 'Stop'; try {{ $p = Start-Process -Verb RunAs -Wait -PassThru -FilePath '{escaped_executable}' -ArgumentList @('--repair-private-acl','{escaped_path}','--target-user-sid','{escaped_target_user_sid}','--authorization','{escaped_nonce}','--scope','{}'); if ($null -eq $p) {{ exit 1223 }}; exit $p.ExitCode }} catch {{ exit 1223 }}", + scope.wire_name() + ); + use std::os::windows::process::CommandExt; + let status = std::process::Command::new("powershell.exe") + .args([ + "-NoProfile", + "-NonInteractive", + "-WindowStyle", + "Hidden", + "-Command", + script.as_str(), + ]) + .creation_flags(0x0800_0000) + .status() + .map_err(|error| format!("启动 AGC ACL 提权修复失败:{error}")); + let _ = windows_acl_repair_authorization_path(&nonce).and_then(|authorization_path| { + fs::remove_file(authorization_path).map_err(|error| error.to_string()) + }); + let status = status?; + if status.success() { + Ok(()) + } else { + Err(format!( + "AGC ACL 提权修复未成功(exit code {:?})", + status.code() + )) + } +} + #[cfg(windows)] pub(crate) fn windows_private_dacl_security_information( initialize_owner: bool, @@ -861,11 +2504,26 @@ pub(crate) fn windows_private_dacl_security_information( } else { 0 } - | if initialize_owner && !owner_matches { - OWNER_SECURITY_INFORMATION - } else { - 0 - } +} + +#[cfg(windows)] +fn windows_security_object_path(path: &Path) -> std::ffi::OsString { + use std::ffi::OsString; + + let raw = path.as_os_str().to_string_lossy(); + // GetNamedSecurityInfoW/SetNamedSecurityInfoW report ERROR_INVALID_NAME + // for ordinary absolute paths at the MAX_PATH boundary. Extended-length + // paths are accepted by these APIs and preserve the exact object identity. + // Do not add the prefix twice, and translate UNC paths to the documented + // \\?\UNC\server\share form. + if raw.starts_with("\\\\?\\") || raw.encode_utf16().count() < 260 { + return path.as_os_str().to_os_string(); + } + if let Some(unc) = raw.strip_prefix("\\\\") { + OsString::from(format!("\\\\?\\UNC\\{unc}")) + } else { + OsString::from(format!("\\\\?\\{raw}")) + } } #[cfg(windows)] @@ -874,10 +2532,57 @@ fn secure_windows_game_creator_path_for_current_user_with_owner_policy( is_directory: bool, tighten: bool, initialize_owner: bool, +) -> Result<(), String> { + secure_windows_game_creator_path_for_user_sid_with_owner_policy( + path, + is_directory, + tighten, + initialize_owner, + None, + ) +} + +#[cfg(windows)] +fn secure_windows_game_creator_path_for_user_sid_with_owner_policy( + path: &Path, + is_directory: bool, + tighten: bool, + initialize_owner: bool, + target_user_sid: Option<&str>, ) -> Result<(), String> { use std::ffi::c_void; use std::os::windows::ffi::OsStrExt; + let metadata = fs::symlink_metadata(path).map_err(|error| { + format!( + "读取 Windows 私有对象元数据失败:{}: {error}", + path.display() + ) + })?; + if metadata.file_type().is_symlink() { + return Err(format!( + "Windows 私有对象不能是符号链接:{}", + path.display() + )); + } + { + use std::os::windows::fs::MetadataExt; + const FILE_ATTRIBUTE_REPARSE_POINT: u32 = 0x0000_0400; + if metadata.file_attributes() & FILE_ATTRIBUTE_REPARSE_POINT != 0 { + return Err(format!( + "Windows 私有对象不能是 reparse point:{}", + path.display() + )); + } + } + if (is_directory && !metadata.is_dir()) || (!is_directory && !metadata.is_file()) { + return Err(format!( + "Windows 私有对象类型不符合预期:{}", + path.display() + )); + } + validate_game_creator_private_path_ancestors(path, "Windows 私有对象")?; + type Handle = *mut c_void; type Sid = *mut c_void; @@ -932,6 +2637,24 @@ fn secure_windows_game_creator_path_for_current_user_with_owner_policy( sid_start: u32, } + #[repr(C)] + struct Luid { + low_part: u32, + high_part: i32, + } + + #[repr(C)] + struct LuidAndAttributes { + luid: Luid, + attributes: u32, + } + + #[repr(C)] + struct TokenPrivileges { + privilege_count: u32, + privileges: [LuidAndAttributes; 1], + } + #[link(name = "advapi32")] unsafe extern "system" { fn GetNamedSecurityInfoW( @@ -960,6 +2683,16 @@ fn secure_windows_game_creator_path_for_current_user_with_owner_policy( new_acl: *mut *mut c_void, ) -> u32; fn OpenProcessToken(process: Handle, access: u32, token: *mut Handle) -> i32; + fn LookupPrivilegeValueW(system_name: *const u16, name: *const u16, luid: *mut Luid) + -> i32; + fn AdjustTokenPrivileges( + token: Handle, + disable_all_privileges: i32, + new_state: *mut TokenPrivileges, + buffer_length: u32, + previous_state: *mut TokenPrivileges, + return_length: *mut u32, + ) -> i32; fn GetTokenInformation( token: Handle, information_class: u32, @@ -967,8 +2700,10 @@ fn secure_windows_game_creator_path_for_current_user_with_owner_policy( information_length: u32, return_length: *mut u32, ) -> i32; + fn ConvertStringSidToSidW(string_sid: *const u16, sid: *mut Sid) -> i32; fn EqualSid(first: Sid, second: Sid) -> i32; fn IsValidSid(sid: Sid) -> i32; + fn GetLengthSid(sid: Sid) -> u32; fn IsValidAcl(acl: *mut c_void) -> i32; fn GetAce(acl: *mut c_void, index: u32, ace: *mut *mut c_void) -> i32; fn GetSecurityDescriptorControl( @@ -983,6 +2718,7 @@ fn secure_windows_game_creator_path_for_current_user_with_owner_policy( fn GetCurrentProcess() -> Handle; fn CloseHandle(handle: Handle) -> i32; fn LocalFree(memory: *mut c_void) -> *mut c_void; + fn GetLastError() -> u32; } const SE_FILE_OBJECT: u32 = 1; @@ -990,7 +2726,10 @@ fn secure_windows_game_creator_path_for_current_user_with_owner_policy( const DACL_SECURITY_INFORMATION: u32 = 0x0000_0004; const SE_DACL_PROTECTED: u16 = 0x1000; const TOKEN_QUERY: u32 = 0x0000_0008; + const TOKEN_ADJUST_PRIVILEGES: u32 = 0x0000_0020; const TOKEN_USER_CLASS: u32 = 1; + const SE_PRIVILEGE_ENABLED: u32 = 0x0000_0002; + const ERROR_NOT_ALL_ASSIGNED: u32 = 1300; const SET_ACCESS: i32 = 2; const TRUSTEE_IS_SID: i32 = 0; const TRUSTEE_IS_USER: i32 = 1; @@ -1001,7 +2740,13 @@ fn secure_windows_game_creator_path_for_current_user_with_owner_policy( let mut token = std::ptr::null_mut(); // SAFETY: GetCurrentProcess returns a valid pseudo handle and token is a valid output pointer. - if unsafe { OpenProcessToken(GetCurrentProcess(), TOKEN_QUERY, &mut token) } == 0 + if unsafe { + OpenProcessToken( + GetCurrentProcess(), + TOKEN_QUERY | TOKEN_ADJUST_PRIVILEGES, + &mut token, + ) + } == 0 || token.is_null() { return Err(format!( @@ -1010,6 +2755,7 @@ fn secure_windows_game_creator_path_for_current_user_with_owner_policy( )); } + let mut requested_user_sid = std::ptr::null_mut(); let result = (|| { let mut required = 0_u32; // SAFETY: the null query buffer is the documented size-probe call. @@ -1048,8 +2794,34 @@ fn secure_windows_game_creator_path_for_current_user_with_owner_policy( if current_user_sid.is_null() || unsafe { IsValidSid(current_user_sid) } == 0 { return Err("Windows 当前用户 SID 无效".to_string()); } + let target_user_sid = if let Some(target_user_sid) = target_user_sid { + let target_user_sid = target_user_sid.trim(); + if target_user_sid.is_empty() { + return Err("Windows ACL 修复目标 TokenUser SID 不能为空".to_string()); + } + let mut wide_target_user_sid = target_user_sid + .encode_utf16() + .chain(std::iter::once(0)) + .collect::>(); + if unsafe { + ConvertStringSidToSidW(wide_target_user_sid.as_mut_ptr(), &mut requested_user_sid) + } == 0 + || requested_user_sid.is_null() + || unsafe { IsValidSid(requested_user_sid) } == 0 + { + return Err("Windows ACL 修复目标 TokenUser SID 无效".to_string()); + } + requested_user_sid + } else { + current_user_sid + }; - let mut wide_path = path + // Security APIs otherwise reject a perfectly valid private sidecar at + // the MAX_PATH boundary with ERROR_INVALID_NAME. Use the extended + // length spelling only when needed; short paths retain the ordinary + // Win32 form for compatibility with older Windows builds. + let security_path = windows_security_object_path(path); + let mut wide_path = security_path .as_os_str() .encode_wide() .chain(std::iter::once(0)) @@ -1079,7 +2851,7 @@ fn secure_windows_game_creator_path_for_current_user_with_owner_policy( )); } let owner_matches = unsafe { IsValidSid(initial_owner) } != 0 - && unsafe { EqualSid(initial_owner, current_user_sid) } != 0; + && unsafe { EqualSid(initial_owner, target_user_sid) } != 0; unsafe { LocalFree(initial_descriptor) }; if !owner_matches { if !(initialize_owner && tighten) { @@ -1088,6 +2860,58 @@ fn secure_windows_game_creator_path_for_current_user_with_owner_policy( path.display() )); } + + // Assigning ownership to the current token user requires the + // take-ownership/restore privileges on an elevated process. + // Enabling them is attempted only for this allow-listed repair; + // a normal process will fail and the caller will request UAC + // elevation instead of weakening the verifier. + for privilege_name in ["SeTakeOwnershipPrivilege", "SeRestorePrivilege"] { + let mut privilege_name_wide = privilege_name + .encode_utf16() + .chain(std::iter::once(0)) + .collect::>(); + let mut luid = Luid { + low_part: 0, + high_part: 0, + }; + if unsafe { + LookupPrivilegeValueW( + std::ptr::null(), + privilege_name_wide.as_mut_ptr(), + &mut luid, + ) + } == 0 + { + return Err(format!( + "启用 Windows {privilege_name} 失败:{}", + std::io::Error::last_os_error() + )); + } + let mut privileges = TokenPrivileges { + privilege_count: 1, + privileges: [LuidAndAttributes { + luid, + attributes: SE_PRIVILEGE_ENABLED, + }], + }; + let adjust_status = unsafe { + AdjustTokenPrivileges( + token, + 0, + &mut privileges, + std::mem::size_of::() as u32, + std::ptr::null_mut(), + std::ptr::null_mut(), + ) + }; + let adjust_error = unsafe { GetLastError() }; + if adjust_status == 0 || adjust_error == ERROR_NOT_ALL_ASSIGNED { + return Err(format!( + "启用 Windows {privilege_name} 失败:error {adjust_error}" + )); + } + } } if tighten { let mut entry = ExplicitAccessW { @@ -1103,7 +2927,7 @@ fn secure_windows_game_creator_path_for_current_user_with_owner_policy( multiple_trustee_operation: 0, trustee_form: TRUSTEE_IS_SID, trustee_type: TRUSTEE_IS_USER, - name: current_user_sid.cast(), + name: target_user_sid.cast(), }, }; let mut private_dacl = std::ptr::null_mut(); @@ -1124,7 +2948,7 @@ fn secure_windows_game_creator_path_for_current_user_with_owner_policy( SE_FILE_OBJECT, windows_private_dacl_security_information(initialize_owner, owner_matches), if should_initialize_owner { - current_user_sid + target_user_sid } else { std::ptr::null_mut() }, @@ -1171,8 +2995,7 @@ fn secure_windows_game_creator_path_for_current_user_with_owner_policy( } let validation = (|| { - if unsafe { IsValidSid(owner) } == 0 - || unsafe { EqualSid(owner, current_user_sid) } == 0 + if unsafe { IsValidSid(owner) } == 0 || unsafe { EqualSid(owner, target_user_sid) } == 0 { return Err(format!( "Windows 安全对象不属于当前用户:{}", @@ -1229,18 +3052,23 @@ fn secure_windows_game_creator_path_for_current_user_with_owner_policy( } else { 0 }; - if allowed.header.ace_flags & (OBJECT_INHERIT_ACE | CONTAINER_INHERIT_ACE) - != required_inheritance - { + if allowed.header.ace_flags != required_inheritance { return Err(format!("Windows DACL 继承边界无效:{}", path.display())); } let ace_sid = std::ptr::addr_of!(allowed.sid_start) .cast_mut() .cast::(); - if unsafe { IsValidSid(ace_sid) } == 0 - || unsafe { EqualSid(ace_sid, current_user_sid) } == 0 + let sid_length = unsafe { GetLengthSid(ace_sid) } as usize; + let sid_offset = std::mem::size_of::() + std::mem::size_of::(); + if sid_length == 0 + || sid_offset.saturating_add(sid_length) > usize::from(header.ace_size) + || unsafe { IsValidSid(ace_sid) } == 0 + || unsafe { EqualSid(ace_sid, target_user_sid) } == 0 { - return Err(format!("Windows DACL 含非当前用户 ACE:{}", path.display())); + return Err(format!( + "Windows DACL 当前用户 ACE 身份无效:{}", + path.display() + )); } Ok(()) })(); @@ -1250,6 +3078,10 @@ fn secure_windows_game_creator_path_for_current_user_with_owner_policy( })(); // SAFETY: token was opened successfully above. + if !requested_user_sid.is_null() { + // SAFETY: ConvertStringSidToSidW allocates this SID with LocalAlloc. + unsafe { LocalFree(requested_user_sid.cast()) }; + } unsafe { CloseHandle(token) }; result } @@ -1263,16 +3095,96 @@ pub(crate) fn configure_game_creator_runtime_config_dir( let config_dir = prepare_game_creator_runtime_config_dir(&requested_config_dir) .map_err(std::io::Error::other)?; let config_path = config_dir.join(GAME_CREATOR_CONFIG_FILE_NAME); - if !config_path.exists() { + let config_exists = + validate_game_creator_config_file_entry(&config_path).map_err(std::io::Error::other)?; + if !config_exists { write_game_creator_config_atomically(&config_path, DEFAULT_GAME_CREATOR_APP_CONFIG_JSON) .map_err(std::io::Error::other)?; - } else { - migrate_legacy_game_creator_agent_mode(&config_path).map_err(std::io::Error::other)?; + } + // Both the normal config and the optional local override are persisted + // inputs. A release build must scrub legacy provider credentials from + // either file before the next read can observe them again. + for path in [ + config_path, + config_dir.join(GAME_CREATOR_LOCAL_CONFIG_FILE_NAME), + ] { + migrate_legacy_game_creator_agent_mode(&path).map_err(std::io::Error::other)?; + migrate_locked_game_creator_llm_config(&path).map_err(std::io::Error::other)?; } set_game_creator_runtime_config_dir(config_dir); Ok(()) } +/// Validates a persisted AGC config file without following links. Existing +/// regular files are tightened through the same Windows owner/DACL gate used +/// by credential files before any read is allowed. +fn validate_game_creator_config_file_entry(path: &Path) -> Result { + #[cfg(windows)] + validate_game_creator_private_path_ancestors_with_auto_elevation(path, "客户端配置文件")?; + #[cfg(not(windows))] + validate_game_creator_private_path_ancestors(path, "客户端配置文件")?; + let metadata = match fs::symlink_metadata(path) { + Ok(metadata) => metadata, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(false), + Err(error) => { + return Err(format!( + "读取客户端配置文件元数据失败:{}: {error}", + path.display() + )); + } + }; + if metadata.file_type().is_symlink() || !metadata.is_file() { + return Err("客户端配置文件必须是普通文件,不能是链接或其他对象".to_string()); + } + #[cfg(windows)] + secure_windows_game_creator_path_for_current_user_with_auto_elevation(path, false, true)?; + Ok(true) +} + +fn migrate_locked_game_creator_llm_config(path: &Path) -> Result<(), String> { + if !game_creator_official_llm_route_locked() || !validate_game_creator_config_file_entry(path)? + { + return Ok(()); + } + let content = read_game_creator_private_file_to_string(path, "客户端配置", 256 * 1024)?; + let mut config = serde_json::from_str::(&content) + .map_err(|error| format!("解析客户端配置失败:{}: {error}", path.display()))?; + let changed = scrub_locked_game_creator_config_file(&mut config); + if changed { + let content = serde_json::to_string_pretty(&config) + .map_err(|error| format!("序列化客户端配置失败:{error}"))?; + write_game_creator_config_atomically(path, &format!("{content}\n"))?; + } + Ok(()) +} + +/// Applies the release migration to a parsed config file. This helper is +/// deliberately independent of build-mode detection so regression tests can +/// prove that every sensitive legacy field is removed without enabling a +/// production-only route lock in the test binary. +pub(crate) fn scrub_locked_game_creator_config_file(config: &mut GameCreatorAppConfigFile) -> bool { + let mut changed = config.agent_mode.as_deref() + != Some(GAME_CREATOR_AGENT_MODE_CODEX_APP_SERVER) + || config.agent_llm.is_some(); + config.agent_mode = Some(GAME_CREATOR_AGENT_MODE_CODEX_APP_SERVER.to_string()); + config.agent_llm = None; + if let Some(llm) = config.llm.as_mut() { + changed |= llm.api_key.is_some() + || llm.base_url.is_some() + || llm.model.is_some() + || llm.api_kind.is_some(); + llm.api_key = None; + llm.base_url = None; + llm.model = None; + llm.api_kind = None; + } + if config.editor_api.is_some() { + changed = true; + config.editor_api = None; + } + changed +} + pub(crate) fn legacy_game_creator_agent_mode( config: &GameCreatorAppConfigFile, ) -> Option<&'static str> { @@ -1300,8 +3212,10 @@ pub(crate) fn legacy_game_creator_agent_mode( } fn migrate_legacy_game_creator_agent_mode(path: &Path) -> Result<(), String> { - let content = fs::read_to_string(path) - .map_err(|error| format!("读取客户端配置失败:{}: {error}", path.display()))?; + if !validate_game_creator_config_file_entry(path)? { + return Ok(()); + } + let content = read_game_creator_private_file_to_string(path, "客户端配置", 256 * 1024)?; let mut config = serde_json::from_str::(&content) .map_err(|error| format!("解析客户端配置失败:{}: {error}", path.display()))?; let Some(agent_mode) = legacy_game_creator_agent_mode(&config) else { @@ -1335,9 +3249,29 @@ pub(crate) fn load_game_creator_app_config() -> Result bool { + !cfg!(debug_assertions) && !cfg!(test) && editor_api_mode() == EditorApiMode::PlatformAccount +} + +pub(crate) fn lock_game_creator_app_config_to_official_route(config: &mut GameCreatorAppConfig) { + if !game_creator_official_llm_route_locked() { + return; + } + config.agent_mode = GAME_CREATOR_AGENT_MODE_CODEX_APP_SERVER.to_string(); + config.llm.api_key.clear(); + config.llm.base_url = OFFICIAL_AGC_LLM_BASE_URL.to_string(); + config.llm.model = OFFICIAL_AGC_LLM_MODEL.to_string(); + config.llm.api_kind = DEFAULT_GAME_CREATOR_LLM_API_KIND.to_string(); + config.agent_llm.clear(); + config.editor_api.api_key.clear(); +} + pub(crate) fn game_creator_app_config_view( mut config: GameCreatorAppConfig, ) -> Result { @@ -1353,8 +3287,15 @@ pub(crate) fn game_creator_app_config_view( pub(crate) fn serialize_game_creator_app_config_for_renderer_write( config: &GameCreatorAppConfig, ) -> Result { - let mut value = - serde_json::to_value(config).map_err(|error| format!("序列化客户端配置失败:{error}"))?; + // Keep the serialization boundary defensive. Even if a future caller + // bypasses `write_game_creator_app_config`'s normalizer, a PlatformAccount + // release can never persist or echo legacy provider credentials. + let mut safe_config = config.clone(); + if game_creator_official_llm_route_locked() { + lock_game_creator_app_config_to_official_route(&mut safe_config); + } + let mut value = serde_json::to_value(&safe_config) + .map_err(|error| format!("序列化客户端配置失败:{error}"))?; if editor_api_mode() == EditorApiMode::PlatformAccount { value .as_object_mut() @@ -1435,15 +3376,15 @@ pub(crate) fn merge_game_creator_config_file( path: &Path, ) -> Result<(), String> { let backup_path = game_creator_config_backup_path(path); - let read_path = if path.is_file() { + let path_exists = validate_game_creator_config_file_entry(path)?; + let read_path = if path_exists { path - } else if backup_path.is_file() { + } else if validate_game_creator_config_file_entry(&backup_path)? { backup_path.as_path() } else { return Ok(()); }; - let content = fs::read_to_string(read_path) - .map_err(|error| format!("读取客户端配置失败:{}: {error}", read_path.display()))?; + let content = read_game_creator_private_file_to_string(read_path, "客户端配置", 256 * 1024)?; let file_config = serde_json::from_str::(&content) .map_err(|error| format!("解析客户端配置失败:{}: {error}", read_path.display()))?; if let Some(agent_mode) = file_config.agent_mode { @@ -1486,11 +3427,19 @@ pub(crate) fn write_game_creator_config_atomically( path: &Path, content: &str, ) -> Result<(), String> { + #[cfg(windows)] + validate_game_creator_private_path_ancestors_with_auto_elevation(path, "客户端配置文件")?; + #[cfg(not(windows))] + validate_game_creator_private_path_ancestors(path, "客户端配置文件")?; let parent = path .parent() .ok_or_else(|| "客户端配置缺少父目录".to_string())?; - fs::create_dir_all(parent) + ensure_game_creator_private_directory_tree(parent, "客户端配置目录") .map_err(|error| format!("创建客户端配置目录失败:{}: {error}", parent.display()))?; + // Never replace a symlink or another non-regular object. A regular + // existing config is repaired/tightened before it can be moved to the + // recoverable backup below. + let _initial_path_exists = validate_game_creator_config_file_entry(path)?; let temp_path = path.with_file_name(format!( ".{}.tmp.{}.{}", path.file_name() @@ -1509,12 +3458,24 @@ pub(crate) fn write_game_creator_config_atomically( use std::os::unix::fs::OpenOptionsExt; options.mode(0o600); } + #[cfg(windows)] + { + use std::os::windows::fs::OpenOptionsExt; + options.custom_flags(PROJECT_FILE_FLAG_OPEN_REPARSE_POINT); + } let mut file = options.open(&temp_path).map_err(|error| { format!( "创建客户端配置临时文件失败:{}: {error}", temp_path.display() ) })?; + if let Err(error) = + harden_new_game_creator_private_path(&temp_path, false, "客户端配置临时文件") + { + drop(file); + let _ = fs::remove_file(&temp_path); + return Err(error); + } let write_result = file .write_all(content.as_bytes()) .and_then(|_| file.sync_all()); @@ -1527,44 +3488,55 @@ pub(crate) fn write_game_creator_config_atomically( )); } + let backup_path = game_creator_config_backup_path(path); + // Re-check the destination immediately before changing either directory + // entry. A newly appeared regular target is moved aside; a link, + // reparse point, foreign object, or unsafe ACL is rejected by the same + // private-path policy used for reads. + let had_previous = validate_game_creator_config_file_entry(path)?; + if validate_game_creator_config_file_entry(&backup_path)? { + fs::remove_file(&backup_path).map_err(|error| { + let _ = fs::remove_file(&temp_path); + format!( + "清理旧客户端配置备份失败:{}: {error}", + backup_path.display() + ) + })?; + } + if had_previous { + if let Err(error) = fs::rename(path, &backup_path) { + let _ = fs::remove_file(&temp_path); + return Err(format!( + "准备替换客户端配置失败:{} -> {}: {error}", + path.display(), + backup_path.display() + )); + } + } match fs::rename(&temp_path, path) { Ok(()) => { - let _ = fs::remove_file(game_creator_config_backup_path(path)); + let _ = fs::remove_file(&backup_path); + #[cfg(windows)] + secure_windows_game_creator_path_for_current_user_with_auto_elevation( + path, false, true, + )?; Ok(()) } - Err(replace_error) => { - let backup_path = game_creator_config_backup_path(path); - if path.exists() { - let _ = fs::remove_file(&backup_path); - fs::rename(path, &backup_path).map_err(|error| { - let _ = fs::remove_file(&temp_path); - format!( - "准备替换客户端配置失败:{} -> {}: {error}", - path.display(), - backup_path.display() - ) - })?; - } - match fs::rename(&temp_path, path) { - Ok(()) => { - let _ = fs::remove_file(&backup_path); - Ok(()) - } - Err(error) => { - let restore_error = if backup_path.exists() { - fs::rename(&backup_path, path).err() - } else { - None - }; - let _ = fs::remove_file(&temp_path); - let restore_detail = restore_error - .map(|error| format!(";恢复旧配置失败:{error}")) - .unwrap_or_default(); - Err(format!( - "替换客户端配置失败:{replace_error};重试失败:{error}{restore_detail}" - )) - } - } + Err(error) => { + let restore_error = if had_previous { + fs::rename(&backup_path, path).err() + } else { + None + }; + let _ = fs::remove_file(&temp_path); + let restore_detail = restore_error + .map(|error| format!(";恢复旧配置失败:{error}")) + .unwrap_or_default(); + Err(format!( + "替换客户端配置失败:{} -> {}: {error}{restore_detail}", + temp_path.display(), + path.display() + )) } } } @@ -1705,6 +3677,9 @@ pub(crate) fn trim_config_string(value: &str) -> Option { pub(crate) fn normalize_game_creator_app_config( mut config: GameCreatorAppConfig, ) -> Result { + if game_creator_official_llm_route_locked() { + lock_game_creator_app_config_to_official_route(&mut config); + } config.agent_mode = normalize_game_creator_agent_mode(&config.agent_mode)?; config.llm.api_key = config.llm.api_key.trim().to_string(); config.llm.base_url = @@ -1894,3 +3869,225 @@ mod anthropic_strict_capability_tests { } } } + +#[cfg(test)] +mod private_file_write_tests { + use super::*; + + #[test] + fn private_file_write_installs_via_hardened_sibling_and_replaces_regular_target() { + let root = tempfile::tempdir().expect("create private file fixture"); + let parent = root.path().join("private"); + let path = parent.join("state.json"); + + write_game_creator_private_file(&path, b"first\n", "测试私有文件") + .expect("install first private file"); + assert_eq!( + fs::read(&path).expect("read first private file"), + b"first\n" + ); + + write_game_creator_private_file(&path, b"second\n", "测试私有文件") + .expect("replace private file"); + assert_eq!( + fs::read(&path).expect("read replaced private file"), + b"second\n" + ); + + let leftovers = fs::read_dir(&parent) + .expect("read private file directory") + .filter_map(Result::ok) + .map(|entry| entry.file_name().to_string_lossy().into_owned()) + .filter(|name| name.contains(".tmp-") || name.contains(".previous-")) + .collect::>(); + assert!( + leftovers.is_empty(), + "temporary/backup residue: {leftovers:?}" + ); + + #[cfg(unix)] + { + use std::os::unix::fs::{MetadataExt, PermissionsExt}; + let metadata = fs::symlink_metadata(&path).expect("metadata"); + assert_eq!(metadata.mode() & 0o777, 0o600); + } + } + + #[test] + fn private_file_append_creates_and_reuses_hardened_target() { + let root = tempfile::tempdir().expect("create append fixture"); + let path = root.path().join("private").join("journal.log"); + + append_game_creator_private_file(&path, b"first\n", "测试追加文件") + .expect("append first record"); + append_game_creator_private_file(&path, b"second\n", "测试追加文件") + .expect("append second record"); + assert_eq!( + fs::read(&path).expect("read append file"), + b"first\nsecond\n" + ); + + #[cfg(unix)] + { + use std::os::unix::fs::{MetadataExt, PermissionsExt}; + let metadata = fs::symlink_metadata(&path).expect("append metadata"); + assert_eq!(metadata.nlink(), 1); + assert_eq!(metadata.mode() & 0o777, 0o600); + } + } +} + +#[cfg(test)] +mod private_path_elevation_policy_tests { + use super::*; + + #[test] + fn automatic_elevation_covers_all_regular_project_descendants() { + let root = tempfile::tempdir().expect("create path policy fixture"); + let external_file = root.path().join("game").join("index.html"); + let nested_project_file = root.path().join("assets").join("sprites").join("hero.png"); + let managed_agent_file = root + .path() + .join(".agent") + .join("runtime") + .join("state.json"); + fs::create_dir_all(managed_agent_file.parent().expect("agent parent")) + .expect("create managed agent fixture"); + fs::write(root.path().join(".agent/manifest.json"), b"{}").expect("create marker"); + let traversal_path = root + .path() + .join(".agent") + .join("..") + .join("Windows") + .join("System32"); + + assert!(game_creator_private_path_allows_auto_elevation( + &external_file + )); + assert!(game_creator_private_path_allows_auto_elevation( + &nested_project_file + )); + assert!(game_creator_private_path_allows_auto_elevation( + &managed_agent_file + )); + assert!(!game_creator_private_path_allows_auto_elevation( + &traversal_path + )); + } + + #[test] + fn project_marker_does_not_authorize_unrelated_or_nested_agent_paths() { + let root = tempfile::tempdir().expect("create path policy fixture"); + fs::create_dir_all(root.path().join(".agent")).expect("create agent directory"); + fs::write(root.path().join(".agent/manifest.json"), b"{}").expect("create marker"); + + let unrelated = tempfile::tempdir().expect("create unrelated fixture"); + let unrelated_file = unrelated.path().join("game/index.html"); + let nested_agent = root.path().join("game").join(".agent").join("state.json"); + + assert!(!game_creator_private_path_allows_auto_elevation( + &unrelated_file + )); + assert!(!game_creator_private_path_allows_auto_elevation( + &nested_agent + )); + } + + #[cfg(windows)] + #[test] + fn explicit_user_selection_allows_repair_on_user_selected_non_system_path() { + let profile = std::env::var_os("USERPROFILE") + .or_else(|| std::env::var_os("HOME")) + .map(PathBuf::from) + .expect("current user profile"); + let windir = std::env::var_os("WINDIR") + .map(PathBuf::from) + .expect("WINDIR"); + let program_files = std::env::var_os("ProgramFiles") + .map(PathBuf::from) + .expect("ProgramFiles"); + let system_drive = std::env::var_os("SystemDrive") + .map(PathBuf::from) + .expect("SystemDrive"); + let selected = profile.join("Documents").join("fixture.png"); + let external_drive = PathBuf::from(r"D:\Genarrative\fixture.png"); + let system_path = windir.join("System32").join("fixture.png"); + let program_files_path = program_files.join("Genarrative").join("fixture.png"); + let drive_root = PathBuf::from(format!( + "{}\\", + system_drive.to_string_lossy().trim_end_matches(['\\', '/']) + )); + let unc_root = PathBuf::from(r"\\server\share\"); + let traversal_path = PathBuf::from(r"C:\Users\test\..\Windows\fixture.png"); + + assert!(game_creator_user_selected_path_allows_auto_elevation( + &selected + )); + assert!(game_creator_user_selected_path_allows_auto_elevation( + &external_drive + )); + assert!(!game_creator_user_selected_path_allows_auto_elevation( + &system_path + )); + assert!(!game_creator_user_selected_path_allows_auto_elevation( + &program_files_path + )); + assert!(!game_creator_user_selected_path_allows_auto_elevation( + &drive_root + )); + assert!(!game_creator_user_selected_path_allows_auto_elevation( + &unc_root + )); + assert!(!game_creator_user_selected_path_allows_auto_elevation( + &traversal_path + )); + } + + #[test] + fn arbitrary_agent_directory_without_project_marker_cannot_trigger_elevation() { + let root = tempfile::tempdir().expect("create unverified agent fixture"); + let path = root + .path() + .join(".agent") + .join("runtime") + .join("state.json"); + fs::create_dir_all(path.parent().expect("agent parent")).expect("create agent fixture"); + assert!(!game_creator_private_path_allows_auto_elevation(&path)); + } + + #[test] + fn nested_agent_components_cannot_trigger_elevation() { + let root = tempfile::tempdir().expect("create nested agent fixture"); + let path = root + .path() + .join(".agent") + .join("nested") + .join(".agent") + .join("state.json"); + fs::create_dir_all(path.parent().expect("nested agent parent")) + .expect("create nested agent"); + fs::write(root.path().join(".agent/manifest.json"), b"{}").expect("create marker"); + assert!(!game_creator_private_path_allows_auto_elevation(&path)); + } + + #[cfg(windows)] + #[test] + fn explicit_project_root_entry_can_tighten_owner_correct_inherited_acl() { + let root = tempfile::tempdir().expect("create project root fixture"); + assert!(prepare_game_creator_project_root_for_read(root.path(), true, "项目根").is_ok()); + } + + #[cfg(windows)] + #[test] + fn owner_flag_is_requested_only_when_owner_initialization_is_needed() { + const OWNER_SECURITY_INFORMATION: u32 = 0x0000_0001; + assert_eq!( + windows_private_dacl_security_information(true, false) & OWNER_SECURITY_INFORMATION, + OWNER_SECURITY_INFORMATION + ); + assert_eq!( + windows_private_dacl_security_information(true, true) & OWNER_SECURITY_INFORMATION, + 0 + ); + } +} diff --git a/apps/ai-game-creator-shell/src-tauri/src/debug/debug_drafts.rs b/apps/ai-game-creator-shell/src-tauri/src/debug/debug_drafts.rs index 65a9b9c69..7f2a0d927 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/debug/debug_drafts.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/debug/debug_drafts.rs @@ -5,7 +5,6 @@ //! 不会往仓库写任何文件。 use crate::unix_millis; -use std::fs; use std::path::PathBuf; // 找到仓库根(包含 apps/ai-game-creator-shell/src-tauri/Cargo.toml 的目录), @@ -46,12 +45,15 @@ pub(crate) fn persist_snapshot(raw_content: &str) { .join("apps") .join("ai-game-creator-shell") .join(".llm-drafts"); - if let Err(error) = fs::create_dir_all(&dir) { + if let Err(error) = crate::ensure_game_creator_private_directory_tree(&dir, "LLM 调试快照目录") + { eprintln!("llm.draft.snapshot.dir.failed: {}: {error}", dir.display()); return; } let path = dir.join(format!("draft-{}.txt", unix_millis())); - if let Err(error) = fs::write(&path, raw_content) { + if let Err(error) = + crate::write_game_creator_private_file(&path, raw_content.as_bytes(), "LLM 调试快照") + { eprintln!( "llm.draft.snapshot.write.failed: {}: {error}", path.display() @@ -60,7 +62,8 @@ pub(crate) fn persist_snapshot(raw_content: &str) { } // 同步更新 latest.txt,方便直接打开最近一次草案。 let latest = dir.join("latest.txt"); - let _ = fs::write(&latest, raw_content); + let _ = + crate::write_game_creator_private_file(&latest, raw_content.as_bytes(), "LLM 最新调试快照"); eprintln!("llm.draft.snapshot.saved: {}", path.display()); } @@ -75,7 +78,9 @@ pub(crate) fn persist_error_input(system_prompt: &str, user_prompt: &str, error: .join("apps") .join("ai-game-creator-shell") .join(".llm-drafts"); - if let Err(io_error) = fs::create_dir_all(&dir) { + if let Err(io_error) = + crate::ensure_game_creator_private_directory_tree(&dir, "LLM 错误输入快照目录") + { eprintln!( "llm.draft.error-input.dir.failed: {}: {io_error}", dir.display() @@ -86,7 +91,9 @@ pub(crate) fn persist_error_input(system_prompt: &str, user_prompt: &str, error: "# LLM 生成失败输入快照\n\n## 错误\n{error}\n\n## System Prompt\n{system_prompt}\n\n## User Prompt(含用户需求/短长期记忆/spec/agenda/组 brief/findings)\n{user_prompt}\n", ); let path = dir.join(format!("error-input-{}.txt", unix_millis())); - if let Err(io_error) = fs::write(&path, &body) { + if let Err(io_error) = + crate::write_game_creator_private_file(&path, body.as_bytes(), "LLM 错误输入快照") + { eprintln!( "llm.draft.error-input.write.failed: {}: {io_error}", path.display() @@ -95,6 +102,7 @@ pub(crate) fn persist_error_input(system_prompt: &str, user_prompt: &str, error: } // 同步更新 latest-error-input.txt,方便直接打开最近一次失败输入。 let latest = dir.join("latest-error-input.txt"); - let _ = fs::write(&latest, &body); + let _ = + crate::write_game_creator_private_file(&latest, body.as_bytes(), "LLM 最新错误输入快照"); eprintln!("llm.draft.error-input.saved: {}", path.display()); } diff --git a/apps/ai-game-creator-shell/src-tauri/src/image_inspect.rs b/apps/ai-game-creator-shell/src-tauri/src/image_inspect.rs index 1d23c81c0..149f6221e 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/image_inspect.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/image_inspect.rs @@ -1,6 +1,6 @@ use crate::project::{ - normalize_relative_path, open_project_snapshot_regular_file, - reject_sensitive_project_file_read, resolve_local_project_path, + normalize_relative_path, open_project_private_regular_file, reject_sensitive_project_file_read, + resolve_local_project_path, }; use crate::resource_preview_scheduler::ProjectResourcePreviewScopeCancellation; use base64::Engine as _; @@ -282,7 +282,7 @@ fn read_agent_runtime_inspection_image_with_cancellation( cancellation: &ProjectResourcePreviewScopeCancellation, ) -> Result { cancellation.check()?; - let (mut file, initial_metadata) = open_project_snapshot_regular_file(path, "视觉检查图片")?; + let (mut file, initial_metadata) = open_project_private_regular_file(path, "视觉检查图片")?; cancellation.check()?; if initial_metadata.len() == 0 { return Err(format!("image.inspect 图片不能为空:{relative_path}")); @@ -334,7 +334,7 @@ fn read_agent_runtime_inspection_image_with_cancellation( } cancellation.check()?; - let (reopened, reopened_metadata) = open_project_snapshot_regular_file(path, "视觉检查图片")?; + let (reopened, reopened_metadata) = open_project_private_regular_file(path, "视觉检查图片")?; if !same_open_file_identity(&file, &initial_metadata, &reopened, &reopened_metadata)? { return Err(format!( "image.inspect 图片路径读取期间发生替换:{relative_path}" diff --git a/apps/ai-game-creator-shell/src-tauri/src/main.rs b/apps/ai-game-creator-shell/src-tauri/src/main.rs index ad1499c0b..018e3241a 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/main.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/main.rs @@ -764,7 +764,8 @@ struct GameCreatorDirectTurnUpdateEvent { struct GameCreatorLlmConfigStatus { agent_mode: String, configured: bool, - api_key_present: bool, + account_credential_state: String, + official_route_locked: bool, base_url: Option, model: Option, api_kind: String, @@ -788,7 +789,8 @@ struct GameCreatorAgentLlmConfigStatus { agent_id: String, label: String, configured: bool, - api_key_present: bool, + account_credential_state: String, + official_route_locked: bool, base_url: Option, model: Option, api_kind: String, @@ -1580,7 +1582,10 @@ fn append_bounded_diagnostic_line_with_limit( .lock() .unwrap_or_else(|poisoned| poisoned.into_inner()); if let Some(parent) = path.parent() { - fs::create_dir_all(parent)?; + ensure_game_creator_private_directory_tree(parent, "诊断日志目录") + .map_err(std::io::Error::other)?; + prepare_game_creator_private_path_for_read(parent, true, "诊断日志目录") + .map_err(std::io::Error::other)?; } let mut file = open_secure_diagnostic_log(path)?; if file.metadata()?.len() >= max_bytes { @@ -1627,6 +1632,8 @@ fn open_secure_diagnostic_log(path: &Path) -> std::io::Result { Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} Err(error) => return Err(error), } + let existed = prepare_game_creator_private_path_for_read(path, false, "诊断日志") + .map_err(std::io::Error::other)?; let mut options = OpenOptions::new(); options.read(true).write(true).create(true); #[cfg(unix)] @@ -1641,6 +1648,10 @@ fn open_secure_diagnostic_log(path: &Path) -> std::io::Result { options.custom_flags(FILE_FLAG_OPEN_REPARSE_POINT); } let file = options.open(path)?; + if !existed { + harden_new_game_creator_private_path(path, false, "诊断日志") + .map_err(std::io::Error::other)?; + } let metadata = file.metadata()?; if !metadata.is_file() { return Err(std::io::Error::new( @@ -1661,6 +1672,8 @@ fn open_secure_diagnostic_log(path: &Path) -> std::io::Result { #[cfg(windows)] crate::runner::validate_windows_regular_file_handle(&file, "diagnostic log") .map_err(std::io::Error::other)?; + prepare_game_creator_private_path_for_read(path, false, "诊断日志") + .map_err(std::io::Error::other)?; Ok(file) } diff --git a/apps/ai-game-creator-shell/src-tauri/src/platform_session.rs b/apps/ai-game-creator-shell/src-tauri/src/platform_session.rs index daf5cd59f..0e24b3b53 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/platform_session.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/platform_session.rs @@ -1,5 +1,5 @@ -use sha2::{Digest, Sha256}; use serde::Deserialize; +use sha2::{Digest, Sha256}; use std::fs::{self, OpenOptions}; use std::io::Read; use std::path::{Path, PathBuf}; @@ -8,10 +8,8 @@ use std::sync::{Mutex, OnceLock}; /// Debug-only fixture hook used by the deterministic AGC E2E. The hook takes /// a path, rather than credentials on argv, so a child Runner can inherit the /// test identity without putting the bearer token in process listings. -pub(crate) const PLATFORM_SESSION_FIXTURE_ENV: &str = - "GENARRATIVE_AGC_PLATFORM_SESSION_FIXTURE"; -const PLATFORM_SESSION_FIXTURE_SCHEMA_VERSION: &str = - "genarrative-agc-platform-session-fixture.v1"; +pub(crate) const PLATFORM_SESSION_FIXTURE_ENV: &str = "GENARRATIVE_AGC_PLATFORM_SESSION_FIXTURE"; +const PLATFORM_SESSION_FIXTURE_SCHEMA_VERSION: &str = "genarrative-agc-platform-session-fixture.v1"; const PLATFORM_SESSION_FIXTURE_MAX_BYTES: u64 = 16 * 1024; #[derive(Clone, Debug, Eq, PartialEq)] @@ -144,8 +142,8 @@ fn validate_fixture_path(config_dir: &Path, fixture_path: &Path) -> Result Result, String> { - let before = fs::symlink_metadata(path) - .map_err(|_| "平台登录态 fixture 文件不可读取".to_string())?; + let before = + fs::symlink_metadata(path).map_err(|_| "平台登录态 fixture 文件不可读取".to_string())?; if metadata_is_link_or_reparse(&before) || !before.is_file() { return Err("平台登录态 fixture 必须是普通文件".to_string()); } @@ -169,7 +167,8 @@ fn read_fixture_file(path: &Path) -> Result, String> { if metadata_is_link_or_reparse(&opened) || !opened.is_file() || opened.len() > before.len() { return Err("平台登录态 fixture 文件身份校验失败".to_string()); } - let mut bytes = Vec::with_capacity(opened.len().min(PLATFORM_SESSION_FIXTURE_MAX_BYTES) as usize); + let mut bytes = + Vec::with_capacity(opened.len().min(PLATFORM_SESSION_FIXTURE_MAX_BYTES) as usize); file.take(PLATFORM_SESSION_FIXTURE_MAX_BYTES + 1) .read_to_end(&mut bytes) .map_err(|_| "读取平台登录态 fixture 失败".to_string())?; @@ -188,10 +187,18 @@ fn parse_platform_session_fixture(bytes: &[u8]) -> Result Result Result Result, String> { if create { - fs::create_dir_all(root) - .map_err(|error| format!("创建 Agent DB 项目目录失败:{}: {error}", root.display()))?; + ensure_game_creator_private_directory_tree(root, "Agent DB 项目目录")?; + } + if !prepare_game_creator_private_path_for_read(root, true, "Agent DB 项目目录")? { + return Ok(None); + } + let agent_path = root.join(".agent"); + if create { + ensure_game_creator_private_directory_tree(&agent_path, "项目 .agent 目录")?; + } + if !prepare_game_creator_private_path_for_read(&agent_path, true, "项目 .agent 目录")? { + return Ok(None); } let root_directory = match open_windows_agent_db_root(root, create) { Ok(file) => file, @@ -875,6 +884,21 @@ fn open_agent_db_storage( create: bool, ) -> Result, String> { verify_agent_db_directory_current(&directory)?; + let path = directory.path.join("agent.db"); + let existed = match fs::symlink_metadata(&path) { + Ok(_) => true, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => false, + Err(_) => { + // Do not let an ACL-denied existing Agent DB fall through to + // NtCreateFile, which would otherwise be reported as a generic + // open failure without trying the approved repair path. + prepare_game_creator_private_path_for_read(&path, false, "Agent 本地索引")?; + true + } + }; + if existed { + prepare_game_creator_private_path_for_read(&path, false, "Agent 本地索引")?; + } let file = { let mut opened = None; for attempt in 0..100 { @@ -924,10 +948,16 @@ fn open_agent_db_storage( return Err("Agent 本地索引必须是普通文件".to_string()); } validate_windows_regular_file_handle(&file, "Agent 本地索引")?; + if existed { + secure_windows_game_creator_path_for_current_user_with_auto_elevation(&path, false, true)?; + } else { + initialize_windows_game_creator_file_owner_for_current_user(&path)?; + secure_windows_game_creator_path_for_current_user_with_auto_elevation(&path, false, false)?; + } verify_agent_db_directory_current(&directory)?; Ok(Some(AgentDbStorage { file, - path: directory.path.join("agent.db"), + path, root_path: directory.root_path, root_directory: directory.root_directory, agent_directory: directory.agent_directory, @@ -4369,13 +4399,10 @@ fn project_append_os_lock_path(path: &Path) -> Result { fn acquire_project_append_os_lock(path: &Path, error_label: &str) -> Result { if let Some(parent) = path.parent() { - fs::create_dir_all(parent).map_err(|error| { - format!( - "创建{error_label}跨进程锁目录失败:{}: {error}", - parent.display() - ) - })?; + ensure_game_creator_private_directory_tree(parent, error_label)?; + prepare_game_creator_private_path_for_read(parent, true, error_label)?; } + prepare_game_creator_private_path_for_read(path, false, error_label)?; for attempt in 0..100 { if let Some(file) = try_open_project_append_os_lock(path, error_label)? { return Ok(file); @@ -4391,12 +4418,54 @@ fn acquire_project_append_os_lock(path: &Path, error_label: &str) -> Result Result, String> { use std::os::fd::AsRawFd; - let file = fs::OpenOptions::new() - .create(true) - .read(true) - .write(true) + let existed = prepare_game_creator_private_path_for_read(path, false, error_label)?; + let mut options = fs::OpenOptions::new(); + options.create(true).read(true).write(true); + use std::os::unix::fs::OpenOptionsExt; + options.custom_flags(libc::O_NOFOLLOW).mode(0o600); + let file = options .open(path) .map_err(|error| format!("打开{error_label}跨进程锁失败:{}: {error}", path.display()))?; + let metadata = file.metadata().map_err(|error| { + format!( + "读取{error_label}跨进程锁元数据失败:{}: {error}", + path.display() + ) + })?; + if !metadata.is_file() { + return Err(format!( + "{error_label}跨进程锁必须是普通文件:{}", + path.display() + )); + } + if !existed { + harden_new_game_creator_private_path(path, false, error_label)?; + } + let path_metadata = fs::symlink_metadata(path).map_err(|error| { + format!( + "复核{error_label}跨进程锁路径失败:{}: {error}", + path.display() + ) + })?; + if path_metadata.file_type().is_symlink() { + return Err(format!( + "{error_label}跨进程锁不能是符号链接:{}", + path.display() + )); + } + #[cfg(unix)] + { + use std::os::unix::fs::MetadataExt; + if path_metadata.nlink() != 1 + || path_metadata.dev() != metadata.dev() + || path_metadata.ino() != metadata.ino() + { + return Err(format!( + "{error_label}跨进程锁路径在打开期间发生替换:{}", + path.display() + )); + } + } let result = unsafe { libc::flock(file.as_raw_fd(), libc::LOCK_EX | libc::LOCK_NB) }; if result == 0 { return Ok(Some(file)); @@ -4416,14 +4485,29 @@ fn try_open_project_append_os_lock(path: &Path, error_label: &str) -> Result Result, String> { use std::os::windows::fs::OpenOptionsExt; - match fs::OpenOptions::new() - .create(true) - .read(true) - .write(true) - .share_mode(0) - .open(path) - { - Ok(file) => Ok(Some(file)), + let existed = prepare_game_creator_private_path_for_read(path, false, error_label)?; + const FILE_FLAG_OPEN_REPARSE_POINT: u32 = 0x0020_0000; + match { + let mut options = fs::OpenOptions::new(); + options + .create(true) + .read(true) + .write(true) + .share_mode(0) + .custom_flags(FILE_FLAG_OPEN_REPARSE_POINT); + options.open(path) + } { + Ok(file) => { + crate::runner::validate_windows_regular_file_handle(&file, error_label)?; + if !existed { + harden_new_game_creator_private_path(path, false, error_label)?; + } + crate::secure_windows_game_creator_path_for_current_user_with_auto_elevation( + path, false, true, + )?; + crate::runner::validate_windows_regular_file_handle(&file, error_label)?; + Ok(Some(file)) + } Err(error) if matches!( error.kind(), @@ -4453,22 +4537,72 @@ pub(super) fn append_jsonl_line_unlocked( error_label: &str, ) -> Result<(), String> { if let Some(parent) = path.parent() { - fs::create_dir_all(parent) - .map_err(|error| format!("创建{error_label}目录失败:{}: {error}", parent.display()))?; + ensure_game_creator_private_directory_tree(parent, error_label)?; + prepare_game_creator_private_path_for_read(parent, true, error_label)?; } - let mut file = fs::OpenOptions::new() - .create(true) - .read(true) - .write(true) + let existed = prepare_game_creator_private_path_for_read(path, false, error_label)?; + let mut options = fs::OpenOptions::new(); + options.create(true).read(true).write(true); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + options.custom_flags(libc::O_NOFOLLOW).mode(0o600); + } + #[cfg(windows)] + { + use std::os::windows::fs::OpenOptionsExt; + const FILE_FLAG_OPEN_REPARSE_POINT: u32 = 0x0020_0000; + const FILE_SHARE_READ: u32 = 0x0000_0001; + const FILE_SHARE_WRITE: u32 = 0x0000_0002; + const FILE_SHARE_DELETE: u32 = 0x0000_0004; + options + .custom_flags(FILE_FLAG_OPEN_REPARSE_POINT) + .share_mode(FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE); + } + let mut file = options .open(path) .map_err(|error| format!("打开{error_label}失败:{}: {error}", path.display()))?; + let opened_metadata = file.metadata().map_err(|error| { + format!( + "读取{error_label}文件句柄元数据失败:{}: {error}", + path.display() + ) + })?; + if !opened_metadata.is_file() { + return Err(format!("{error_label}必须是普通文件:{}", path.display())); + } + #[cfg(unix)] + { + use std::os::unix::fs::MetadataExt; + if opened_metadata.nlink() != 1 { + return Err(format!("{error_label}不能是硬链接:{}", path.display())); + } + let path_metadata = fs::symlink_metadata(path) + .map_err(|error| format!("复核{error_label}路径失败:{}: {error}", path.display()))?; + if path_metadata.file_type().is_symlink() + || path_metadata.dev() != opened_metadata.dev() + || path_metadata.ino() != opened_metadata.ino() + { + return Err(format!( + "{error_label}路径在安全打开期间发生替换:{}", + path.display() + )); + } + } + #[cfg(windows)] + crate::runner::validate_windows_regular_file_handle(&file, error_label)?; + if !existed { + harden_new_game_creator_private_path(path, false, error_label)?; + } repair_truncated_jsonl_tail_unlocked(&mut file, path, error_label)?; let framed = format!("{line}\n"); file.seek(SeekFrom::End(0)) .and_then(|_| file.write_all(framed.as_bytes())) .and_then(|_| file.flush()) .and_then(|_| file.sync_data()) - .map_err(|error| format!("写入{error_label}失败:{}: {error}", path.display())) + .map_err(|error| format!("写入{error_label}失败:{}: {error}", path.display()))?; + prepare_game_creator_private_path_for_read(path, false, error_label)?; + Ok(()) } const AGENT_DB_FINALIZATION_SLOT_PREPARED: u8 = 0; diff --git a/apps/ai-game-creator-shell/src-tauri/src/project/asset_canvas.rs b/apps/ai-game-creator-shell/src-tauri/src/project/asset_canvas.rs index 99c961d92..6af0fb201 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/project/asset_canvas.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/project/asset_canvas.rs @@ -772,7 +772,7 @@ fn read_asset_canvas_bytes( )) } } - let (file, metadata) = open_project_snapshot_regular_file(&path, label)?; + let (file, metadata) = open_project_private_regular_file(&path, label)?; if metadata.len() > max_bytes as u64 { return Err(format!("{label} 超过 {max_bytes} 字节上限")); } @@ -890,10 +890,12 @@ fn write_asset_canvas_raw_sidecar( } let mut path = resolve_local_project_path(root, relative_path)?; let parent = path.parent().ok_or_else(|| format!("{label} 缺少父目录"))?; - fs::create_dir_all(parent).map_err(|error| format!("创建 {label} 目录失败:{error}"))?; + crate::ensure_game_creator_private_directory_tree(parent, label)?; + crate::prepare_game_creator_private_path_for_read(parent, true, label)?; path = resolve_local_project_path(root, relative_path)?; + crate::prepare_game_creator_private_path_for_read(&path, false, label)?; if fs::symlink_metadata(&path).is_ok() { - open_project_snapshot_regular_file(&path, label)?; + open_project_private_regular_file(&path, label)?; } let file_name = path .file_name() @@ -912,9 +914,19 @@ fn write_asset_canvas_raw_sidecar( use std::os::unix::fs::OpenOptionsExt; options.custom_flags(libc::O_NOFOLLOW).mode(0o600); } + #[cfg(windows)] + { + use std::os::windows::fs::OpenOptionsExt; + options.custom_flags(PROJECT_FILE_FLAG_OPEN_REPARSE_POINT); + } let mut file = options .open(&temp_path) .map_err(|error| format!("创建 {label} 临时文件失败:{error}"))?; + if let Err(error) = crate::harden_new_game_creator_private_path(&temp_path, false, label) { + drop(file); + let _ = fs::remove_file(&temp_path); + return Err(error); + } file.write_all(bytes) .and_then(|_| file.sync_data()) .map_err(|error| { @@ -944,6 +956,7 @@ fn write_asset_canvas_raw_sidecar( .map_err(|error| format!("清理 {label} 恢复副本失败:{error}"))?; } } + crate::prepare_game_creator_private_path_for_read(&path, false, label)?; #[cfg(unix)] File::open(path.parent().ok_or_else(|| format!("{label} 缺少父目录"))?) .and_then(|directory| directory.sync_all()) @@ -1139,7 +1152,10 @@ fn try_acquire_asset_canvas_draft_lock( let parent = path .parent() .ok_or_else(|| "素材画布锁缺少父目录".to_string())?; - fs::create_dir_all(parent).map_err(|_| "创建素材画布锁目录失败".to_string())?; + crate::ensure_game_creator_private_directory_tree(parent, "素材画布锁目录")?; + crate::secure_windows_game_creator_path_for_current_user_with_auto_elevation( + parent, true, true, + )?; let open_lock = |create_new| { let mut options = fs::OpenOptions::new(); options @@ -1161,6 +1177,18 @@ fn try_acquire_asset_canvas_draft_lock( } Err(_) => return Err("获取素材画布系统文件锁失败".to_string()), }, + Err(error) if error.kind() == std::io::ErrorKind::PermissionDenied => { + // Permission denial is an ACL defect, not lock contention. Repair + // the existing managed lock once, then retry the non-creating + // open; sharing violations continue to return the normal busy + // result below. + crate::prepare_game_creator_private_path_for_read(&path, false, "素材画布锁")?; + match open_lock(false) { + Ok(file) => (file, false), + Err(error) if windows_file_lock_is_contended(&error) => return Ok(None), + Err(_) => return Err("获取素材画布系统文件锁失败".to_string()), + } + } Err(error) if windows_file_lock_is_contended(&error) => return Ok(None), Err(_) => return Err("获取素材画布系统文件锁失败".to_string()), }; @@ -1168,7 +1196,9 @@ fn try_acquire_asset_canvas_draft_lock( if created { crate::initialize_windows_game_creator_file_owner_for_current_user(&path)?; } else { - crate::secure_windows_game_creator_path_for_current_user(&path, false, true)?; + crate::secure_windows_game_creator_path_for_current_user_with_auto_elevation( + &path, false, true, + )?; } Ok(Some(AssetCanvasDraftLock { _file: file })) } @@ -1210,7 +1240,7 @@ fn open_and_validate_image_file( expected_media_type: &str, expected_sha256: Option<&str>, ) -> Result<(Vec, u32, u32), String> { - let (mut file, metadata) = open_project_snapshot_regular_file(path, "素材图片")?; + let (mut file, metadata) = open_project_private_regular_file(path, "素材图片")?; if metadata.len() == 0 || metadata.len() > ASSET_CANVAS_MAX_MEDIA_BYTES as u64 { return Err("素材图片大小超限".to_string()); } @@ -1610,7 +1640,9 @@ fn rollback_new_asset_canvas_files(paths: &[PathBuf], label: &str) -> Result<(), fn install_new_asset_canvas_file(path: &Path, bytes: &[u8], label: &str) -> Result<(), String> { let parent = path.parent().ok_or_else(|| format!("{label} 缺少父目录"))?; - fs::create_dir_all(parent).map_err(|_| format!("创建 {label} 目录失败"))?; + crate::ensure_game_creator_private_directory_tree(parent, label)?; + crate::prepare_game_creator_private_path_for_read(parent, true, label)?; + crate::prepare_game_creator_private_path_for_read(path, false, label)?; if fs::symlink_metadata(path).is_ok() { return Err(format!("{label} 目标已存在")); } @@ -1622,9 +1654,19 @@ fn install_new_asset_canvas_file(path: &Path, bytes: &[u8], label: &str) -> Resu use std::os::unix::fs::OpenOptionsExt; options.mode(0o600).custom_flags(libc::O_NOFOLLOW); } + #[cfg(windows)] + { + use std::os::windows::fs::OpenOptionsExt; + options.custom_flags(PROJECT_FILE_FLAG_OPEN_REPARSE_POINT); + } let mut file = options .open(&temp_path) .map_err(|_| format!("创建 {label} 临时文件失败"))?; + if let Err(error) = crate::harden_new_game_creator_private_path(&temp_path, false, label) { + drop(file); + let _ = fs::remove_file(&temp_path); + return Err(error); + } if let Err(error) = file.write_all(bytes).and_then(|_| file.sync_all()) { let _ = fs::remove_file(&temp_path); return Err(format!("写入 {label} 临时文件失败:{error}")); @@ -1640,7 +1682,7 @@ fn install_new_asset_canvas_file(path: &Path, bytes: &[u8], label: &str) -> Resu let completion = (|| -> Result<(), String> { fs::remove_file(&temp_path).map_err(|error| format!("完成 {label} 安装失败:{error}"))?; - let (_, metadata) = open_project_snapshot_regular_file(path, label)?; + let (_, metadata) = open_project_private_regular_file(path, label)?; if metadata.len() != bytes.len() as u64 { return Err(format!("{label} 安装后大小不一致")); } @@ -1675,7 +1717,7 @@ fn asset_canvas_draft_media_bytes(root: &Path, draft_id: &str) -> Result ASSET_CANVAS_MAX_LAYERS * 2 { return Err("素材画布媒体条目数量超限".to_string()); } - let (_, metadata) = open_project_snapshot_regular_file(&entry.path(), "素材画布媒体")?; + let (_, metadata) = open_project_private_regular_file(&entry.path(), "素材画布媒体")?; total = total .checked_add(metadata.len()) .ok_or_else(|| "素材画布媒体总量溢出".to_string())?; @@ -2911,7 +2953,7 @@ fn find_ledger_by_idempotency_key( return Err("素材画布 commit ledger 数量超限".to_string()); } let path = entry.path(); - let (_, metadata) = open_project_snapshot_regular_file(&path, "素材画布 commit ledger")?; + let (_, metadata) = open_project_private_regular_file(&path, "素材画布 commit ledger")?; if metadata.len() > ASSET_CANVAS_MAX_LEDGER_BYTES as u64 { return Err("素材画布 commit ledger 超限".to_string()); } @@ -3045,7 +3087,9 @@ fn replace_asset_canvas_runtime_entry( label: &str, ) -> Result<(), String> { let parent = path.parent().ok_or_else(|| format!("{label} 缺少父目录"))?; - fs::create_dir_all(parent).map_err(|_| format!("创建 {label} 目录失败"))?; + crate::ensure_game_creator_private_directory_tree(parent, label)?; + crate::prepare_game_creator_private_path_for_read(parent, true, label)?; + crate::prepare_game_creator_private_path_for_read(path, false, label)?; if let Ok(metadata) = fs::symlink_metadata(path) { if metadata.file_type().is_symlink() || !metadata.is_file() { return Err(format!("{label} 必须是普通文件")); @@ -3059,9 +3103,19 @@ fn replace_asset_canvas_runtime_entry( use std::os::unix::fs::OpenOptionsExt; options.mode(0o600).custom_flags(libc::O_NOFOLLOW); } + #[cfg(windows)] + { + use std::os::windows::fs::OpenOptionsExt; + options.custom_flags(PROJECT_FILE_FLAG_OPEN_REPARSE_POINT); + } let mut file = options .open(&temp_path) .map_err(|_| format!("创建 {label} 临时文件失败"))?; + if let Err(error) = crate::harden_new_game_creator_private_path(&temp_path, false, label) { + drop(file); + let _ = fs::remove_file(&temp_path); + return Err(error); + } if let Err(error) = file.write_all(bytes).and_then(|_| file.sync_all()) { let _ = fs::remove_file(&temp_path); return Err(format!("写入 {label} 临时文件失败:{error}")); @@ -3071,7 +3125,7 @@ fn replace_asset_canvas_runtime_entry( let _ = fs::remove_file(&temp_path); format!("安装 {label} 失败:{error}") })?; - let (_, metadata) = open_project_snapshot_regular_file(path, label)?; + let (_, metadata) = open_project_private_regular_file(path, label)?; if metadata.len() != bytes.len() as u64 { return Err(format!("{label} 安装后大小不一致")); } @@ -4029,7 +4083,7 @@ fn read_asset_canvas_snapshot( root, &asset_canvas_transaction_relative_path(commit_id, file_name), )?; - let (mut file, metadata) = open_project_snapshot_regular_file(&path, "素材画布事务快照")?; + let (mut file, metadata) = open_project_private_regular_file(&path, "素材画布事务快照")?; if metadata.len() > ASSET_CANVAS_MAX_LEDGER_BYTES as u64 { return Err("素材画布事务快照超限".to_string()); } diff --git a/apps/ai-game-creator-shell/src-tauri/src/project/checkpoint.rs b/apps/ai-game-creator-shell/src-tauri/src/project/checkpoint.rs index 66b6b142b..bbdd78bf2 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/project/checkpoint.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/project/checkpoint.rs @@ -22,10 +22,10 @@ pub(crate) fn create_local_project_checkpoint_at( &checkpoint_file_relative_path(&checkpoint_id, &normalized_path), )?; if let Some(parent) = target.parent() { - fs::create_dir_all(parent).map_err(|error| { - format!("创建 checkpoint 目录失败:{}: {error}", parent.display()) - })?; + ensure_game_creator_private_directory_tree(parent, "checkpoint 目录")?; + prepare_game_creator_private_path_for_read(parent, true, "checkpoint 目录")?; } + prepare_game_creator_private_path_for_read(&target, false, "checkpoint 文件")?; fs::copy(&source, &target).map_err(|error| { format!( "写入 checkpoint 文件失败:{} -> {}: {error}", @@ -33,6 +33,7 @@ pub(crate) fn create_local_project_checkpoint_at( target.display() ) })?; + prepare_game_creator_private_path_for_read(&target, false, "checkpoint 文件")?; } let total_bytes = files.iter().map(|file| file.size).sum::(); let manifest = serde_json::json!({ @@ -42,20 +43,21 @@ pub(crate) fn create_local_project_checkpoint_at( }); let manifest_path = resolve_local_project_path(root, &checkpoint_manifest_relative_path(&checkpoint_id))?; - fs::write( + if let Some(parent) = manifest_path.parent() { + ensure_game_creator_private_directory_tree(parent, "checkpoint manifest 目录")?; + prepare_game_creator_private_path_for_read(parent, true, "checkpoint manifest 目录")?; + } + prepare_game_creator_private_path_for_read(&manifest_path, false, "checkpoint manifest")?; + crate::write_game_creator_private_file( &manifest_path, format!( "{}\n", serde_json::to_string_pretty(&manifest) .map_err(|error| format!("序列化 checkpoint 失败:{error}"))? - ), - ) - .map_err(|error| { - format!( - "写入 checkpoint manifest 失败:{}: {error}", - manifest_path.display() ) - })?; + .as_bytes(), + "checkpoint manifest", + )?; append_agent_db_record( root, serde_json::json!({ @@ -147,13 +149,68 @@ pub(crate) fn open_project_snapshot_regular_file( } #[cfg(windows)] validate_windows_regular_file_handle(&file, label)?; + // The pathname was checked before opening, but another process can replace + // it between those two operations. Compare the opened handle identity to + // the current directory entry before any caller reads bytes; subsequent + // reads use the already-open handle and therefore are not pathname-based. + let path_metadata = fs::symlink_metadata(path) + .map_err(|error| format!("复核{label}路径失败:{}: {error}", path.display()))?; + if path_metadata.file_type().is_symlink() || !path_metadata.is_file() { + return Err(format!( + "{label}路径在安全打开期间发生替换:{}", + path.display() + )); + } + #[cfg(unix)] + { + use std::os::unix::fs::MetadataExt; + if path_metadata.dev() != metadata.dev() || path_metadata.ino() != metadata.ino() { + return Err(format!( + "{label}路径在安全打开期间发生替换:{}", + path.display() + )); + } + } + #[cfg(windows)] + { + use std::os::windows::fs::OpenOptionsExt; + let mut identity_options = fs::OpenOptions::new(); + identity_options + .read(true) + .custom_flags(PROJECT_FILE_FLAG_OPEN_REPARSE_POINT); + let identity_file = identity_options + .open(path) + .map_err(|error| format!("复核{label}路径失败:{}: {error}", path.display()))?; + validate_windows_regular_file_handle(&identity_file, label)?; + if crate::runner::windows_regular_file_handle_identity(&identity_file, label)? + != crate::runner::windows_regular_file_handle_identity(&file, label)? + { + return Err(format!( + "{label}路径在安全打开期间发生替换:{}", + path.display() + )); + } + } Ok((file, metadata)) } +/// Opens an AGC-managed project file after the private-path owner/DACL gate +/// has had a chance to repair an inherited or foreign ACL. Callers that read +/// arbitrary user-selected files must keep using +/// `open_project_snapshot_regular_file` so importing an external file never +/// silently changes its owner. +pub(crate) fn open_project_private_regular_file( + path: &Path, + label: &str, +) -> Result<(File, fs::Metadata), String> { + prepare_game_creator_private_path_for_read(path, false, label)?; + open_project_snapshot_regular_file(path, label) +} + fn read_local_project_content_diff_source( path: &Path, ) -> Result { - let (mut file, metadata) = open_project_snapshot_regular_file(path, "内容 diff 文件")?; + let (mut file, metadata) = open_project_private_regular_file(path, "内容 diff 文件")?; let mut hasher = Sha256::new(); let mut bytes = (metadata.len() <= PROJECT_CONTENT_DIFF_MAX_FILE_BYTES) .then(|| Vec::with_capacity(metadata.len() as usize)); @@ -518,10 +575,12 @@ pub(crate) fn restore_local_project_checkpoint_at( let restored_count = restore_plan.len(); for (source, target) in restore_plan { + prepare_game_creator_private_path_for_read(&source, false, "checkpoint 源文件")?; if let Some(parent) = target.parent() { - fs::create_dir_all(parent) - .map_err(|error| format!("创建恢复目录失败:{}: {error}", parent.display()))?; + ensure_game_creator_private_directory_tree(parent, "恢复目录")?; + prepare_game_creator_private_path_for_read(parent, true, "恢复目录")?; } + prepare_game_creator_private_path_for_read(&target, false, "恢复目标文件")?; fs::copy(&source, &target).map_err(|error| { format!( "恢复 checkpoint 文件失败:{} -> {}: {error}", @@ -529,6 +588,7 @@ pub(crate) fn restore_local_project_checkpoint_at( target.display() ) })?; + prepare_game_creator_private_path_for_read(&target, false, "恢复目标文件")?; } let deleted_count = delete_plan.len(); for target in delete_plan { diff --git a/apps/ai-game-creator-shell/src-tauri/src/project/conversation.rs b/apps/ai-game-creator-shell/src-tauri/src/project/conversation.rs index 12eb9f5d0..0b35bc267 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/project/conversation.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/project/conversation.rs @@ -68,6 +68,9 @@ fn file_modified_timestamp(path: &Path) -> u64 { } fn count_conversation_messages(path: &Path) -> Result { + if !prepare_game_creator_private_path_for_read(path, false, "对话记录")? { + return Ok(0); + } match File::open(path) { Ok(file) => { let mut count = 0_u64; @@ -118,6 +121,7 @@ fn read_agent_conversation_session_catalog_unlocked( validate_project_root(root)?; let agent_id = normalize_conversation_agent_id(agent_id)?; let catalog_path = agent_conversation_session_catalog_path(root, &agent_id); + prepare_game_creator_private_path_for_read(&catalog_path, false, "Agent Session 目录")?; let mut catalog = match fs::read_to_string(&catalog_path) { Ok(content) => serde_json::from_str::(&content) .map_err(|error| { @@ -305,37 +309,16 @@ fn write_agent_conversation_session_catalog_unlocked( ) -> Result<(), String> { let path = agent_conversation_session_catalog_path(root, &catalog.agent_id); if let Some(parent) = path.parent() { - fs::create_dir_all(parent).map_err(|error| { - format!("创建 Agent Session 目录失败:{}: {error}", parent.display()) - })?; + ensure_game_creator_private_directory_tree(parent, "Agent Session 目录")?; + prepare_game_creator_private_path_for_read(parent, true, "Agent Session 目录")?; } let content = serde_json::to_string_pretty(catalog) .map_err(|error| format!("序列化 Agent Session 目录失败:{error}"))?; - let temp_path = path.with_file_name(format!( - ".{}.tmp.{}.{}", - path.file_name() - .and_then(|value| value.to_str()) - .unwrap_or("sessions.json"), - std::process::id(), - SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap_or_default() - .as_nanos() - )); - fs::write(&temp_path, format!("{content}\n")).map_err(|error| { - format!( - "写入 Agent Session 临时目录失败:{}: {error}", - temp_path.display() - ) - })?; - fs::rename(&temp_path, &path).map_err(|error| { - let _ = fs::remove_file(&temp_path); - format!( - "替换 Agent Session 目录失败:{} -> {}: {error}", - temp_path.display(), - path.display() - ) - }) + write_game_creator_private_file( + &path, + format!("{content}\n").as_bytes(), + "Agent Session 目录", + ) } pub(crate) fn ensure_agent_conversation_session_at( @@ -419,6 +402,7 @@ pub(crate) fn ensure_agent_session_has_no_live_tasks( let task_path = root .join(".agent/runtime/tasks") .join(format!("{agent_id}.jsonl")); + prepare_game_creator_private_path_for_read(&task_path, false, "Agent Runtime 任务")?; match File::open(&task_path) { Ok(file) => { for line in BufReader::new(file).lines() { @@ -497,6 +481,7 @@ pub(crate) fn ensure_agent_session_has_no_live_tasks( if path.extension().and_then(|value| value.to_str()) != Some("jsonl") { continue; } + prepare_game_creator_private_path_for_read(&path, false, "Agent Runtime 任务")?; let mut delegated_latest_by_run = BTreeMap::::new(); let file = File::open(&path) .map_err(|error| format!("读取 Agent Runtime 任务失败:{}: {error}", path.display()))?; @@ -558,19 +543,25 @@ pub(crate) fn create_game_creator_agent_session_at( let conversation_path = conversation_file_path_for_resolved_session(root, &agent_id, &session_id); if let Some(parent) = conversation_path.parent() { - fs::create_dir_all(parent) - .map_err(|error| format!("创建对话目录失败:{}: {error}", parent.display()))?; + ensure_game_creator_private_directory_tree(parent, "对话目录")?; + prepare_game_creator_private_path_for_read(parent, true, "对话目录")?; } - fs::OpenOptions::new() - .create_new(true) - .write(true) - .open(&conversation_path) - .map_err(|error| { - format!( - "创建 Agent Session 对话失败:{}: {error}", - conversation_path.display() - ) - })?; + prepare_game_creator_private_path_for_read(&conversation_path, false, "对话记录")?; + let mut options = fs::OpenOptions::new(); + options.create_new(true).write(true); + #[cfg(windows)] + { + use std::os::windows::fs::OpenOptionsExt; + options.custom_flags(PROJECT_FILE_FLAG_OPEN_REPARSE_POINT); + } + let file = options.open(&conversation_path).map_err(|error| { + format!( + "创建 Agent Session 对话失败:{}: {error}", + conversation_path.display() + ) + })?; + harden_new_game_creator_private_path(&conversation_path, false, "对话记录")?; + drop(file); catalog.sessions.push(AgentConversationSessionRecord { session_id: session_id.clone(), title, @@ -665,20 +656,28 @@ where let conversation_path = conversation_file_path_for_resolved_session(root, &agent_id, &session_id); if let Some(parent) = conversation_path.parent() { - fs::create_dir_all(parent) - .map_err(|error| format!("创建对话目录失败:{}: {error}", parent.display()))?; + ensure_game_creator_private_directory_tree(parent, "对话目录")?; + prepare_game_creator_private_path_for_read(parent, true, "对话目录")?; } + prepare_game_creator_private_path_for_read( + &conversation_path, + false, + "Agent Session 分叉对话", + )?; let write_result = (|| -> Result<(), String> { - let mut file = fs::OpenOptions::new() - .create_new(true) - .write(true) - .open(&conversation_path) - .map_err(|error| { - format!( - "创建 Agent Session 分叉对话失败:{}: {error}", - conversation_path.display() - ) - })?; + let mut options = fs::OpenOptions::new(); + options.create_new(true).write(true); + #[cfg(windows)] + { + use std::os::windows::fs::OpenOptionsExt; + options.custom_flags(PROJECT_FILE_FLAG_OPEN_REPARSE_POINT); + } + let mut file = options.open(&conversation_path).map_err(|error| { + format!( + "创建 Agent Session 分叉对话失败:{}: {error}", + conversation_path.display() + ) + })?; for record in &records { serde_json::to_writer(&mut file, record) .map_err(|error| format!("序列化 Agent Session 分叉消息失败:{error}"))?; @@ -700,6 +699,11 @@ where let _ = fs::remove_file(&conversation_path); return Err(error); } + prepare_game_creator_private_path_for_read( + &conversation_path, + false, + "Agent Session 分叉对话", + )?; let now = unix_timestamp(); let requested_title = title.trim(); @@ -938,6 +942,7 @@ fn read_persisted_local_conversation_records_unlocked( path: &Path, ) -> Result, String> { let mut records = Vec::new(); + prepare_game_creator_private_path_for_read(path, false, "对话记录")?; match File::open(path) { Ok(file) => { for line in BufReader::new(file).lines() { @@ -1431,23 +1436,22 @@ pub(crate) fn append_markdown_entry( error_label: &str, ) -> Result<(), String> { if let Some(parent) = path.parent() { - fs::create_dir_all(parent) - .map_err(|error| format!("{error_label}:{}: {error}", parent.display()))?; + ensure_game_creator_private_directory_tree(parent, error_label)?; + prepare_game_creator_private_path_for_read(parent, true, error_label)?; } + prepare_game_creator_private_path_for_read(path, false, error_label)?; let needs_header = fs::metadata(path) .map(|metadata| metadata.len() == 0) .unwrap_or(true); - let mut file = fs::OpenOptions::new() - .create(true) - .append(true) - .open(path) - .map_err(|error| format!("{error_label}:{}: {error}", path.display()))?; - if needs_header { - file.write_all(header.as_bytes()) - .map_err(|error| format!("{error_label}:{}: {error}", path.display()))?; - } - file.write_all(entry.as_bytes()) - .map_err(|error| format!("{error_label}:{}: {error}", path.display())) + let bytes = if needs_header { + let mut bytes = Vec::with_capacity(header.len() + entry.len()); + bytes.extend_from_slice(header.as_bytes()); + bytes.extend_from_slice(entry.as_bytes()); + bytes + } else { + entry.as_bytes().to_vec() + }; + append_game_creator_private_file(path, &bytes, error_label) } pub(crate) fn append_local_permission_log_at( @@ -1474,16 +1478,12 @@ pub(crate) fn append_local_permission_log_at( let log_path = root.join(".agent/logs/command.log"); if let Some(parent) = log_path.parent() { - fs::create_dir_all(parent) - .map_err(|error| format!("创建命令日志目录失败:{}: {error}", parent.display()))?; + ensure_game_creator_private_directory_tree(parent, "命令日志目录")?; + prepare_game_creator_private_path_for_read(parent, true, "命令日志目录")?; } + prepare_game_creator_private_path_for_read(&log_path, false, "命令日志")?; let line = format!("{} {event} {command_id}\n", unix_timestamp()); - fs::OpenOptions::new() - .create(true) - .append(true) - .open(&log_path) - .and_then(|mut file| file.write_all(line.as_bytes())) - .map_err(|error| format!("写入命令日志失败:{}: {error}", log_path.display())) + append_game_creator_private_file(&log_path, line.as_bytes(), "命令日志") } #[cfg(test)] diff --git a/apps/ai-game-creator-shell/src-tauri/src/project/export.rs b/apps/ai-game-creator-shell/src-tauri/src/project/export.rs index 0b1238987..4beed6bc5 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/project/export.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/project/export.rs @@ -13,6 +13,7 @@ pub(crate) fn export_local_project_package_at( if !game_index_metadata.is_file() { return Err("导出试玩包前需要先生成 game/index.html".to_string()); } + prepare_game_creator_private_path_for_read(&game_index_path, false, "游戏入口")?; let game_index = fs::read_to_string(&game_index_path) .map_err(|error| format!("读取游戏入口失败:{}: {error}", game_index_path.display()))?; if game_index.trim().is_empty() { @@ -51,12 +52,22 @@ pub(crate) fn export_local_project_package_at( let package_relative_path = next_project_export_package_relative_path(root)?; let package_path = resolve_local_project_path(root, &package_relative_path)?; if let Some(parent) = package_path.parent() { - fs::create_dir_all(parent) - .map_err(|error| format!("创建导出目录失败:{}: {error}", parent.display()))?; + ensure_game_creator_private_directory_tree(parent, "导出目录")?; + prepare_game_creator_private_path_for_read(parent, true, "导出目录")?; } + prepare_game_creator_private_path_for_read(&package_path, false, "试玩包")?; - let file = File::create(&package_path) + let mut options = fs::OpenOptions::new(); + options.write(true).create_new(true); + #[cfg(windows)] + { + use std::os::windows::fs::OpenOptionsExt; + options.custom_flags(PROJECT_FILE_FLAG_OPEN_REPARSE_POINT); + } + let file = options + .open(&package_path) .map_err(|error| format!("创建试玩包失败:{}: {error}", package_path.display()))?; + harden_new_game_creator_private_path(&package_path, false, "试玩包")?; let mut writer = zip::ZipWriter::new(file); let options = zip::write::SimpleFileOptions::default() .compression_method(zip::CompressionMethod::Deflated); @@ -64,6 +75,7 @@ pub(crate) fn export_local_project_package_at( writer .start_file(relative_path, options) .map_err(|error| format!("写入试玩包条目失败:{relative_path}: {error}"))?; + prepare_game_creator_private_path_for_read(absolute_path, false, "导出文件")?; let bytes = fs::read(absolute_path) .map_err(|error| format!("读取导出文件失败:{}: {error}", absolute_path.display()))?; writer @@ -73,13 +85,10 @@ pub(crate) fn export_local_project_package_at( writer .finish() .map_err(|error| format!("完成试玩包失败:{}: {error}", package_path.display()))?; + prepare_game_creator_private_path_for_read(&package_path, false, "试玩包")?; let updated_at = unix_timestamp(); let log_path = root.join(".agent/logs/command.log"); - if let Some(parent) = log_path.parent() { - fs::create_dir_all(parent) - .map_err(|error| format!("创建命令日志目录失败:{}: {error}", parent.display()))?; - } let output = format!( "导出试玩包:{},{} 个文件,{}B", package_relative_path, @@ -87,12 +96,7 @@ pub(crate) fn export_local_project_package_at( total_bytes ); let line = format!("{updated_at} project.export_package: {output}\n"); - fs::OpenOptions::new() - .create(true) - .append(true) - .open(&log_path) - .and_then(|mut file| file.write_all(line.as_bytes())) - .map_err(|error| format!("写入命令日志失败:{}: {error}", log_path.display()))?; + append_game_creator_private_file(&log_path, line.as_bytes(), "命令日志")?; record_command_run( root, GameCreationAppCommandRunState { diff --git a/apps/ai-game-creator-shell/src-tauri/src/project/external_editor_bindings.rs b/apps/ai-game-creator-shell/src-tauri/src/project/external_editor_bindings.rs index 391bb6c10..943fc4237 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/project/external_editor_bindings.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/project/external_editor_bindings.rs @@ -1111,6 +1111,12 @@ fn validate_bounded_identity(value: &str, label: &str, max_chars: usize) -> Resu fn external_editor_binding_looks_like_absolute_path(value: &str) -> bool { let bytes = value.as_bytes(); Path::new(value).is_absolute() + // A leading slash is rooted on both Unix and Windows, even though + // `Path::is_absolute` is platform-specific and treats `/tmp` as + // drive-relative on Windows. Binding identities are opaque IDs, not + // filesystem paths, so reject both rooted spellings explicitly. + || value.starts_with('/') + || value.starts_with('\\') || value.starts_with("\\\\") || value.starts_with("~/") || value.starts_with("~\\") diff --git a/apps/ai-game-creator-shell/src-tauri/src/project/filesystem.rs b/apps/ai-game-creator-shell/src-tauri/src/project/filesystem.rs index c7f8f38e1..443a08eb3 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/project/filesystem.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/project/filesystem.rs @@ -1,7 +1,7 @@ use super::*; #[cfg(windows)] -pub(super) const PROJECT_FILE_FLAG_OPEN_REPARSE_POINT: u32 = 0x0020_0000; +pub(crate) const PROJECT_FILE_FLAG_OPEN_REPARSE_POINT: u32 = 0x0020_0000; static PROJECT_WRITE_LOCK_NONCE: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1); @@ -135,6 +135,7 @@ fn project_write_lock_can_be_reclaimed(path: &Path) -> bool { return false; }; if metadata.file_type().is_symlink() + || windows_metadata_is_reparse_point(&metadata) || !metadata.is_file() || metadata.len() > PROJECT_WRITE_LOCK_MAX_BYTES { @@ -196,12 +197,13 @@ pub(crate) fn acquire_project_write_lock( validate_project_root(root)?; let mut path = resolve_project_write_lock_path(root)?; if let Some(parent) = path.parent() { - fs::create_dir_all(parent) - .map_err(|error| format!("创建项目锁目录失败:{}: {error}", parent.display()))?; + ensure_game_creator_private_directory_tree(parent, "项目锁目录")?; + prepare_game_creator_private_path_for_read(parent, true, "项目锁目录")?; } // Re-check the parent after creation so skipping metadata only for the final // create_new target cannot weaken the normal ancestor link/reparse checks. path = resolve_project_write_lock_path(root)?; + prepare_game_creator_private_path_for_read(&path, false, "项目写锁")?; let payload = serde_json::json!({ "commandId": command_id, "pid": std::process::id(), @@ -212,16 +214,26 @@ pub(crate) fn acquire_project_write_lock( .map_err(|error| format!("生成项目写锁失败:{error}"))?; let mut retried_after_reclaim = false; loop { - match fs::OpenOptions::new() - .create_new(true) - .write(true) - .open(&path) + let mut options = fs::OpenOptions::new(); + options.create_new(true).write(true); + #[cfg(windows)] { + use std::os::windows::fs::OpenOptionsExt; + options.custom_flags(PROJECT_FILE_FLAG_OPEN_REPARSE_POINT); + } + match options.open(&path) { Ok(mut file) => { + if let Err(error) = harden_new_game_creator_private_path(&path, false, "项目写锁") + { + drop(file); + let _ = fs::remove_file(&path); + return Err(error); + } if let Err(error) = file.write_all(content.as_bytes()) { let _ = fs::remove_file(&path); return Err(format!("写入项目写锁失败:{}: {error}", path.display())); } + prepare_game_creator_private_path_for_read(&path, false, "项目写锁")?; return Ok(ProjectWriteLock { path, content: content.clone(), @@ -347,6 +359,7 @@ pub(crate) fn read_local_project_file_at( if !metadata.is_file() { return Err("只能读取文件".to_string()); } + prepare_game_creator_private_path_for_read(&path, false, "项目文件")?; let content = fs::read_to_string(&path) .map_err(|error| format!("读取项目文件失败:{}: {error}", path.display()))?; @@ -483,12 +496,7 @@ pub(crate) fn write_local_project_file_at( if path.exists() && !path.is_file() { return Err("只能写入文件".to_string()); } - if let Some(parent) = path.parent() { - fs::create_dir_all(parent) - .map_err(|error| format!("创建项目目录失败:{}: {error}", parent.display()))?; - } - fs::write(&path, content) - .map_err(|error| format!("写入项目文件失败:{}: {error}", path.display()))?; + crate::write_game_creator_private_file(&path, content.as_bytes(), "项目文件")?; Ok(LocalProjectFileMutationResult { path: normalized_path, @@ -516,6 +524,7 @@ pub(crate) fn delete_local_project_file_at( if !path.is_file() { return Err("只能删除文件".to_string()); } + prepare_game_creator_private_path_for_read(&path, false, "项目文件")?; fs::remove_file(&path) .map_err(|error| format!("删除项目文件失败:{}: {error}", path.display()))?; @@ -537,24 +546,17 @@ pub(crate) fn build_local_project_index_at(root: &Path) -> Result { - return Err("项目文件路径不能包含符号链接".to_string()); + Ok(metadata) + if metadata.file_type().is_symlink() + || windows_metadata_is_reparse_point(&metadata) => + { + return Err("项目文件路径不能包含符号链接或 Windows reparse point".to_string()); } Ok(_) => {} Err(error) if error.kind() == std::io::ErrorKind::NotFound => { @@ -808,10 +813,18 @@ pub(crate) fn validate_project_root(root: &Path) -> Result<(), String> { if project_path_has_control_chars(root) { return Err("项目目录不能包含控制字符".to_string()); } + // Every project operation enters through this validator. On Windows the + // root may be a historical directory whose owner is still the elevated + // installer account or whose DACL is inherited. Reuse the formal prepare + // entry here so all downstream reads/writes get the same one-shot UAC + // repair and post-repair verification, instead of failing later at the + // first individual sidecar read. + #[cfg(windows)] + crate::prepare_game_creator_project_root_for_read(root, true, "项目目录")?; match fs::symlink_metadata(root) { Ok(metadata) => { - if metadata.file_type().is_symlink() { - return Err("项目目录不能是符号链接".to_string()); + if metadata.file_type().is_symlink() || windows_metadata_is_reparse_point(&metadata) { + return Err("项目目录不能是符号链接或 Windows reparse point".to_string()); } } Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} @@ -822,6 +835,20 @@ pub(crate) fn validate_project_root(root: &Path) -> Result<(), String> { Ok(()) } +fn windows_metadata_is_reparse_point(metadata: &fs::Metadata) -> bool { + #[cfg(windows)] + { + use std::os::windows::fs::MetadataExt; + const FILE_ATTRIBUTE_REPARSE_POINT: u32 = 0x0000_0400; + return metadata.file_attributes() & FILE_ATTRIBUTE_REPARSE_POINT != 0; + } + #[cfg(not(windows))] + { + let _ = metadata; + false + } +} + pub(crate) fn project_path_has_control_chars(root: &Path) -> bool { root.to_string_lossy().chars().any(char::is_control) } diff --git a/apps/ai-game-creator-shell/src-tauri/src/project/manifest.rs b/apps/ai-game-creator-shell/src-tauri/src/project/manifest.rs index 5168849e5..3f44c3185 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/project/manifest.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/project/manifest.rs @@ -253,6 +253,8 @@ fn try_open_manifest_write_lock_file(path: &Path) -> Result, String .lock() .map_err(|_| "manifest 锁安全打开门禁已损坏".to_string())?; let lock_path = manifest_lock_path(path); + let existed = + crate::prepare_game_creator_private_path_for_read(&lock_path, false, "manifest 锁")?; let mut options = fs::OpenOptions::new(); options .create(true) @@ -277,6 +279,9 @@ fn try_open_manifest_write_lock_file(path: &Path) -> Result, String lock_path.display() )); } + if !existed { + crate::harden_new_game_creator_private_path(&lock_path, false, "manifest 锁")?; + } file.set_permissions(fs::Permissions::from_mode(0o600)) .map_err(|error| format!("收紧 manifest 锁权限失败:{}: {error}", lock_path.display()))?; let path_metadata = fs::symlink_metadata(&lock_path) @@ -340,6 +345,18 @@ fn try_open_manifest_write_lock_file(path: &Path) -> Result, String )); } } + let existed = match fs::symlink_metadata(&lock_path) { + Ok(_) => true, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => false, + Err(_) => { + // An inherited/foreign ACL can hide an existing lock from the + // normal token. Prepare it through the formal elevation gate so + // the subsequent exclusive open does not misclassify access + // denial as a stale lock or a generic failure. + crate::prepare_game_creator_private_path_for_read(&lock_path, false, "manifest 锁")?; + true + } + }; match fs::OpenOptions::new() .create(true) .read(true) @@ -350,12 +367,21 @@ fn try_open_manifest_write_lock_file(path: &Path) -> Result, String { Ok(file) => { validate_windows_regular_file_handle(&file, "manifest 锁")?; - // 提升权限运行时,Windows 可能用 TokenOwner=Administrators 创建新文件。 - // 独占句柄与普通文件检查通过后,将这个固定锁文件收归当前 TokenUser, - // 再复核句柄并按既有规则验证 owner/DACL;不放宽旧文件的安全门禁。 - crate::initialize_windows_game_creator_file_owner_for_current_user(&lock_path)?; - validate_windows_regular_file_handle(&file, "manifest 锁")?; - crate::secure_windows_game_creator_path_for_current_user(&lock_path, false, false)?; + if existed { + // Existing files may have a foreign owner or inherited DACL; + // let the strict verifier request one-shot UAC repair. + crate::secure_windows_game_creator_path_for_current_user_with_auto_elevation( + &lock_path, false, true, + )?; + } else { + // Only this invocation's newly created lock may initialize its + // owner. It is still revalidated after initialization. + crate::initialize_windows_game_creator_file_owner_for_current_user(&lock_path)?; + validate_windows_regular_file_handle(&file, "manifest 锁")?; + crate::secure_windows_game_creator_path_for_current_user_with_auto_elevation( + &lock_path, false, false, + )?; + } Ok(Some(file)) } Err(error) @@ -402,19 +428,20 @@ pub(crate) fn init_local_game_project_at( return Err("项目名称不能为空".to_string()); } + prepare_game_creator_project_root_for_read(root, true, "本地项目目录")?; for relative in ["game", "assets", "memory", "memory/agents", "exports"] { - fs::create_dir_all(root.join(relative)).map_err(|error| { - format!( - "创建本地项目目录失败:{}: {error}", - root.join(relative).display() - ) - })?; + let path = root.join(relative); + ensure_game_creator_private_directory_tree(&path, "本地项目目录")?; + prepare_game_creator_private_path_for_read(&path, true, "本地项目目录")?; } let index_path = root.join("game/index.html"); - if !index_path.exists() { - fs::write(&index_path, DEFAULT_GAME_INDEX_HTML) - .map_err(|error| format!("写入默认游戏入口失败:{}: {error}", index_path.display()))?; + if !prepare_game_creator_private_path_for_read(&index_path, false, "默认游戏入口")? { + crate::write_game_creator_private_file( + &index_path, + DEFAULT_GAME_INDEX_HTML.as_bytes(), + "默认游戏入口", + )?; } let agent_db_path = root.join(".agent/agent.db"); @@ -430,12 +457,9 @@ pub(crate) fn init_local_game_project_at( } for relative in [".agent/logs", ".agent/runtime"] { - fs::create_dir_all(root.join(relative)).map_err(|error| { - format!( - "创建本地项目目录失败:{}: {error}", - root.join(relative).display() - ) - })?; + let path = root.join(relative); + ensure_game_creator_private_directory_tree(&path, "本地项目目录")?; + prepare_game_creator_private_path_for_read(&path, true, "本地项目目录")?; } let manifest_path = root.join(".agent/manifest.json"); @@ -466,8 +490,15 @@ pub(crate) fn import_local_godot_project_at( if project_path_has_control_chars(root) { return Err("项目目录不能包含控制字符".to_string()); } - if !root.is_dir() { - return Err("Godot 工作区目录不存在或不是文件夹".to_string()); + // The user explicitly selected this workspace as a project root. Route it + // through the project-root ACL entry so an owner-correct inherited DACL (or + // a foreign owner that requires UAC) is repaired before discovery; once the + // AGC marker is written, descendants use the stricter managed-root policy. + prepare_game_creator_project_root_for_read(root, true, "Godot 工作区目录")?; + let root_metadata = fs::symlink_metadata(root) + .map_err(|error| format!("读取 Godot 工作区目录失败:{}: {error}", root.display()))?; + if root_metadata.file_type().is_symlink() || !root_metadata.is_dir() { + return Err("Godot 工作区目录不存在或不是普通文件夹".to_string()); } let godot_project_root = discover_local_godot_project_root(root)?.ok_or_else(|| { "所选工作区未在根目录或一层子目录发现有效的普通文件 project.godot".to_string() @@ -508,12 +539,9 @@ pub(crate) fn import_local_godot_project_at( } for relative in [".agent/logs", ".agent/runtime"] { - fs::create_dir_all(root.join(relative)).map_err(|error| { - format!( - "创建 Godot 项目 Agent 目录失败:{}: {error}", - root.join(relative).display() - ) - })?; + let path = root.join(relative); + ensure_game_creator_private_directory_tree(&path, "Godot 项目 Agent 目录")?; + prepare_game_creator_private_path_for_read(&path, true, "Godot 项目 Agent 目录")?; } let mut manifest = new_game_creation_app_manifest(project_id, name); @@ -1162,8 +1190,8 @@ pub(crate) fn mutate_manifest_at( } let manifest_path = root.join(".agent/manifest.json"); if let Some(parent) = manifest_path.parent() { - fs::create_dir_all(parent) - .map_err(|error| format!("创建 manifest 目录失败:{}: {error}", parent.display()))?; + ensure_game_creator_private_directory_tree(parent, "manifest 目录")?; + prepare_game_creator_private_path_for_read(parent, true, "manifest 目录")?; } let _write_lock = acquire_manifest_write_lock(&manifest_path)?; let (_, mut manifest) = read_or_create_manifest(root)?; @@ -1182,10 +1210,16 @@ fn manifest_backup_path(path: &Path) -> PathBuf { } pub(crate) fn manifest_storage_exists(path: &Path) -> Result { + let _ = prepare_game_creator_private_path_for_read(path, false, "manifest")?; match fs::symlink_metadata(path) { Ok(_) => Ok(true), Err(error) if error.kind() == std::io::ErrorKind::NotFound => { let backup_path = manifest_backup_path(path); + let _ = prepare_game_creator_private_path_for_read( + &backup_path, + false, + "manifest 恢复副本", + )?; match fs::symlink_metadata(&backup_path) { Ok(_) => Ok(true), Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(false), @@ -1219,9 +1253,15 @@ fn remove_manifest_backup(path: &Path) -> Result<(), String> { pub(crate) fn read_manifest(path: &Path) -> Result { let backup_path = manifest_backup_path(path); + let _ = prepare_game_creator_private_path_for_read(path, false, "manifest")?; let (source_path, metadata, is_backup) = match fs::symlink_metadata(path) { Ok(metadata) => (path, metadata, false), Err(error) if error.kind() == std::io::ErrorKind::NotFound => { + let _ = prepare_game_creator_private_path_for_read( + &backup_path, + false, + "manifest 恢复副本", + )?; match fs::symlink_metadata(&backup_path) { Ok(metadata) => (backup_path.as_path(), metadata, true), Err(backup_error) if backup_error.kind() == std::io::ErrorKind::NotFound => { @@ -1255,6 +1295,8 @@ pub(crate) fn read_manifest(path: &Path) -> Result Res .unwrap_or_default() .as_nanos() )); - fs::write(&temp_path, format!("{payload}\n")).map_err(|error| { - format!( - "写入 manifest 临时文件失败:{}: {error}", - temp_path.display() - ) - })?; + crate::write_game_creator_private_file( + &temp_path, + format!("{payload}\n").as_bytes(), + "manifest 临时文件", + )?; install_manifest_temp_with(path, &temp_path, |from, to| fs::rename(from, to))?; + prepare_game_creator_private_path_for_read(path, false, "manifest")?; let installed = read_manifest(path)?; if installed != *manifest { return Err("manifest 安装后回读与待写入内容不一致".to_string()); diff --git a/apps/ai-game-creator-shell/src-tauri/src/project/memory.rs b/apps/ai-game-creator-shell/src-tauri/src/project/memory.rs index a8551c0f2..c18ff0b8c 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/project/memory.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/project/memory.rs @@ -5,6 +5,15 @@ pub(crate) fn read_local_game_memory_at( scope: &str, ) -> Result { let (scope, path) = memory_file_path(root, scope)?; + let prepared = prepare_game_creator_private_path_for_read(&path, false, "游戏记忆")?; + if !prepared { + return Ok(LocalGameMemoryResult { + scope: scope.to_string(), + path: path.to_string_lossy().into_owned(), + content: String::new(), + exists: false, + }); + } match fs::read_to_string(&path) { Ok(content) => Ok(LocalGameMemoryResult { scope: scope.to_string(), @@ -28,6 +37,15 @@ pub(crate) fn read_local_agent_memory_at( ) -> Result { let relative_path = agent_role_memory_relative_path_for_task(task_id)?; let path = resolve_local_project_path(root, &relative_path)?; + let prepared = prepare_game_creator_private_path_for_read(&path, false, "Agent 记忆")?; + if !prepared { + return Ok(LocalAgentMemoryResult { + task_id: task_id.to_string(), + path: path.to_string_lossy().into_owned(), + content: String::new(), + exists: false, + }); + } match fs::read_to_string(&path) { Ok(content) => Ok(LocalAgentMemoryResult { task_id: task_id.to_string(), @@ -52,12 +70,7 @@ pub(crate) fn write_local_agent_memory_at( ) -> Result { let relative_path = agent_role_memory_relative_path_for_task(task_id)?; let path = resolve_local_project_path(root, &relative_path)?; - if let Some(parent) = path.parent() { - fs::create_dir_all(parent) - .map_err(|error| format!("创建 Agent 记忆目录失败:{}: {error}", parent.display()))?; - } - fs::write(&path, content) - .map_err(|error| format!("写入 Agent 记忆失败:{}: {error}", path.display()))?; + crate::write_game_creator_private_file(&path, content.as_bytes(), "Agent 记忆")?; Ok(LocalAgentMemoryResult { task_id: task_id.to_string(), path: path.to_string_lossy().into_owned(), @@ -72,12 +85,7 @@ pub(crate) fn write_local_game_memory_at( content: &str, ) -> Result { let (scope, path) = memory_file_path(root, scope)?; - if let Some(parent) = path.parent() { - fs::create_dir_all(parent) - .map_err(|error| format!("创建记忆目录失败:{}: {error}", parent.display()))?; - } - fs::write(&path, content) - .map_err(|error| format!("写入记忆失败:{}: {error}", path.display()))?; + crate::write_game_creator_private_file(&path, content.as_bytes(), "游戏记忆")?; Ok(LocalGameMemoryResult { scope: scope.to_string(), path: path.to_string_lossy().into_owned(), diff --git a/apps/ai-game-creator-shell/src-tauri/src/project/resource_editor.rs b/apps/ai-game-creator-shell/src-tauri/src/project/resource_editor.rs index d9cefa8e4..bb8296f32 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/project/resource_editor.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/project/resource_editor.rs @@ -1016,7 +1016,7 @@ fn read_stable_resource_edit_file( reject_sensitive_project_file_read(&normalized)?; let absolute = resolve_local_project_path(root, &normalized)?; validate_agent_runtime_inspection_ancestors(root, &absolute)?; - let (mut file, initial_metadata) = open_project_snapshot_regular_file(&absolute, label)?; + let (mut file, initial_metadata) = open_project_private_regular_file(&absolute, label)?; if initial_metadata.len() > max_bytes as u64 { return Err(format!("{label}不能超过 {} MiB", max_bytes / 1024 / 1024)); } @@ -1527,9 +1527,10 @@ fn write_resource_edit_staging( ) -> Result<(), String> { let path = resolve_local_project_path(root, &resource_edit_staging_path(operation_id))?; if let Some(parent) = path.parent() { - fs::create_dir_all(parent) - .map_err(|error| format!("创建资源编辑 staging 目录失败:{error}"))?; + ensure_game_creator_private_directory_tree(parent, "资源编辑 staging 目录")?; + prepare_game_creator_private_path_for_read(parent, true, "资源编辑 staging 目录")?; } + prepare_game_creator_private_path_for_read(&path, false, "资源编辑 staging")?; match fs::symlink_metadata(&path) { Ok(metadata) if metadata.file_type().is_symlink() || !metadata.is_file() => { return Err("资源编辑 staging 必须是普通文件".to_string()); @@ -1553,16 +1554,30 @@ fn write_resource_edit_staging( options.custom_flags(libc::O_NOFOLLOW); options.mode(0o600); } + #[cfg(windows)] + { + use std::os::windows::fs::OpenOptionsExt; + options.custom_flags(PROJECT_FILE_FLAG_OPEN_REPARSE_POINT); + } let mut file = options .open(&path) .map_err(|error| format!("创建资源编辑 staging 失败:{error}"))?; + if let Err(error) = harden_new_game_creator_private_path(&path, false, "资源编辑 staging") { + drop(file); + let _ = fs::remove_file(&path); + return Err(error); + } file.write_all(bytes) .and_then(|_| file.sync_data()) - .map_err(|error| format!("写入资源编辑 staging 失败:{error}")) + .map_err(|error| { + let _ = fs::remove_file(&path); + format!("写入资源编辑 staging 失败:{error}") + }) } fn read_resource_edit_staging(root: &Path, operation_id: &str) -> Result, String> { let path = resolve_local_project_path(root, &resource_edit_staging_path(operation_id))?; + prepare_game_creator_private_path_for_read(&path, false, "资源编辑 staging")?; let metadata = fs::symlink_metadata(&path) .map_err(|error| format!("读取资源编辑 staging 失败:{error}"))?; if metadata.file_type().is_symlink() || !metadata.is_file() { @@ -1576,6 +1591,10 @@ fn read_optional_resource_edit_staging( operation_id: &str, ) -> Result>, String> { let path = resolve_local_project_path(root, &resource_edit_staging_path(operation_id))?; + let prepared = prepare_game_creator_private_path_for_read(&path, false, "资源编辑 staging")?; + if !prepared { + return Ok(None); + } match fs::symlink_metadata(&path) { Ok(metadata) if metadata.file_type().is_symlink() || !metadata.is_file() => { Err("资源编辑 staging 必须是普通文件".to_string()) @@ -2322,10 +2341,7 @@ fn resource_edit_remote_request( }, }); } - Ok(( - "/api/editor/character-animations/generations", - body, - )) + Ok(("/api/editor/character-animations/generations", body)) } LocalProjectResourceEditKind::Video => { let mut body = serde_json::json!({ @@ -2372,10 +2388,7 @@ fn resource_edit_remote_request( "placeholder": external_canvas_placeholder("1:1"), }); } - Ok(( - "/api/editor/audios/sound-effects/generations", - body, - )) + Ok(("/api/editor/audios/sound-effects/generations", body)) } LocalProjectResourceEditKind::BackgroundMusic => { let mut body = serde_json::json!({ @@ -2392,10 +2405,7 @@ fn resource_edit_remote_request( "placeholder": external_canvas_placeholder("1:1"), }); } - Ok(( - "/api/editor/audios/background-music/generations", - body, - )) + Ok(("/api/editor/audios/background-music/generations", body)) } _ => Err("当前资源类型不是远端媒体派生".to_string()), } @@ -3861,8 +3871,10 @@ fn install_resource_edit_final_media( ) -> Result<(), String> { let absolute_path = resolve_local_project_path(root, relative_path)?; if let Some(parent) = absolute_path.parent() { - fs::create_dir_all(parent).map_err(|error| format!("创建派生资源目录失败:{error}"))?; + ensure_game_creator_private_directory_tree(parent, "派生资源目录")?; + prepare_game_creator_private_path_for_read(parent, true, "派生资源目录")?; } + prepare_game_creator_private_path_for_read(&absolute_path, false, "派生资源")?; let mut options = fs::OpenOptions::new(); options.write(true).create_new(true); #[cfg(unix)] @@ -3871,12 +3883,26 @@ fn install_resource_edit_final_media( options.custom_flags(libc::O_NOFOLLOW); options.mode(0o600); } + #[cfg(windows)] + { + use std::os::windows::fs::OpenOptionsExt; + options.custom_flags(PROJECT_FILE_FLAG_OPEN_REPARSE_POINT); + } let mut file = options .open(&absolute_path) .map_err(|error| format!("创建派生资源失败:{error}"))?; + if let Err(error) = harden_new_game_creator_private_path(&absolute_path, false, "派生资源") + { + drop(file); + let _ = fs::remove_file(&absolute_path); + return Err(error); + } file.write_all(bytes) .and_then(|_| file.sync_data()) - .map_err(|error| format!("写入派生资源失败:{error}")) + .map_err(|error| { + let _ = fs::remove_file(&absolute_path); + format!("写入派生资源失败:{error}") + }) } fn commit_resource_edit_asset_internal( @@ -8273,10 +8299,7 @@ mod tests { Some(&canvas_context), ) .expect("build create video request"); - assert_eq!( - create_video_endpoint, - "/api/editor/videos/generations" - ); + assert_eq!(create_video_endpoint, "/api/editor/videos/generations"); assert!(create_video_body.get("referenceVideoSrcs").is_none()); assert_eq!( create_video_body["projectId"], @@ -8521,9 +8544,7 @@ mod tests { "assetObjectId": "source-video-object" }}}), ); - } else if request_line - .starts_with("POST /api/editor/videos/generations ") - { + } else if request_line.starts_with("POST /api/editor/videos/generations ") { assert!(request_lower .contains("authorization: bearer resource-editor-external-key")); assert!(request_lower.contains("idempotency-key:")); diff --git a/apps/ai-game-creator-shell/src-tauri/src/project/resource_layout.rs b/apps/ai-game-creator-shell/src-tauri/src/project/resource_layout.rs index 8c87f9403..9723484f8 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/project/resource_layout.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/project/resource_layout.rs @@ -259,7 +259,9 @@ fn try_open_resource_layout_write_lock_file(root: &Path) -> Result, Ok(file) => { validate_windows_regular_file_handle(&file, "资源布局锁")?; if existed { - crate::secure_windows_game_creator_path_for_current_user(&path, false, true)?; + crate::secure_windows_game_creator_path_for_current_user_with_auto_elevation( + &path, false, true, + )?; } else { crate::initialize_windows_game_creator_file_owner_for_current_user(&path)?; } @@ -379,7 +381,7 @@ fn validate_existing_resource_layout_storage( let path = resolve_local_project_path(root, relative_path)?; match fs::symlink_metadata(&path) { Ok(_) => { - let (_, metadata) = open_project_snapshot_regular_file(&path, "资源布局 sidecar")?; + let (_, metadata) = open_project_private_regular_file(&path, "资源布局 sidecar")?; if metadata.len() > RESOURCE_LAYOUT_MAX_BYTES as u64 { return Err(format!( "资源布局 sidecar 超过 {RESOURCE_LAYOUT_MAX_BYTES} 字节上限" @@ -398,7 +400,7 @@ fn validate_existing_resource_layout_storage( match fs::symlink_metadata(&backup_path) { Ok(_) => { let (_, metadata) = - open_project_snapshot_regular_file(&backup_path, "资源布局恢复副本")?; + open_project_private_regular_file(&backup_path, "资源布局恢复副本")?; if metadata.len() > RESOURCE_LAYOUT_MAX_BYTES as u64 { return Err(format!( "资源布局恢复副本超过 {RESOURCE_LAYOUT_MAX_BYTES} 字节上限" diff --git a/apps/ai-game-creator-shell/src-tauri/src/project/verification.rs b/apps/ai-game-creator-shell/src-tauri/src/project/verification.rs index f30dd9710..acc780b40 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/project/verification.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/project/verification.rs @@ -15,6 +15,7 @@ pub(crate) fn run_limited_local_command_at( } let game_index_path = root.join("game/index.html"); + prepare_game_creator_private_path_for_read(&game_index_path, false, "游戏入口")?; let html = fs::read_to_string(&game_index_path) .map_err(|error| format!("读取游戏入口失败:{}: {error}", game_index_path.display()))?; if !html.contains("(&content) @@ -736,14 +721,13 @@ pub(crate) fn write_project_permission_policy_at( validate_project_root(root)?; let policy = normalize_project_permission_policy(policy)?; let path = root.join(PROJECT_PERMISSION_POLICY_PATH); - if let Some(parent) = path.parent() { - fs::create_dir_all(parent) - .map_err(|error| format!("创建项目权限策略目录失败:{}: {error}", parent.display()))?; - } let content = serde_json::to_string_pretty(&policy) .map_err(|error| format!("序列化项目权限策略失败:{error}"))?; - fs::write(&path, format!("{content}\n")) - .map_err(|error| format!("写入项目权限策略失败:{}: {error}", path.display()))?; + crate::write_game_creator_private_file( + &path, + format!("{content}\n").as_bytes(), + "项目权限策略", + )?; append_agent_db_record( root, serde_json::json!({ diff --git a/apps/ai-game-creator-shell/src-tauri/src/resource_inspect.rs b/apps/ai-game-creator-shell/src-tauri/src/resource_inspect.rs index 690c95b45..a311d04c9 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/resource_inspect.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/resource_inspect.rs @@ -2,8 +2,8 @@ use crate::image_inspect::{ same_open_file_identity, same_open_file_snapshot, validate_agent_runtime_inspection_ancestors, }; use crate::project::{ - normalize_relative_path, open_project_snapshot_regular_file, - reject_sensitive_project_file_read, resolve_local_project_path, + normalize_relative_path, open_project_private_regular_file, reject_sensitive_project_file_read, + resolve_local_project_path, }; use crate::resource_preview_scheduler::ProjectResourcePreviewScopeCancellation; use base64::Engine as _; @@ -354,7 +354,7 @@ fn read_stable_project_resource( let absolute = resolve_local_project_path(root, normalized)?; validate_agent_runtime_inspection_ancestors(root, &absolute)?; cancellation.check()?; - let (mut file, initial_metadata) = open_project_snapshot_regular_file(&absolute, label)?; + let (mut file, initial_metadata) = open_project_private_regular_file(&absolute, label)?; cancellation.check()?; if initial_metadata.len() > max_bytes { return Err(format!("{label}不能超过 {} MiB", max_bytes / 1024 / 1024)); @@ -392,7 +392,7 @@ fn read_stable_project_resource( return Err(format!("{label}读取期间发生漂移:{normalized}")); } cancellation.check()?; - let (reopened, reopened_metadata) = open_project_snapshot_regular_file(&absolute, label)?; + let (reopened, reopened_metadata) = open_project_private_regular_file(&absolute, label)?; if !same_open_file_identity(&file, &initial_metadata, &reopened, &reopened_metadata)? { return Err(format!("{label}路径读取期间发生替换:{normalized}")); } diff --git a/apps/ai-game-creator-shell/src-tauri/src/runner.rs b/apps/ai-game-creator-shell/src-tauri/src/runner.rs index be22c4d96..5767b0900 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/runner.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/runner.rs @@ -21,12 +21,14 @@ pub(crate) use client::{ shutdown_external_agent_runner_if_idle, steer_external_agent_runner, wake_external_agent_runner_pending, wake_external_agent_runner_pending_for_run, }; -#[cfg(windows)] -pub(crate) use endpoint::validate_windows_regular_file_handle; pub(crate) use endpoint::{ acquire_external_agent_runner_gui_owner_lock, external_agent_runner_enabled, external_agent_runner_is_server_process, }; +#[cfg(windows)] +pub(crate) use endpoint::{ + validate_windows_regular_file_handle, windows_regular_file_handle_identity, +}; #[allow(unused_imports)] pub(crate) use protocol::{ExternalAgentRunnerStatus, EXTERNAL_AGENT_RUNNER_PROTOCOL_VERSION}; pub(crate) use server::{ diff --git a/apps/ai-game-creator-shell/src-tauri/src/runner/endpoint.rs b/apps/ai-game-creator-shell/src-tauri/src/runner/endpoint.rs index bfbd56241..79a687e43 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/runner/endpoint.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/runner/endpoint.rs @@ -378,12 +378,16 @@ pub(super) fn private_create_new_file(path: &Path) -> io::Result { let secured = (|| { validate_windows_regular_file_handle(&file, "新建私有临时文件") .map_err(io::Error::other)?; - crate::initialize_windows_game_creator_file_owner_for_current_user(path) - .map_err(io::Error::other)?; + crate::secure_windows_game_creator_path_for_current_user_with_auto_elevation( + path, false, true, + ) + .map_err(io::Error::other)?; validate_windows_regular_file_handle(&file, "新建私有临时文件") .map_err(io::Error::other)?; - crate::secure_windows_game_creator_path_for_current_user(path, false, false) - .map_err(io::Error::other) + crate::secure_windows_game_creator_path_for_current_user_with_auto_elevation( + path, false, false, + ) + .map_err(io::Error::other) })(); if let Err(error) = secured { drop(file); @@ -399,6 +403,59 @@ pub(super) fn private_create_new_file(path: &Path) -> io::Result { } } +#[cfg(windows)] +pub(crate) fn windows_regular_file_handle_identity( + file: &File, + label: &str, +) -> Result<(u32, u64), String> { + use std::ffi::c_void; + use std::os::windows::io::AsRawHandle; + + #[repr(C)] + struct FileTime { + low_date_time: u32, + high_date_time: u32, + } + + #[repr(C)] + struct ByHandleFileInformation { + file_attributes: u32, + creation_time: FileTime, + last_access_time: FileTime, + last_write_time: FileTime, + volume_serial_number: u32, + file_size_high: u32, + file_size_low: u32, + number_of_links: u32, + file_index_high: u32, + file_index_low: u32, + } + + #[link(name = "kernel32")] + unsafe extern "system" { + fn GetFileInformationByHandle( + file: *mut c_void, + information: *mut ByHandleFileInformation, + ) -> i32; + } + + const FILE_ATTRIBUTE_DIRECTORY: u32 = 0x0000_0010; + const FILE_ATTRIBUTE_REPARSE_POINT: u32 = 0x0000_0400; + // SAFETY: the structure is plain data initialized by GetFileInformationByHandle. + let mut information = unsafe { std::mem::zeroed::() }; + // SAFETY: file owns a live kernel handle and information is a valid output pointer. + if unsafe { GetFileInformationByHandle(file.as_raw_handle().cast(), &mut information) } == 0 { + return Err(format!( + "读取 {label} Windows 文件句柄信息失败:{}", + io::Error::last_os_error() + )); + } + Ok(( + information.volume_serial_number, + (u64::from(information.file_index_high) << 32) | u64::from(information.file_index_low), + )) +} + #[cfg(windows)] pub(crate) fn validate_windows_regular_file_handle(file: &File, label: &str) -> Result<(), String> { use std::ffi::c_void; @@ -600,7 +657,9 @@ pub(super) fn write_external_agent_runner_endpoint_atomic( } #[cfg(windows)] - crate::secure_windows_game_creator_path_for_current_user(path, false, true)?; + crate::secure_windows_game_creator_path_for_current_user_with_auto_elevation( + path, false, true, + )?; Ok(()) } @@ -630,6 +689,14 @@ pub(super) fn open_external_agent_runner_endpoint_file(path: &Path) -> Result EXTERNAL_AGENT_RUNNER_MAX_ENDPOINT_BYTES { @@ -853,6 +922,12 @@ pub(super) fn try_open_external_agent_runner_lock( )); } + // Repair an existing lock before opening it. Otherwise an inherited + // DACL can make the first OpenOptions call fail before the UAC path gets + // a chance to run. Missing lock files are created below and hardened + // through the same gate after the handle is acquired. + crate::prepare_game_creator_private_path_for_read(path, false, label)?; + match OpenOptions::new() .create(true) .read(true) @@ -879,9 +954,10 @@ pub(super) fn try_open_external_agent_runner_lock( // lock path is known to be a stale, single-link, non-reparse regular file inside // the current TokenUser's private AppData. Repairing its owner is therefore safe // and is required when Windows creates it with TokenOwner=Administrators. - crate::initialize_windows_game_creator_file_owner_for_current_user(path)?; + crate::secure_windows_game_creator_path_for_current_user_with_auto_elevation( + path, false, true, + )?; validate_windows_regular_file_handle(&file, label)?; - crate::secure_windows_game_creator_path_for_current_user(path, false, false)?; Ok(Some(file)) } Err(error) if windows_external_agent_runner_lock_is_busy_error(&error) => Ok(None), diff --git a/apps/ai-game-creator-shell/src-tauri/src/runner/project_owner.rs b/apps/ai-game-creator-shell/src-tauri/src/runner/project_owner.rs index 6092dcf2e..6b07936dd 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/runner/project_owner.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/runner/project_owner.rs @@ -334,6 +334,11 @@ pub(super) fn open_windows_project_owner_root(root: &Path) -> Result 2); let planner = status @@ -917,7 +1016,6 @@ fn llm_config_check_reports_per_agent_status_without_leaking_keys() { .find(|agent| agent.agent_id == "planner") .expect("planner status"); assert!(planner.configured); - assert!(planner.api_key_present); assert_eq!(planner.model.as_deref(), Some("planner-model")); assert_eq!(planner.api_kind, "anthropic"); assert!(!planner.web_search_enabled); @@ -1008,7 +1106,8 @@ fn llm_status_cli_lines_include_agent_errors_without_leaking_keys() { let status = GameCreatorLlmConfigStatus { agent_mode: GAME_CREATOR_AGENT_MODE_PROVIDER.to_string(), configured: false, - api_key_present: false, + account_credential_state: "not_required".to_string(), + official_route_locked: false, base_url: Some("https://global.example.test/v1".to_string()), model: Some("global-model".to_string()), api_kind: "openai_responses".to_string(), @@ -1027,7 +1126,8 @@ fn llm_status_cli_lines_include_agent_errors_without_leaking_keys() { agent_id: "generator".to_string(), label: "Generator".to_string(), configured: false, - api_key_present: false, + account_credential_state: "not_required".to_string(), + official_route_locked: false, base_url: Some("https://generator.example.test/v1".to_string()), model: Some("generator-model".to_string()), api_kind: "openai_chat".to_string(), @@ -1580,6 +1680,29 @@ fn windows_private_dacl_does_not_reassert_an_owner_that_already_matches() { ); } +#[cfg(windows)] +#[test] +fn windows_acl_repair_classifies_foreign_owner_and_permission_failures_for_elevation() { + assert!(windows_acl_error_may_need_elevation( + "Windows 安全对象不属于当前用户:C:\\Users\\test\\AppData" + )); + assert!(windows_acl_error_may_need_elevation( + "Windows DACL 必须禁止继承并仅限当前用户:C:\\Users\\test\\AppData" + )); + assert!(windows_acl_error_may_need_elevation( + "读取 Windows owner 失败:error 5" + )); + assert!(windows_acl_error_may_need_elevation( + "启用 Windows SeTakeOwnershipPrivilege 失败:error 1300" + )); + assert!(!windows_acl_error_may_need_elevation( + "客户端 AppData 配置目录不能是 Windows reparse point" + )); + assert!(!windows_acl_error_may_need_elevation( + "待修复私有对象不能是 Windows reparse point 或链接" + )); +} + #[cfg(windows)] #[test] fn windows_appdata_validation_does_not_follow_directory_links() { diff --git a/apps/ai-game-creator-shell/src-tauri/src/ui_editor/persistence.rs b/apps/ai-game-creator-shell/src-tauri/src/ui_editor/persistence.rs index 9557d24b2..a20a04b26 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/ui_editor/persistence.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/ui_editor/persistence.rs @@ -10,7 +10,7 @@ use crate::*; use nalgebra::Vector2; use serde::{Deserialize, Serialize}; use std::collections::HashSet; -use std::fs::{self, File}; +use std::fs; use std::io::{Read, Write}; use std::path::Path; use std::time::{SystemTime, UNIX_EPOCH}; @@ -312,7 +312,7 @@ fn read_valid_ui_design_document_path( } fn read_ui_design_document_path(path: &Path) -> Result { - let (mut file, metadata) = open_project_snapshot_regular_file(path, "UI 设计 State")?; + let (mut file, metadata) = open_project_private_regular_file(path, "UI 设计 State")?; if metadata.len() > UI_DESIGN_STATE_MAX_BYTES as u64 { return Err(format!( "UI 设计 State 超过 {UI_DESIGN_STATE_MAX_BYTES} 字节上限" @@ -386,9 +386,11 @@ fn write_ui_design_raw_file(path: &Path, label: &str, bytes: &[u8]) -> Result<() return Err(format!("{label} 超过 {UI_DESIGN_STATE_MAX_BYTES} 字节上限")); } let parent = path.parent().ok_or_else(|| format!("{label} 缺少父目录"))?; - fs::create_dir_all(parent).map_err(|error| format!("创建 {label} 目录失败:{error}"))?; + ensure_game_creator_private_directory_tree(parent, label)?; + prepare_game_creator_private_path_for_read(parent, true, label)?; + prepare_game_creator_private_path_for_read(path, false, label)?; if fs::symlink_metadata(path).is_ok() { - open_project_snapshot_regular_file(path, label)?; + open_project_private_regular_file(path, label)?; } let name = path @@ -408,12 +410,22 @@ fn write_ui_design_raw_file(path: &Path, label: &str, bytes: &[u8]) -> Result<() use std::os::unix::fs::OpenOptionsExt; options.custom_flags(libc::O_NOFOLLOW).mode(0o600); } + #[cfg(windows)] + { + use std::os::windows::fs::OpenOptionsExt; + options.custom_flags(crate::PROJECT_FILE_FLAG_OPEN_REPARSE_POINT); + } let mut file = options.open(&temp_path).map_err(|error| { format!( "创建 {label} 临时文件失败:{}: {error}", temp_path.display() ) })?; + if let Err(error) = harden_new_game_creator_private_path(&temp_path, false, label) { + drop(file); + let _ = fs::remove_file(&temp_path); + return Err(error); + } file.write_all(bytes) .and_then(|_| file.sync_data()) .map_err(|error| { @@ -424,7 +436,6 @@ fn write_ui_design_raw_file(path: &Path, label: &str, bytes: &[u8]) -> Result<() ) })?; drop(file); - match fs::rename(&temp_path, path) { // `.previous` is prepared by write_ui_design_document before this call; // preserve it on the normal atomic-rename path for startup recovery. @@ -456,8 +467,9 @@ fn write_ui_design_raw_file(path: &Path, label: &str, bytes: &[u8]) -> Result<() remove_agent_runtime_json_sidecar_backup(&replacement_path, label)?; } } + prepare_game_creator_private_path_for_read(path, false, label)?; #[cfg(unix)] - File::open(parent) + std::fs::File::open(parent) .and_then(|directory| directory.sync_all()) .map_err(|error| format!("同步 {label} 目录失败:{}: {error}", parent.display()))?; Ok(()) diff --git a/apps/ai-game-creator-shell/src-tauri/src/ui_editor/resource_bridge.rs b/apps/ai-game-creator-shell/src-tauri/src/ui_editor/resource_bridge.rs index 0200500d5..db271aee7 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/ui_editor/resource_bridge.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/ui_editor/resource_bridge.rs @@ -1,10 +1,11 @@ use crate::ui_editor::persistence::initialize_ui_design_state_with_source_image_at; use crate::{ acquire_project_write_lock, advance_agent_runtime_project_revision_locked, - enforce_project_permission_policy, read_existing_manifest_for_project, - read_game_creator_agent_runtime_project_revision, register_local_asset_at, - resolve_local_project_path, write_manifest, GameCreationAppAssetManifestEntry, - GameCreationAppAssetSource, GameCreationAppAssetSourceKind, GameCreationAppManifest, + enforce_project_permission_policy, prepare_game_creator_private_path_for_read, + read_existing_manifest_for_project, read_game_creator_agent_runtime_project_revision, + register_local_asset_at, resolve_local_project_path, write_manifest, + GameCreationAppAssetManifestEntry, GameCreationAppAssetSource, GameCreationAppAssetSourceKind, + GameCreationAppManifest, }; use image::GenericImageView; use serde::{Deserialize, Serialize}; @@ -81,6 +82,7 @@ pub(crate) fn ensure_ui_design_resource_for_prototype( .unwrap_or(prototype_asset_id) .to_string(); let source_absolute_path = resolve_local_project_path(root, &source_path)?; + prepare_game_creator_private_path_for_read(&source_absolute_path, false, "UI 原型图片")?; let dimensions = image::open(&source_absolute_path) .map_err(|error| format!("读取 UI 原型图片失败:{error}"))? .dimensions(); @@ -108,12 +110,7 @@ pub(crate) fn ensure_ui_design_resource_for_prototype( let (resource_name, relative_path) = next_ui_design_path(root, &manifest)?; let absolute_path = resolve_local_project_path(root, &relative_path)?; - if let Some(parent) = absolute_path.parent() { - fs::create_dir_all(parent) - .map_err(|error| format!("创建 UI 资源目录失败:{}: {error}", parent.display()))?; - } - fs::write(&absolute_path, "") - .map_err(|error| format!("创建 UI 资源失败:{}: {error}", absolute_path.display()))?; + crate::write_game_creator_private_file(&absolute_path, b"", "UI 资源")?; let asset = match register_local_asset_at( root, diff --git a/apps/ai-game-creator-shell/src-tauri/src/ui_editor/workflow.rs b/apps/ai-game-creator-shell/src-tauri/src/ui_editor/workflow.rs index 829782148..9d1e68871 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/ui_editor/workflow.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/ui_editor/workflow.rs @@ -396,6 +396,7 @@ fn discover_ui_pages(root: &Path) -> Result, Strin let mut declarations = Vec::<(UiWorkflowPageDeclaration, String)>::new(); let registry_path = resolve_local_project_path(root, UI_WORKFLOW_PAGE_REGISTRY_PATH)?; if registry_path.exists() { + prepare_game_creator_private_path_for_read(®istry_path, false, "UI 页面注册表")?; let metadata = fs::symlink_metadata(®istry_path) .map_err(|error| format!("读取 UI 页面注册表失败:{error}"))?; if metadata.file_type().is_symlink() || !metadata.is_file() { @@ -425,6 +426,7 @@ fn discover_ui_pages(root: &Path) -> Result, Strin if !path.exists() { continue; } + prepare_game_creator_private_path_for_read(&path, false, "UI 页面声明文件")?; let metadata = fs::symlink_metadata(&path) .map_err(|error| format!("读取 UI 页面声明文件失败:{relative}: {error}"))?; if metadata.file_type().is_symlink() { @@ -732,19 +734,37 @@ fn ensure_page_ui_resource( } else { let relative_path = workflow_relative_path(source, &page.page_id); let absolute_path = resolve_local_project_path(root, &relative_path)?; - if absolute_path.exists() { + if prepare_game_creator_private_path_for_read(&absolute_path, false, "UI workflow 资源")? + { return Err(format!( "UI workflow 资源路径已存在但未登记:{relative_path}" )); } - fs::create_dir_all( - absolute_path - .parent() - .ok_or_else(|| "UI workflow 路径缺少父目录".to_string())?, - ) - .map_err(|error| format!("创建 UI workflow 目录失败:{error}"))?; - fs::write(&absolute_path, b"") + let parent = absolute_path + .parent() + .ok_or_else(|| "UI workflow 路径缺少父目录".to_string())?; + ensure_game_creator_private_directory_tree(parent, "UI workflow 目录")?; + prepare_game_creator_private_path_for_read(parent, true, "UI workflow 目录")?; + let mut options = fs::OpenOptions::new(); + options.write(true).create_new(true); + #[cfg(windows)] + { + use std::os::windows::fs::OpenOptionsExt; + options.custom_flags(crate::PROJECT_FILE_FLAG_OPEN_REPARSE_POINT); + } + let mut file = options + .open(&absolute_path) .map_err(|error| format!("创建 UI workflow 资源失败:{error}"))?; + if let Err(error) = + harden_new_game_creator_private_path(&absolute_path, false, "UI workflow 资源") + { + drop(file); + let _ = fs::remove_file(&absolute_path); + return Err(error); + } + file.sync_all() + .map_err(|error| format!("同步 UI workflow 资源失败:{error}"))?; + drop(file); let registered = register_local_asset_at( root, &relative_path, @@ -1349,6 +1369,7 @@ fn validate_application_marker( return Err("applicationPath 必须位于 game/".to_string()); } let path = resolve_local_project_path(root, &normalized)?; + prepare_game_creator_private_path_for_read(&path, false, "applicationPath")?; let metadata = fs::symlink_metadata(&path) .map_err(|error| format!("读取 applicationPath 失败:{error}"))?; if metadata.file_type().is_symlink() || !metadata.is_file() || metadata.len() == 0 { @@ -1383,6 +1404,7 @@ fn apply_application_marker( return Err("applicationPath 必须位于 game/".to_string()); } let path = resolve_local_project_path(root, &normalized)?; + prepare_game_creator_private_path_for_read(&path, false, "applicationPath")?; let metadata = fs::symlink_metadata(&path) .map_err(|error| format!("读取 applicationPath 失败:{error}"))?; if metadata.file_type().is_symlink() || !metadata.is_file() || metadata.len() == 0 { @@ -1405,7 +1427,7 @@ fn apply_application_marker( return Ok(()); } let next = format!("{content}\n{marker_comment}\n"); - fs::write(&path, next.as_bytes()) + crate::write_game_creator_private_file(&path, next.as_bytes(), "applicationPath") .map_err(|error| format!("应用 UI 页面 {} 到游戏失败:{error}", page_id))?; advance_agent_runtime_project_revision_locked(root) .map(|_| ()) @@ -1423,11 +1445,11 @@ fn write_final_receipt( let digest = Sha256::digest(source_asset_id.as_bytes()); let relative = format!(".agent/ui-workflows/{}.json", &format!("{digest:x}")[..24]); let path = resolve_local_project_path(root, &relative)?; - fs::create_dir_all( - path.parent() - .ok_or_else(|| "UI workflow receipt 缺少父目录".to_string())?, - ) - .map_err(|error| format!("创建 UI workflow receipt 目录失败:{error}"))?; + let parent = path + .parent() + .ok_or_else(|| "UI workflow receipt 缺少父目录".to_string())?; + ensure_game_creator_private_directory_tree(parent, "UI workflow receipt 目录")?; + prepare_game_creator_private_path_for_read(parent, true, "UI workflow receipt 目录")?; let receipt = UiWorkflowReceipt { schema_version: UI_WORKFLOW_RECEIPT_SCHEMA_VERSION.to_string(), project_id: project_id.to_string(), @@ -1438,10 +1460,34 @@ fn write_final_receipt( let bytes = serde_json::to_vec_pretty(&receipt) .map_err(|error| format!("序列化 UI workflow receipt 失败:{error}"))?; let temporary = path.with_extension("json.tmp"); - fs::write(&temporary, &bytes) + let mut temporary_options = fs::OpenOptions::new(); + temporary_options.write(true).create_new(true); + #[cfg(windows)] + { + use std::os::windows::fs::OpenOptionsExt; + temporary_options.custom_flags(crate::PROJECT_FILE_FLAG_OPEN_REPARSE_POINT); + } + let mut temporary_file = temporary_options + .open(&temporary) + .map_err(|error| format!("创建 UI workflow receipt 临时文件失败:{error}"))?; + if let Err(error) = + harden_new_game_creator_private_path(&temporary, false, "UI workflow receipt 临时文件") + { + drop(temporary_file); + let _ = fs::remove_file(&temporary); + return Err(error); + } + temporary_file + .write_all(&bytes) + .and_then(|_| temporary_file.sync_all()) .map_err(|error| format!("写入 UI workflow receipt 临时文件失败:{error}"))?; - fs::rename(&temporary, &path) - .map_err(|error| format!("安装 UI workflow receipt 失败:{error}"))?; + drop(temporary_file); + prepare_game_creator_private_path_for_read(&path, false, "UI workflow receipt")?; + fs::rename(&temporary, &path).map_err(|error| { + let _ = fs::remove_file(&temporary); + format!("安装 UI workflow receipt 失败:{error}") + })?; + prepare_game_creator_private_path_for_read(&path, false, "UI workflow receipt")?; let installed = fs::read(&path).map_err(|error| format!("回读 UI workflow receipt 失败:{error}"))?; if installed != bytes { diff --git a/apps/ai-game-creator-shell/src/App.tsx b/apps/ai-game-creator-shell/src/App.tsx index e265d1e82..debc671a1 100644 --- a/apps/ai-game-creator-shell/src/App.tsx +++ b/apps/ai-game-creator-shell/src/App.tsx @@ -5254,8 +5254,8 @@ export function App({ ? `LLM 已配置:${formatLlmRouteEndpoint(status)}。` : `LLM 未就绪:${status.error ?? '配置不完整'}。${ status.reasoningEffort ? `推理 ${status.reasoningEffort},` : '' - }联网检索 ${status.webSearchEnabled ? '开启' : '关闭'},API Key:${ - status.apiKeyPresent ? '已读取' : '未读取' + }联网检索 ${status.webSearchEnabled ? '开启' : '关闭'},账号凭据:${ + status.accountCredentialState ?? '未检查' }。`; setMessages((current) => [ ...current, @@ -11036,6 +11036,7 @@ export function App({ {runtimeConfigOpen ? ( setRuntimeConfigOpen(false)} onLog={(entry) => setCommandLog((current) => [...current, entry])} /> diff --git a/apps/ai-game-creator-shell/src/app/types.ts b/apps/ai-game-creator-shell/src/app/types.ts index 23474fb71..8525ca603 100644 --- a/apps/ai-game-creator-shell/src/app/types.ts +++ b/apps/ai-game-creator-shell/src/app/types.ts @@ -618,7 +618,8 @@ export type GameCreatorLlmReasoningEffort = export interface GameCreatorLlmConfigStatus { agentMode: GameCreatorAgentMode; configured: boolean; - apiKeyPresent: boolean; + accountCredentialState?: string; + officialRouteLocked?: boolean; baseUrl: string | null; model: string | null; apiKind: string; @@ -637,7 +638,8 @@ export interface GameCreatorAgentLlmConfigStatus { agentId: string; label: string; configured: boolean; - apiKeyPresent: boolean; + accountCredentialState?: string; + officialRouteLocked?: boolean; baseUrl: string | null; model: string | null; apiKind: string; diff --git a/apps/ai-game-creator-shell/src/features/app-shell/useDeveloperAgentPanel.ts b/apps/ai-game-creator-shell/src/features/app-shell/useDeveloperAgentPanel.ts index ef39f1443..77ac80c1e 100644 --- a/apps/ai-game-creator-shell/src/features/app-shell/useDeveloperAgentPanel.ts +++ b/apps/ai-game-creator-shell/src/features/app-shell/useDeveloperAgentPanel.ts @@ -617,11 +617,11 @@ export function useDeveloperAgentPanel(launcherView: LauncherView) { agentStatus.reasoningEffort ? `,推理 ${agentStatus.reasoningEffort}` : '' - },联网检索 ${agentStatus.webSearchEnabled ? '开启' : '关闭'},API Key ${ - agentStatus.apiKeyPresent ? '已读取' : '未读取' + },联网检索 ${agentStatus.webSearchEnabled ? '开启' : '关闭'},账号凭据 ${ + agentStatus.accountCredentialState ?? '未检查' }` : `当前 Agent LLM 未就绪:${ - agentStatus.error ?? '缺少 API Key 或模型配置' + agentStatus.error ?? '账号凭据或模型配置不可用' }${ agentStatus.reasoningEffort ? `(推理 ${agentStatus.reasoningEffort})` diff --git a/apps/ai-game-creator-shell/src/features/project-summary/agentPresentation.ts b/apps/ai-game-creator-shell/src/features/project-summary/agentPresentation.ts index 126acff67..58347fb5f 100644 --- a/apps/ai-game-creator-shell/src/features/project-summary/agentPresentation.ts +++ b/apps/ai-game-creator-shell/src/features/project-summary/agentPresentation.ts @@ -785,7 +785,7 @@ export function formatLlmAgentStatusLine( ...(agent.reasoningEffort ? [`推理 ${agent.reasoningEffort}`] : []), `流式 ${agent.stream ? '开启' : '关闭'}`, `联网检索 ${agent.webSearchEnabled ? '开启' : '关闭'}`, - `API Key ${agent.apiKeyPresent ? '已读取' : '未读取'}`, + `账号凭据 ${agent.accountCredentialState ?? '未检查'}`, ]; if (!agent.configured && agent.error) { parts.push(`错误:${agent.error}`); @@ -803,7 +803,7 @@ export function formatLlmRouteEndpoint( | 'reasoningEffort' | 'stream' | 'webSearchEnabled' - | 'apiKeyPresent' + | 'accountCredentialState' >, ) { if (isCodexAgentMode(status.agentMode)) { @@ -815,7 +815,7 @@ export function formatLlmRouteEndpoint( status.reasoningEffort ? `,推理 ${status.reasoningEffort}` : '' },流式 ${status.stream ? '开启' : '关闭'},联网检索 ${ status.webSearchEnabled ? '开启' : '关闭' - },API Key ${status.apiKeyPresent ? '已读取' : '未读取'}`; + },账号凭据 ${status.accountCredentialState ?? '未检查'}`; } export function isSameResolvedLlmRouteAsGlobal( @@ -932,7 +932,7 @@ export function formatAgentCardLlmStatus( : []), `流式${agentStatus.stream ? '开' : '关'}`, `联网检索${agentStatus.webSearchEnabled ? '开' : '关'}`, - `Key${agentStatus.apiKeyPresent ? '已读' : '未读'}`, + `账号凭据${agentStatus.accountCredentialState ?? '未检查'}`, ]), ].join(' · '); } @@ -1008,7 +1008,7 @@ export function formatAgentDialogLlmStatus( : []), `流式 ${agentStatus.stream ? '开启' : '关闭'}`, `联网检索 ${agentStatus.webSearchEnabled ? '开启' : '关闭'}`, - `API Key ${agentStatus.apiKeyPresent ? '已读取' : '未读取'}`, + `账号凭据 ${agentStatus.accountCredentialState ?? '未检查'}`, ]; if (!agentStatus.configured && agentStatus.error) { parts.push(`错误:${agentStatus.error}`); diff --git a/apps/ai-game-creator-shell/src/features/runtime-config/RuntimeConfigDialog.tsx b/apps/ai-game-creator-shell/src/features/runtime-config/RuntimeConfigDialog.tsx index 386c6d2cf..e37e7bd4d 100644 --- a/apps/ai-game-creator-shell/src/features/runtime-config/RuntimeConfigDialog.tsx +++ b/apps/ai-game-creator-shell/src/features/runtime-config/RuntimeConfigDialog.tsx @@ -13,7 +13,6 @@ import { } from 'lucide-react'; import { type FormEvent, useEffect, useRef, useState } from 'react'; -import { createGameCreationAppSeedTasks } from '../../../../../packages/shared/src/contracts/gameCreationApp'; import BRAND_ICON from '../../../../../packages/shared/src/icons/taonier-product-ip.png'; import { APP_NAME, APP_VERSION } from '../../app/appMetadata'; import { @@ -23,46 +22,18 @@ import { } from '../../app/dialogs'; import { resolveTauriInvoke } from '../../app/tauri'; import { - type GameCreatorAgentLlmConfig, type GameCreatorAppConfig, type GameCreatorAppConfigView, - type GameCreatorLlmApiKind, - type GameCreatorLlmConfig, + type GameCreatorLlmConfigStatus, type GameCreatorLlmReasoningEffort, gameCreatorLlmReasoningEfforts, - type RuntimeAgentLlmProviderPresetId, - type RuntimeLlmProviderPresetId, } from '../../app/types'; -const runtimeAgentReasoningEffortDefaults = { - 'project-supervisor': 'high', - planner: 'high', - orchestrator: 'medium', - generator: 'high', - evaluator: 'high', - 'design-director': 'medium', - 'design-foundation': 'high', - 'balance-director': 'medium', - 'balance-seed': 'medium', - 'art-director': 'high', - 'art-asset-plan': 'high', - 'art-polish': 'medium', - 'audio-director': 'low', - 'audio-asset-plan': 'medium', - 'code-director': 'medium', - 'code-prototype': 'high', - 'quality-review': 'high', - 'preview-readiness': 'low', - 'preview-playtest': 'low', - 'publish-strategy': 'low', - 'publish-package': 'medium', -} as const satisfies Record; - const defaultRuntimeConfigDraft: GameCreatorAppConfig = { agentMode: 'codex_app_server', llm: { apiKey: '', - baseUrl: 'https://dev.genarrative.world/gpt/v1', + baseUrl: 'https://router.genarrative.world/v1', model: 'gpt-5.6-sol', apiKind: 'openai_responses', reasoningEffort: 'max', @@ -82,6 +53,42 @@ const defaultRuntimeConfigDraft: GameCreatorAppConfig = { }, }; +const OFFICIAL_LLM_BASE_URL = 'https://router.genarrative.world/v1'; +const OFFICIAL_LLM_MODEL = 'gpt-5.6-sol'; + +function formatAccountCredentialState( + status: GameCreatorLlmConfigStatus | null, +) { + switch (status?.accountCredentialState) { + case 'ready': + return { label: '账号 Key 已就绪', tone: 'success' as const }; + case 'login_required': + return { label: '请先登录陶泥儿账号', tone: 'warning' as const }; + case 'permission_required': + case 'forbidden': + return { + label: '账号 Key 权限不足,请重新登录', + tone: 'warning' as const, + }; + case 'acl_repair_required': + return { label: '本机凭据权限需要修复', tone: 'warning' as const }; + case 'unavailable': + return { + label: '账号 Key 暂不可用,请稍后重试', + tone: 'warning' as const, + }; + case 'not_required': + return { + label: '当前发行版不需要本地账号 Key', + tone: 'neutral' as const, + }; + default: + return status?.configured + ? { label: '账号权限已就绪', tone: 'success' as const } + : { label: '尚未检查账号权限', tone: 'neutral' as const }; + } +} + type RuntimeSettingsSection = | 'general' | 'agents' @@ -132,91 +139,6 @@ const runtimeSettingsSections = [ icon: typeof Settings2; }>; -const runtimeCoreAgentLlmRows = [ - { id: 'planner', label: 'Planner' }, - { id: 'orchestrator', label: 'Orchestrator' }, - { id: 'generator', label: 'Generator' }, - { id: 'evaluator', label: 'Evaluator' }, -] as const; - -const runtimeAgentLlmRows = [ - { id: 'project-supervisor', label: '项目总控 Agent' }, - ...runtimeCoreAgentLlmRows, - ...createGameCreationAppSeedTasks().map((task) => ({ - id: task.id, - label: `${task.title} (${task.group}/${task.role})`, - })), -] satisfies Array<{ id: string; label: string }>; - -const runtimeLlmProviderPresets: Array<{ - id: RuntimeLlmProviderPresetId; - label: string; - baseUrl: string; - model: string; - apiKind: GameCreatorLlmApiKind; -}> = [ - { - id: 'openai', - label: 'OpenAI', - baseUrl: 'https://api.openai.com/v1', - model: 'gpt-4.1', - apiKind: 'openai_responses', - }, - { - id: 'deepseek', - label: 'DeepSeek', - baseUrl: 'https://api.deepseek.com', - model: 'deepseek-chat', - apiKind: 'openai_chat', - }, - { - id: 'anthropic', - label: 'Anthropic', - baseUrl: 'https://api.anthropic.com', - model: 'claude-3-5-sonnet-latest', - apiKind: 'anthropic', - }, - { - id: 'ark', - label: '火山 Ark', - baseUrl: 'https://ark.cn-beijing.volces.com/api/v3', - model: 'doubao-seed-1-6', - apiKind: 'openai_chat', - }, -]; - -function findRuntimeLlmProviderPreset( - config: Pick, -) { - return runtimeLlmProviderPresets.find( - (preset) => - preset.baseUrl === config.baseUrl && - preset.model === config.model && - preset.apiKind === config.apiKind, - ); -} - -function resolveRuntimeLlmProviderPresetId( - config: Pick, -): RuntimeLlmProviderPresetId { - return findRuntimeLlmProviderPreset(config)?.id ?? 'custom'; -} - -function resolveRuntimeAgentLlmProviderPresetId( - config: GameCreatorAgentLlmConfig, -): RuntimeAgentLlmProviderPresetId { - if (!config.baseUrl && !config.model && !config.apiKind) { - return 'inherit'; - } - return ( - findRuntimeLlmProviderPreset({ - baseUrl: config.baseUrl ?? '', - model: config.model ?? '', - apiKind: config.apiKind ?? defaultRuntimeConfigDraft.llm.apiKind, - })?.id ?? 'custom' - ); -} - function clampRuntimeConfigNumber(value: number, minimum: number) { const numericValue = Number(value); return Number.isFinite(numericValue) && numericValue >= minimum @@ -230,102 +152,24 @@ function isGameCreatorLlmReasoningEffort( return gameCreatorLlmReasoningEfforts.some((effort) => effort === value); } -function normalizeRuntimeAgentLlmConfig( - config: GameCreatorAgentLlmConfig | undefined, -): GameCreatorAgentLlmConfig { - if (!config) { - return {}; - } - const normalized: GameCreatorAgentLlmConfig = {}; - if (typeof config.apiKey === 'string' && config.apiKey.trim()) { - normalized.apiKey = config.apiKey; - } - if (typeof config.baseUrl === 'string' && config.baseUrl.trim()) { - normalized.baseUrl = config.baseUrl; - } - if (typeof config.model === 'string' && config.model.trim()) { - normalized.model = config.model; - } - if ( - config.apiKind && - ['openai_responses', 'openai_chat', 'anthropic'].includes(config.apiKind) - ) { - normalized.apiKind = config.apiKind; - } - if (isGameCreatorLlmReasoningEffort(config.reasoningEffort)) { - normalized.reasoningEffort = config.reasoningEffort; - } - if (typeof config.stream === 'boolean') { - normalized.stream = config.stream; - } - if (typeof config.webSearchEnabled === 'boolean') { - normalized.webSearchEnabled = config.webSearchEnabled; - } - if (typeof config.contextWindowTokens === 'number') { - normalized.contextWindowTokens = clampRuntimeConfigNumber( - config.contextWindowTokens, - 1, - ); - } - if (typeof config.autoCompactTokenLimit === 'number') { - normalized.autoCompactTokenLimit = clampRuntimeConfigNumber( - config.autoCompactTokenLimit, - 1, - ); - } - if (typeof config.toolOutputTokenLimit === 'number') { - normalized.toolOutputTokenLimit = clampRuntimeConfigNumber( - config.toolOutputTokenLimit, - 1, - ); - } - if (typeof config.requestTimeoutMs === 'number') { - normalized.requestTimeoutMs = clampRuntimeConfigNumber( - config.requestTimeoutMs, - 1000, - ); - } - if (typeof config.maxRetries === 'number') { - normalized.maxRetries = clampRuntimeConfigNumber(config.maxRetries, 0); - } - if (typeof config.retryBackoffMs === 'number') { - normalized.retryBackoffMs = clampRuntimeConfigNumber( - config.retryBackoffMs, - 1, - ); - } - return normalized; -} - function normalizeRuntimeConfigDraft( config: GameCreatorAppConfig, allowAdvancedExternalEditorConfig: boolean, ): GameCreatorAppConfig { - const apiKind: GameCreatorLlmApiKind = [ - 'openai_responses', - 'openai_chat', - 'anthropic', - ].includes(config.llm.apiKind) - ? config.llm.apiKind - : defaultRuntimeConfigDraft.llm.apiKind; const reasoningEffort = isGameCreatorLlmReasoningEffort( config.llm.reasoningEffort, ) ? config.llm.reasoningEffort : defaultRuntimeConfigDraft.llm.reasoningEffort; - const agentLlm: Record = {}; - for (const [agentId, agentConfig] of Object.entries(config.agentLlm ?? {})) { - const normalized = normalizeRuntimeAgentLlmConfig(agentConfig); - if (Object.keys(normalized).length > 0) { - agentLlm[agentId] = normalized; - } - } return { ...config, - agentMode: config.agentMode, + agentMode: 'codex_app_server', llm: { ...config.llm, - apiKind, + apiKey: '', + baseUrl: OFFICIAL_LLM_BASE_URL, + model: OFFICIAL_LLM_MODEL, + apiKind: 'openai_responses', reasoningEffort, webSearchEnabled: typeof config.llm.webSearchEnabled === 'boolean' @@ -356,7 +200,10 @@ function normalizeRuntimeConfigDraft( maxRetries: clampRuntimeConfigNumber(config.llm.maxRetries, 0), retryBackoffMs: clampRuntimeConfigNumber(config.llm.retryBackoffMs, 1), }, - agentLlm, + // Official AGC builds have one account-backed route. Drop every legacy + // per-agent override (including non-sensitive tuning) at the UI boundary + // so it cannot be persisted or accidentally re-exposed as a route. + agentLlm: {}, editorApi: allowAdvancedExternalEditorConfig ? { ...defaultRuntimeConfigDraft.editorApi, ...config.editorApi } : { ...defaultRuntimeConfigDraft.editorApi }, @@ -365,14 +212,25 @@ function normalizeRuntimeConfigDraft( export function RuntimeConfigDialog({ allowAdvancedExternalEditorConfig = false, + llmConfigStatus = null, onClose, onLog, }: { projectPath?: string; allowAdvancedExternalEditorConfig?: boolean; + llmConfigStatus?: GameCreatorLlmConfigStatus | null; onClose: () => void; onLog?: (entry: string) => void; }) { + const allowCustomLlmConfiguration = false; + // The prop is intentionally not sufficient to expose developer credentials: + // only an explicitly opted-in Vite development build may render the legacy + // External Editor fields. Release bundles therefore ignore accidental or + // stale callers that pass the prop. + const advancedExternalEditorConfigEnabled = + allowAdvancedExternalEditorConfig && + import.meta.env.DEV && + import.meta.env.VITE_AGC_ENABLE_ADVANCED_EXTERNAL_EDITOR_CONFIG === '1'; const [runtimeConfigPath, setRuntimeConfigPath] = useState(''); const [runtimeConfigStatus, setRuntimeConfigStatus] = useState('未读取'); const [runtimeConfigToast, setRuntimeConfigToast] = @@ -380,9 +238,10 @@ export function RuntimeConfigDialog({ const [runtimeConfigDraft, setRuntimeConfigDraft] = useState(defaultRuntimeConfigDraft); const [runtimeConfigBusy, setRuntimeConfigBusy] = useState(false); + const [effectiveLlmConfigStatus, setEffectiveLlmConfigStatus] = + useState(llmConfigStatus); const [activeSection, setActiveSection] = useState('general'); - const [expandedAgentIds, setExpandedAgentIds] = useState([]); const runtimeConfigBusyRef = useRef(false); useEscapeToClose(onClose); @@ -399,6 +258,10 @@ export function RuntimeConfigDialog({ }; }, []); + useEffect(() => { + setEffectiveLlmConfigStatus(llmConfigStatus); + }, [llmConfigStatus]); + useEffect(() => { void readRuntimeConfig(); // The dialog reads once on mount; subsequent reads are explicit user actions. @@ -418,81 +281,6 @@ export function RuntimeConfigDialog({ })); } - function updateRuntimeLlmProviderPreset( - presetId: RuntimeLlmProviderPresetId, - ) { - const preset = runtimeLlmProviderPresets.find( - (candidate) => candidate.id === presetId, - ); - if (!preset) { - return; - } - setRuntimeConfigDraft((current) => ({ - ...current, - llm: { - ...current.llm, - baseUrl: preset.baseUrl, - model: preset.model, - apiKind: preset.apiKind, - }, - })); - } - - function updateRuntimeAgentLlmConfig< - K extends keyof GameCreatorAgentLlmConfig, - >(agentId: string, key: K, value: GameCreatorAgentLlmConfig[K]) { - setRuntimeConfigDraft((current) => ({ - ...current, - agentLlm: { - ...(current.agentLlm ?? {}), - [agentId]: { - ...(current.agentLlm?.[agentId] ?? {}), - [key]: value, - }, - }, - })); - } - - function updateRuntimeAgentLlmProviderPreset( - agentId: string, - presetId: RuntimeAgentLlmProviderPresetId, - ) { - if (presetId === 'inherit') { - setRuntimeConfigDraft((current) => { - const nextConfig = { ...(current.agentLlm?.[agentId] ?? {}) }; - delete nextConfig.baseUrl; - delete nextConfig.model; - delete nextConfig.apiKind; - return { - ...current, - agentLlm: { - ...(current.agentLlm ?? {}), - [agentId]: nextConfig, - }, - }; - }); - return; - } - const preset = runtimeLlmProviderPresets.find( - (candidate) => candidate.id === presetId, - ); - if (!preset) { - return; - } - setRuntimeConfigDraft((current) => ({ - ...current, - agentLlm: { - ...(current.agentLlm ?? {}), - [agentId]: { - ...(current.agentLlm?.[agentId] ?? {}), - baseUrl: preset.baseUrl, - model: preset.model, - apiKind: preset.apiKind, - }, - }, - })); - } - async function readRuntimeConfig() { if (runtimeConfigBusyRef.current) { return; @@ -513,10 +301,19 @@ export function RuntimeConfigDialog({ setRuntimeConfigPath(result.path); const config = normalizeRuntimeConfigDraft( result.config, - allowAdvancedExternalEditorConfig, + advancedExternalEditorConfigEnabled, ); setRuntimeConfigDraft(config); setRuntimeConfigStatus(`已读取:${result.path}`); + try { + const status = await invoke( + 'check_game_creator_llm_config', + ); + setEffectiveLlmConfigStatus(status); + } catch { + // Older test fixtures and non-Tauri previews may not expose the + // diagnostic command; the config read itself remains usable. + } onLog?.('runtime_config.read'); } catch (error) { setRuntimeConfigStatus( @@ -548,11 +345,11 @@ export function RuntimeConfigDialog({ { ...runtimeConfigDraft, agentMode: 'codex_app_server', - editorApi: allowAdvancedExternalEditorConfig + editorApi: advancedExternalEditorConfigEnabled ? runtimeConfigDraft.editorApi : defaultRuntimeConfigDraft.editorApi, }, - allowAdvancedExternalEditorConfig, + advancedExternalEditorConfigEnabled, ); const result = await invoke( 'write_game_creator_app_config', @@ -561,10 +358,19 @@ export function RuntimeConfigDialog({ setRuntimeConfigPath(result.path); const savedConfig = normalizeRuntimeConfigDraft( result.config, - allowAdvancedExternalEditorConfig, + advancedExternalEditorConfigEnabled, ); setRuntimeConfigDraft(savedConfig); setRuntimeConfigStatus(`已保存:${result.path}`); + try { + const status = await invoke( + 'check_game_creator_llm_config', + ); + setEffectiveLlmConfigStatus(status); + } catch { + // Keep the last safe status when the optional diagnostic refresh is + // unavailable in a preview or test fixture. + } setRuntimeConfigToast({ tone: 'success', message: '保存成功,新的运行时配置已生效', @@ -700,91 +506,35 @@ export function RuntimeConfigDialog({ 陶泥儿智能创作(固定) 需求将由陶泥儿智能创作服务执行 +
+ 官方 LLM 路由 + router.genarrative.world/v1 · gpt-5.6-sol + + 登录后自动使用账号权限,客户端不保存或展示 LLM API Key。 + +
+ {(() => { + const credential = formatAccountCredentialState( + effectiveLlmConfigStatus, + ); + return ( +
+ 账号权限 + {credential.label} + + {credential.tone === 'success' + ? '由 API Server 按当前登录账号转发到官方 Router。' + : '请返回登录页完成登录或重新登录;普通用户无需填写任何 LLM Key。'} + +
+ ); + })()} {runtimeConfigDraft.agentMode !== 'codex_cli' ? ( <> - - - - - - - - - - - - - - - - - ) : null} - - ); - })} + {activeSection === 'agents' && !allowCustomLlmConfiguration ? ( +
+ Agent 路由 + 所有 Agent 统一使用官方账号路由 + + 普通用户不能为单个 Agent 设置 Provider、Key、Base URL + 或模型。 +
) : null} {activeSection === 'connections' ? ( @@ -1232,7 +706,7 @@ export function RuntimeConfigDialog({

配置图片编辑器使用的外部服务

- {allowAdvancedExternalEditorConfig ? ( + {advancedExternalEditorConfigEnabled ? (
- {activeSection === 'agents' ? ( - {configuredAgentCount} 个角色已覆盖 - ) : null}
{activeSection === 'general' ? ( <>
- Agent 模式 + 工作方式 陶泥儿智能创作(固定) - 需求将由陶泥儿智能创作服务执行 + 需求将由官方智能服务执行
- 官方 LLM 路由 - router.genarrative.world/v1 · gpt-5.6-sol - - 登录后自动使用账号权限,客户端不保存或展示 LLM API Key。 - + 智能服务 + 官方账号服务(固定) + 登录后自动使用当前账号权限。
{(() => { const credential = formatAccountCredentialState( @@ -528,8 +520,8 @@ export function RuntimeConfigDialog({ {credential.label} {credential.tone === 'success' - ? '由 API Server 按当前登录账号转发到官方 Router。' - : '请返回登录页完成登录或重新登录;普通用户无需填写任何 LLM Key。'} + ? '当前账号已具备智能创作权限。' + : '请返回登录页完成登录或重新登录;普通用户无需填写任何智能服务凭据。'}
); @@ -537,9 +529,9 @@ export function RuntimeConfigDialog({ {runtimeConfigDraft.agentMode !== 'codex_cli' ? ( <> ) : null} @@ -592,9 +584,9 @@ export function RuntimeConfigDialog({ runtimeConfigDraft.agentMode !== 'codex_cli' ? ( <>
- ) : transientReply ? ( + ) : null} + {transientReply ? (

diff --git a/apps/ai-game-creator-shell/src/styles.css b/apps/ai-game-creator-shell/src/styles.css index ecb06d6ad..e4281b392 100644 --- a/apps/ai-game-creator-shell/src/styles.css +++ b/apps/ai-game-creator-shell/src/styles.css @@ -2587,6 +2587,12 @@ textarea { display: block; max-height: 180px; overflow: auto; + scrollbar-color: rgb(47 111 75 / 58%) transparent; +} + +.project-supervisor-process-card p.is-expanded::-webkit-scrollbar-track, +.project-supervisor-process-card p.is-expanded::-webkit-scrollbar-corner { + background: transparent; } .project-supervisor-process-detail { diff --git a/apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts b/apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts index 90558c11f..7eaf924ca 100644 --- a/apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts +++ b/apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts @@ -5700,7 +5700,10 @@ export function registerProjectSupervisorSurfaceTests() { expect( within(streamingProcessCard).getByLabelText('陶泥儿正在执行的内容') .textContent, - ).toContain('正在生成回复:DIRECT_STREAM:先完成正式客户端玩法拆解'); + ).toBe('正在生成回复'); + expect( + within(supervisorSurface).getByLabelText('陶泥儿实时回复').textContent, + ).toBe('DIRECT_STREAM:先完成正式客户端玩法拆解'); expect(directMessageList.scrollTop).toBe(640); expect(within(supervisorSurface).queryByText(/不能覆盖正文/u)).toBeNull(); expect( @@ -5725,6 +5728,9 @@ export function registerProjectSupervisorSurfaceTests() { expect( within(streamingProcessCard).getByText(/正在执行 npm test/u), ).not.toBeNull(); + expect( + within(supervisorSurface).getByLabelText('陶泥儿实时回复').textContent, + ).toBe('DIRECT_STREAM:先完成正式客户端玩法拆解'); expect( ( within(streamingProcessCard).getByRole('button', { @@ -5808,10 +5814,10 @@ export function registerProjectSupervisorSurfaceTests() { expect( within(failedTurnProcessCard).getByLabelText('陶泥儿正在执行的内容') .textContent, - ).toContain('正在生成回复:即将失败的临时正文'); + ).toBe('正在生成回复'); expect( - within(supervisorSurface).queryByLabelText('陶泥儿实时回复'), - ).toBeNull(); + within(supervisorSurface).getByLabelText('陶泥儿实时回复').textContent, + ).toBe('即将失败的临时正文'); await act(async () => { directTurnUpdateHandler?.({ payload: { diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index b13c9d328..13e60d322 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -18,9 +18,9 @@ ## 2026-09-02 Direct 过程卡按回合阶段状态驱动 - 背景:DirectProject 结果卡把工具活动词、中间文本和真实回复增量都当成“实时回复”,标题随最近一次事件跳动;上游常整包返回正文时还叠加合成打字机,用户看到的是行为名而非当前阶段。 -- 决策:Direct 过程卡顶部标题只由 `GameCreatorDirectTurnUpdateStatus` 决定(accepted=需求已接收 / running=任务执行中 / streaming=回复生成中 / finalizing=结果整理中 / completed=回复已生成 / failed=处理失败),小字只展示当前正在执行的具体内容并统一加“正在”前缀;真实回复增量(AccumulatedText)才标记 streaming,计划、推理、工具输出与 Activity 一律 running。移除合成打字机回放;工具说明/中间文本不再触发 streaming。展开/收起是同一 `project + clientTurnId` 内的持久状态,内容更新不重置,切换新回合才收起。 +- 决策:Direct 过程卡顶部标题只由 `GameCreatorDirectTurnUpdateStatus` 决定(accepted=需求已接收 / running=任务执行中 / streaming=回复生成中 / finalizing=结果整理中 / completed=回复已生成 / failed=处理失败),小字只展示当前正在执行的具体内容并统一加“正在”前缀;真实回复增量(AccumulatedText)才标记 streaming,计划、推理、工具输出与 Activity 一律 running。生成中的累计回复直接作为 assistant 消息气泡在会话列表中原位更新,不再拼进过程卡;进入 finalizing / completed 时保留完整累计回复直到正式消息接管,失败时清除未完成正文。移除合成打字机回放;工具说明/中间文本不再触发 streaming。展开/收起是同一 `project + clientTurnId` 内的持久状态,内容更新不重置,切换新回合才收起;展开详情的滚动条轨道和角落保持透明。 - 影响范围:`apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime.rs` 的 DirectProject observer、`apps/ai-game-creator-shell/src/App.tsx` 的事件投影、`ProjectSupervisorView` 过程卡渲染与对应 AppSurface 回归。 -- 验证方式:Rust 单测证明只有开启流式时的 AccumulatedText 是 streaming;AppSurface 覆盖接受态、running 长文本展开、streaming 标题与小字、同一回合后续 running 不收起、失败态小字与卡片清理;AGC typecheck、全量 appSurface、rustfmt、`npm run check:encoding`、`git diff --check` 通过。 +- 验证方式:Rust 单测证明只有开启流式时的 AccumulatedText 是 streaming;AppSurface 覆盖接受态、running 长文本展开、streaming 正文进入 assistant 气泡且过程卡只显示阶段、同一回合后续 running 不覆盖正文也不收起、失败后清除未完成正文、正式消息接管不重复;样式核对确认展开详情的滚动条轨道与角落透明;AGC typecheck、全量 appSurface、rustfmt、`npm run check:encoding`、`git diff --check` 通过。 - 关联文档:`docs/technical/【技术方案】Direct回合行为审计账本-2026-08-31.md`、分支 `feat/agc-llm-router-official-chain`。 --- From 5008691975c88a922b6d9892553886465bf0ca36 Mon Sep 17 00:00:00 2001 From: suzmii Date: Wed, 2 Sep 2026 18:24:14 +0800 Subject: [PATCH 22/43] =?UTF-8?q?=E8=BF=87=E7=A8=8B=E5=8D=A1=E6=80=9D?= =?UTF-8?q?=E8=80=83=E9=98=B6=E6=AE=B5=E6=98=BE=E7=A4=BA=E6=AD=A3=E5=9C=A8?= =?UTF-8?q?=E6=80=9D=E8=80=83=E5=B9=B6=E4=BF=9D=E7=95=99=E5=85=B7=E4=BD=93?= =?UTF-8?q?=E6=89=A7=E8=A1=8C=E5=91=BD=E4=BB=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - preparing 活动词文案由正在理解需求改为正在思考中 - Codex 计划与推理通知只收敛为 thinking 活动,不再把原始推理正文送入 UI - 推理期 thinking 活动按 1.2 秒限流,避免重复事件刷屏 - command-exec 等执行细节不再依赖回复流开关,stream=false 也保留具体命令 - 同一活动的心跳事件不再用通用文案覆盖正在执行的具体命令 - 补充 Rust 纯函数单测与 AppSurface 思考态、命令心跳回归 - 决策记录同步 thinking 与执行细节展示规则 --- .../src-tauri/src/agent/codex_app_server.rs | 106 ++++++++++++++---- .../src-tauri/src/agent/direct_runtime.rs | 32 +++++- apps/ai-game-creator-shell/src/App.tsx | 25 ++++- .../appSurface/project-development.suite.ts | 46 ++++++++ .../shared-memory/decision-log.md | 4 +- 5 files changed, 184 insertions(+), 29 deletions(-) diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server.rs index de736c993..00066ccfd 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server.rs @@ -31,6 +31,8 @@ const DIRECT_PROJECT_TURN_HARD_TIMEOUT_MS: u64 = 120 * 60 * 1_000; const DIRECT_PROJECT_MCP_OPTIONAL_STARTUP_GRACE_MS: u64 = 120_000; const DIRECT_CODEX_ACTIVITY_EMIT_MIN_INTERVAL: std::time::Duration = std::time::Duration::from_millis(250); +const DIRECT_CODEX_PREPARING_ACTIVITY_EMIT_MIN_INTERVAL: std::time::Duration = + std::time::Duration::from_millis(1200); const DIRECT_CODEX_INTERMEDIATE_TEXT_MAX_CHARS: usize = 240; const DIRECT_CODEX_INTERMEDIATE_TEXT_MIN_INTERVAL: std::time::Duration = std::time::Duration::from_millis(120); @@ -699,9 +701,13 @@ fn should_emit_direct_codex_activity( activity: &'static str, ) -> bool { let now = std::time::Instant::now(); + let min_interval = if activity == "preparing" { + DIRECT_CODEX_PREPARING_ACTIVITY_EMIT_MIN_INTERVAL + } else { + DIRECT_CODEX_ACTIVITY_EMIT_MIN_INTERVAL + }; if last_activity.is_some_and(|(previous, observed_at)| { - previous == activity - && now.saturating_duration_since(observed_at) < DIRECT_CODEX_ACTIVITY_EMIT_MIN_INTERVAL + previous == activity && now.saturating_duration_since(observed_at) < min_interval }) { return false; } @@ -709,6 +715,36 @@ fn should_emit_direct_codex_activity( true } +fn direct_codex_notification_event( + method: &str, + params: &serde_json::Value, + intermediate_text: Option, + safe_activity: Option<&'static str>, +) -> Option { + let (activity, intermediate_text) = match (&intermediate_text, safe_activity) { + (Some(_), Some(activity)) if activity == "preparing" => (Some(activity), None), + _ => (safe_activity, intermediate_text), + }; + if let Some(text) = intermediate_text { + return Some(CodexTurnEvent::IntermediateText(text)); + } + if let Some(activity) = activity { + return Some(CodexTurnEvent::Activity(activity)); + } + match method { + "item/agentMessage/delta" => params + .get("delta") + .and_then(serde_json::Value::as_str) + .filter(|value| !value.trim().is_empty()) + .map(|delta| CodexTurnEvent::AgentMessageDelta(delta.to_string())), + "item/started" | "item/completed" => Some(CodexTurnEvent::Item { + completed: method == "item/completed", + params: params.clone(), + }), + _ => Some(CodexTurnEvent::Terminal(params.clone())), + } +} + fn is_terminal_client_mcp_startup_status(status: Option<&str>) -> bool { matches!(status, Some("ready") | Some("failed") | Some("cancelled")) } @@ -3019,28 +3055,14 @@ async fn read_game_creator_codex_app_server_stdout( continue; } } - let event = if let Some(text) = intermediate_text { - CodexTurnEvent::IntermediateText(text) - } else if let Some(activity) = safe_activity { - CodexTurnEvent::Activity(activity) - } else { - match method { - "item/agentMessage/delta" => { - let Some(delta) = params - .get("delta") - .and_then(serde_json::Value::as_str) - .filter(|value| !value.is_empty()) - else { - continue; - }; - CodexTurnEvent::AgentMessageDelta(delta.to_string()) - } - "item/started" | "item/completed" => CodexTurnEvent::Item { - completed: method == "item/completed", - params, - }, - _ => CodexTurnEvent::Terminal(params), - } + let event = match direct_codex_notification_event( + method, + ¶ms, + intermediate_text, + safe_activity, + ) { + Some(event) => event, + None => continue, }; let sender = if method == "turn/completed" { last_direct_activity_by_turn.remove(&turn_id); @@ -3594,6 +3616,42 @@ mod tests { assert_eq!(direct_codex_item_intermediate_text(&reasoning), None); } + #[test] + fn direct_preparing_notifications_emit_thinking_activity_without_raw_text() { + let reasoning = serde_json::json!({ "delta": "hidden reasoning must not leak" }); + assert!(matches!( + direct_codex_notification_event( + "item/reasoning/textDelta", + &reasoning, + Some("hidden reasoning must not leak".to_string()), + Some("preparing"), + ), + Some(CodexTurnEvent::Activity("preparing")) + )); + + let plan = serde_json::json!({ "explanation": "private plan text must not leak" }); + assert!(matches!( + direct_codex_notification_event( + "turn/plan/updated", + &plan, + Some("private plan text must not leak".to_string()), + Some("preparing"), + ), + Some(CodexTurnEvent::Activity("preparing")) + )); + + let command_output = serde_json::json!({ "delta": "Bearer secret-command-output" }); + assert!(matches!( + direct_codex_notification_event( + "item/commandExecution/outputDelta", + &command_output, + None, + Some("command-exec"), + ), + Some(CodexTurnEvent::Activity("command-exec")) + )); + } + fn test_llm() -> GameCreatorLlmConfig { GameCreatorLlmConfig { api_key: "fixture-secret".to_string(), diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime.rs index a1df967a3..c8f3f2063 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime.rs @@ -3696,6 +3696,17 @@ fn project_direct_codex_accumulated_text( project_direct_codex_visible_text(accumulated_text) } +fn is_direct_codex_item_started_work_detail(value: &str) -> bool { + const PREFIXES: [&str; 5] = [ + "正在执行:", + "正在调用", + "正在修改:", + "正在联网搜索", + "正在整理上下文", + ]; + PREFIXES.iter().any(|prefix| value.starts_with(prefix)) +} + /// Resolve the UI lifecycle status for one DirectProject observation. Only a /// real agent-message delta is `streaming`; plan, reasoning, tool output, and /// item activity remain `running` because they describe work rather than the @@ -3925,7 +3936,9 @@ async fn run_direct_game_creator_turn_inner( emitter.emit(status, None, visible_text); } DirectCodexTurnObservation::IntermediateText(intermediate_text) => { - let visible_text = if stream_enabled { + let visible_text = if stream_enabled + || is_direct_codex_item_started_work_detail(&intermediate_text) + { project_direct_codex_visible_text(&intermediate_text) } else { None @@ -4680,6 +4693,23 @@ mod tests { ); } + #[test] + fn direct_item_started_work_detail_survives_stream_disabled() { + assert!(is_direct_codex_item_started_work_detail( + "正在执行:npm run build" + )); + assert!(is_direct_codex_item_started_work_detail( + "正在调用 agc_read_file:game/index.html" + )); + assert!(is_direct_codex_item_started_work_detail( + "正在修改:game/player.gd" + )); + assert!(!is_direct_codex_item_started_work_detail("阶段性回复")); + assert!(!is_direct_codex_item_started_work_detail( + "hidden reasoning must not leak" + )); + } + #[test] fn direct_observation_status_separates_reply_stream_from_work_activity() { let accumulated = DirectCodexTurnObservation::AccumulatedText("阶段性回复".to_string()); diff --git a/apps/ai-game-creator-shell/src/App.tsx b/apps/ai-game-creator-shell/src/App.tsx index 264e9692f..04f33762a 100644 --- a/apps/ai-game-creator-shell/src/App.tsx +++ b/apps/ai-game-creator-shell/src/App.tsx @@ -285,7 +285,7 @@ function directCodexActivityDetail( case 'request-accepted': return '正在等待陶泥儿开始'; case 'preparing': - return '正在理解需求'; + return '正在思考中'; case 'file-read': return '正在读取文件'; case 'file-write': @@ -347,6 +347,10 @@ function directCodexProcessDetail({ return directCodexActivityDetail(activity, status); } +function isDirectCodexSpecificWorkDetail(text: string) { + return /^(?:正在执行:|正在调用|正在修改:)/u.test(text); +} + function directCodexTransientReplyText({ accumulatedText, status, @@ -633,6 +637,7 @@ export function App({ lastSequence: number; receivedDirectUpdate: boolean; } | null>(null); + const lastDirectCodexActivityRef = useRef(null); const recoveredDirectCodexTurnClaimsRef = useRef(new Set()); const directCodexClaimReleaseOnConversationWriteFailureRef = useRef( new Map(), @@ -659,6 +664,7 @@ export function App({ function resetDirectCodexTurn() { activeDirectCodexTurnRef.current = null; + lastDirectCodexActivityRef.current = null; setDirectCodexProgress(''); setDirectCodexStatus(null); setDirectCodexProcessKey(''); @@ -1316,6 +1322,7 @@ export function App({ const processDetail = directCodexProcessDetail(payload); if (payload.status === 'failed') { activeDirectCodexTurnRef.current = null; + lastDirectCodexActivityRef.current = null; setDirectCodexStatus(payload.status); setDirectCodexProgress(processDetail); setDirectCodexProgressUpdatedAt(updatedAt); @@ -1324,7 +1331,21 @@ export function App({ return; } setDirectCodexStatus(payload.status); - setDirectCodexProgress(processDetail); + const genericActivity = payload.activity ?? null; + const previousActivity = lastDirectCodexActivityRef.current; + setDirectCodexProgress((current) => { + const heartbeatWouldDowngrade = + genericActivity !== null && + !payload.accumulatedText?.trim() && + payload.status === 'running' && + previousActivity === genericActivity && + current !== processDetail && + isDirectCodexSpecificWorkDetail(current); + return heartbeatWouldDowngrade ? current : processDetail; + }); + if (genericActivity !== null) { + lastDirectCodexActivityRef.current = genericActivity; + } setDirectCodexProgressUpdatedAt(updatedAt); const transientReply = directCodexTransientReplyText(payload); if (transientReply !== null) { diff --git a/apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts b/apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts index 7eaf924ca..f890d531b 100644 --- a/apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts +++ b/apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts @@ -5626,6 +5626,25 @@ export function registerProjectSupervisorSurfaceTests() { updatedAt: 1000, }, }); + directTurnUpdateHandler?.({ + payload: { + projectPath, + turnId: firstTurnId, + sequence: 1, + status: 'running', + activity: 'preparing', + updatedAt: 1200, + }, + }); + }); + const thinkingProcessCard = + within(directMessageList).getByLabelText('陶泥儿执行过程'); + expect(within(thinkingProcessCard).getByText('任务执行中')).not.toBeNull(); + expect( + within(thinkingProcessCard).getByLabelText('陶泥儿正在执行的内容') + .textContent, + ).toBe('正在思考中'); + await act(async () => { directTurnUpdateHandler?.({ payload: { projectPath, @@ -5728,6 +5747,33 @@ export function registerProjectSupervisorSurfaceTests() { expect( within(streamingProcessCard).getByText(/正在执行 npm test/u), ).not.toBeNull(); + await act(async () => { + directTurnUpdateHandler?.({ + payload: { + projectPath, + turnId: firstTurnId, + sequence: 5, + status: 'running', + activity: 'command-exec', + accumulatedText: `正在执行:npm run smoke,${'heartbeat 不得覆盖具体命令。'.repeat(12)}`, + updatedAt: 5000, + }, + }); + directTurnUpdateHandler?.({ + payload: { + projectPath, + turnId: firstTurnId, + sequence: 6, + status: 'running', + activity: 'command-exec', + updatedAt: 5100, + }, + }); + }); + expect( + within(streamingProcessCard).getByText(/heartbeat 不得覆盖具体命令/u), + ).not.toBeNull(); + expect(within(streamingProcessCard).queryByText('正在执行命令')).toBeNull(); expect( within(supervisorSurface).getByLabelText('陶泥儿实时回复').textContent, ).toBe('DIRECT_STREAM:先完成正式客户端玩法拆解'); diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index 13e60d322..c894edf5b 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -18,9 +18,9 @@ ## 2026-09-02 Direct 过程卡按回合阶段状态驱动 - 背景:DirectProject 结果卡把工具活动词、中间文本和真实回复增量都当成“实时回复”,标题随最近一次事件跳动;上游常整包返回正文时还叠加合成打字机,用户看到的是行为名而非当前阶段。 -- 决策:Direct 过程卡顶部标题只由 `GameCreatorDirectTurnUpdateStatus` 决定(accepted=需求已接收 / running=任务执行中 / streaming=回复生成中 / finalizing=结果整理中 / completed=回复已生成 / failed=处理失败),小字只展示当前正在执行的具体内容并统一加“正在”前缀;真实回复增量(AccumulatedText)才标记 streaming,计划、推理、工具输出与 Activity 一律 running。生成中的累计回复直接作为 assistant 消息气泡在会话列表中原位更新,不再拼进过程卡;进入 finalizing / completed 时保留完整累计回复直到正式消息接管,失败时清除未完成正文。移除合成打字机回放;工具说明/中间文本不再触发 streaming。展开/收起是同一 `project + clientTurnId` 内的持久状态,内容更新不重置,切换新回合才收起;展开详情的滚动条轨道和角落保持透明。 +- 决策:Direct 过程卡顶部标题只由 `GameCreatorDirectTurnUpdateStatus` 决定(accepted=需求已接收 / running=任务执行中 / streaming=回复生成中 / finalizing=结果整理中 / completed=回复已生成 / failed=处理失败),小字只展示当前正在执行的具体内容并统一加“正在”前缀;真实回复增量(AccumulatedText)才标记 streaming,计划、推理、工具输出与 Activity 一律 running。生成中的累计回复直接作为 assistant 消息气泡在会话列表中原位更新,不再拼进过程卡;进入 finalizing / completed 时保留完整累计回复直到正式消息接管,失败时清除未完成正文。移除合成打字机回放;工具说明/中间文本不再触发 streaming。计划/推理通知收敛为 `preparing` 活动并在界面显示“正在思考中”,原始推理/计划正文不进入 UI,思考期的心跳按 1.2s 限流。命令/文件/工具执行细节(例如“正在执行:<命令>”)与回复流解耦,`stream=false` 时仍展示在过程卡;同一 command-exec 后续无正文的活动心跳不得用通用“正在执行命令”覆盖已展示的具体命令。展开/收起是同一 `project + clientTurnId` 内的持久状态,内容更新不重置,切换新回合才收起;展开详情的滚动条轨道和角落保持透明。 - 影响范围:`apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime.rs` 的 DirectProject observer、`apps/ai-game-creator-shell/src/App.tsx` 的事件投影、`ProjectSupervisorView` 过程卡渲染与对应 AppSurface 回归。 -- 验证方式:Rust 单测证明只有开启流式时的 AccumulatedText 是 streaming;AppSurface 覆盖接受态、running 长文本展开、streaming 正文进入 assistant 气泡且过程卡只显示阶段、同一回合后续 running 不覆盖正文也不收起、失败后清除未完成正文、正式消息接管不重复;样式核对确认展开详情的滚动条轨道与角落透明;AGC typecheck、全量 appSurface、rustfmt、`npm run check:encoding`、`git diff --check` 通过。 +- 验证方式:Rust 单测证明只有开启流式时的 AccumulatedText 是 streaming、preparing 通知只产生 thinking 活动词且不携带原始推理文本、执行细节在 `stream=false` 时仍保留;AppSurface 覆盖接受态、preparing 显示“正在思考中”、running 长文本展开、command-exec 心跳不覆盖具体命令、streaming 正文进入 assistant 气泡且过程卡只显示阶段、同一回合后续 running 不覆盖正文也不收起、失败后清除未完成正文、正式消息接管不重复;样式核对确认展开详情的滚动条轨道与角落透明;AGC typecheck、全量 appSurface、rustfmt、`npm run check:encoding`、`git diff --check` 通过。 - 关联文档:`docs/technical/【技术方案】Direct回合行为审计账本-2026-08-31.md`、分支 `feat/agc-llm-router-official-chain`。 --- From beae1dfe5fd268233c63bc2285b34e4d0527d5f8 Mon Sep 17 00:00:00 2001 From: suzmii Date: Wed, 2 Sep 2026 19:44:57 +0800 Subject: [PATCH 23/43] =?UTF-8?q?=E8=BF=87=E7=A8=8B=E5=8D=A1=E5=B7=A5?= =?UTF-8?q?=E5=85=B7=E6=96=87=E6=A1=88=E6=94=B9=E4=B8=BA=E7=94=A8=E6=88=B7?= =?UTF-8?q?=E8=AF=AD=E4=B9=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - MCP 工具按写入、浏览、读取素材、导入、生成图片等用户动作显示文案 - 写入工具与文件变更统一显示正在写入文件和项目相对路径 - 命令显示具体命令,验证类命令显示正在验证游戏 - 未知工具只显示正在调用工具,不暴露内部工具名和未审核参数 - 工具详情限制路径长度并拒绝绝对路径与上跳路径 - stream=false 时继续保留新的具体工具执行文案 - 补充工具映射、安全边界、命令分类与过程卡心跳回归 - 决策记录同步工具语义文案规则 --- .../src-tauri/src/agent/codex_app_server.rs | 265 ++++++++++++++++-- .../src-tauri/src/agent/direct_runtime.rs | 28 +- apps/ai-game-creator-shell/src/App.tsx | 21 +- .../appSurface/project-development.suite.ts | 31 +- .../shared-memory/decision-log.md | 4 +- 5 files changed, 308 insertions(+), 41 deletions(-) diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server.rs index 00066ccfd..c9372b5ef 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server.rs @@ -544,15 +544,10 @@ fn direct_codex_safe_activity_for_item_value(item: &serde_json::Value) -> &'stat .and_then(serde_json::Value::as_str) .unwrap_or_default(); if item_type == "commandExecution" { - let command = item + if item .get("command") .and_then(serde_json::Value::as_str) - .unwrap_or_default() - .to_ascii_lowercase(); - if command.contains("game.static_smoke") - || command.contains("preview.validate") - || command.contains("verify") - || command.contains("test") + .is_some_and(direct_codex_command_is_game_verification) { return "game-verify"; } @@ -580,6 +575,108 @@ fn direct_codex_safe_activity_for_item_value(item: &serde_json::Value) -> &'stat direct_codex_safe_activity_for_item(item_type) } +fn direct_codex_command_is_game_verification(command: &str) -> bool { + let command = command.to_ascii_lowercase(); + command.contains("game.static_smoke") + || command.contains("preview.validate") + || command.contains("verify") + || command.contains("test") +} + +fn direct_codex_bounded_detail(value: &str, max_chars: usize) -> Option { + let value = value.split_whitespace().collect::>().join(" "); + if value.is_empty() { + return None; + } + Some(value.chars().take(max_chars).collect()) +} + +fn direct_codex_project_path_detail(item: &serde_json::Value, pointer: &str) -> Option { + let value = item.pointer(pointer)?.as_str()?; + if value.is_empty() { + return None; + } + let path = std::path::Path::new(value); + if path.is_absolute() + || path + .components() + .any(|component| matches!(component, std::path::Component::ParentDir)) + { + return None; + } + direct_codex_bounded_detail(value, 120) +} + +fn direct_codex_mcp_tool_intermediate_text(item: &serde_json::Value) -> String { + let arguments = item + .get("arguments") + .cloned() + .unwrap_or(serde_json::Value::Null); + let optional_path = |pointer: &str| { + direct_codex_project_path_detail( + &serde_json::json!({ "arguments": arguments.clone(), "tool": "" }), + pointer, + ) + }; + let optional_text = |field: &str, max_chars: usize| { + arguments + .get(field) + .and_then(serde_json::Value::as_str) + .and_then(|value| direct_codex_bounded_detail(value, max_chars)) + }; + match item.get("tool").and_then(serde_json::Value::as_str) { + Some("taonier_prepare_game_art") => "正在准备美术素材".to_string(), + Some("agc_generate_image") => match direct_codex_project_path_detail( + &serde_json::json!({ "arguments": arguments.clone() }), + "/arguments/outputPath", + ) { + Some(path) => format!("正在生成图片:{path}"), + None => "正在生成图片".to_string(), + }, + Some("agc_edit_image") => match optional_text("assetName", 80) { + Some(name) => format!("正在编辑图片:{name}"), + None => "正在编辑图片".to_string(), + }, + Some("agc_list_registered_assets") => match optional_text("query", 80) { + Some(query) => format!("正在读取素材库:{query}"), + None => "正在读取素材库".to_string(), + }, + Some("agc_list_project_files") => { + match optional_path("/arguments/path").or_else(|| optional_text("query", 80)) { + Some(detail) => format!("正在浏览项目文件:{detail}"), + None => "正在浏览项目文件".to_string(), + } + } + Some("agc_write_file") => match optional_path("/arguments/path") { + Some(path) => format!("正在写入文件:{path}"), + None => "正在写入文件".to_string(), + }, + Some("agc_list_account_assets") => match optional_text("query", 80) { + Some(query) => format!("正在读取账户素材:{query}"), + None => "正在读取账户素材".to_string(), + }, + Some("agc_import_account_assets") => { + let local_path_count = arguments + .get("localPaths") + .and_then(serde_json::Value::as_array) + .map(Vec::len) + .filter(|count| *count > 0); + match local_path_count { + Some(count) => format!("正在导入素材:{count} 项"), + None => "正在导入素材".to_string(), + } + } + Some("agc_create_or_derive_resource") => "正在创建素材资源".to_string(), + Some("agc_remove_background") => "正在去除图片背景".to_string(), + Some("agc_browser_playtest") => "正在试玩游戏".to_string(), + Some("agc_web_search") => match optional_text("query", 80) { + Some(query) => format!("正在搜索资料:{query}"), + None => "正在搜索资料".to_string(), + }, + _ => "正在调用工具".to_string(), + } +} + /// Project a started Codex item into a short user-visible progress line. /// Codex app-server 0.147/0.149 only pushes structural item/started events /// (with the concrete command/tool/path) while tools run; it does not push @@ -592,28 +689,22 @@ fn direct_codex_item_intermediate_text(item: &serde_json::Value) -> Option { - let tool = item - .get("tool") - .and_then(serde_json::Value::as_str) - .filter(|value| !value.trim().is_empty()) - .unwrap_or("工具"); - match item - .pointer("/arguments/path") - .and_then(serde_json::Value::as_str) - .filter(|value| !value.trim().is_empty()) - { - Some(path) => format!("正在调用 {tool}:{path}"), - None => format!("正在调用 {tool}"), - } - } + "mcpToolCall" => direct_codex_mcp_tool_intermediate_text(item), "commandExecution" => { let command = item .get("command") .and_then(serde_json::Value::as_str) .filter(|value| !value.trim().is_empty()); match command { - Some(command) => format!("正在执行:{command}"), + Some(command) => { + let command = direct_codex_bounded_detail(command, 120) + .unwrap_or_else(|| "命令".to_string()); + if direct_codex_command_is_game_verification(&command) { + format!("正在验证游戏:{command}") + } else { + format!("正在执行命令:{command}") + } + } None => "正在执行命令".to_string(), } } @@ -624,11 +715,11 @@ fn direct_codex_item_intermediate_text(item: &serde_json::Value) -> Option format!("正在修改:{path}"), - None => "正在修改项目文件".to_string(), + Some(path) => format!("正在写入文件:{path}"), + None => "正在写入文件".to_string(), } } - "webSearch" => "正在联网搜索".to_string(), + "webSearch" => "正在搜索资料".to_string(), "contextCompaction" => "正在整理上下文".to_string(), _ => return None, }; @@ -3584,7 +3675,7 @@ mod tests { }); assert_eq!( direct_codex_item_intermediate_text(&mcp).as_deref(), - Some("正在调用 agc_write_file:game/index.html") + Some("正在写入文件:game/index.html") ); let command = serde_json::json!({ @@ -3593,7 +3684,7 @@ mod tests { }); assert_eq!( direct_codex_item_intermediate_text(&command).as_deref(), - Some("正在执行:npm run build") + Some("正在执行命令:npm run build") ); assert_eq!( direct_codex_safe_activity_for_item_value(&serde_json::json!({ @@ -3602,6 +3693,14 @@ mod tests { })), "game-verify" ); + assert_eq!( + direct_codex_item_intermediate_text(&serde_json::json!({ + "type": "commandExecution", + "command": "preview.validate" + })) + .as_deref(), + Some("正在验证游戏:preview.validate") + ); let file_change = serde_json::json!({ "type": "fileChange", @@ -3609,13 +3708,121 @@ mod tests { }); assert_eq!( direct_codex_item_intermediate_text(&file_change).as_deref(), - Some("正在修改:game/player.gd") + Some("正在写入文件:game/player.gd") ); let reasoning = serde_json::json!({ "type": "reasoning" }); assert_eq!(direct_codex_item_intermediate_text(&reasoning), None); } + #[test] + fn direct_mcp_tool_details_use_user_facing_work_labels() { + let detail = |tool: &str, arguments: serde_json::Value| { + direct_codex_item_intermediate_text(&serde_json::json!({ + "type": "mcpToolCall", + "tool": tool, + "arguments": arguments + })) + .expect("mcp tool detail") + }; + + assert_eq!( + detail("taonier_prepare_game_art", serde_json::json!({})), + "正在准备美术素材" + ); + assert_eq!( + detail( + "agc_generate_image", + serde_json::json!({ "outputPath": "assets/hero.png" }) + ), + "正在生成图片:assets/hero.png" + ); + assert_eq!( + detail( + "agc_edit_image", + serde_json::json!({ "assetName": "主角头像" }) + ), + "正在编辑图片:主角头像" + ); + assert_eq!( + detail( + "agc_list_registered_assets", + serde_json::json!({ "query": "hero" }) + ), + "正在读取素材库:hero" + ); + assert_eq!( + detail( + "agc_list_project_files", + serde_json::json!({ "path": "assets" }) + ), + "正在浏览项目文件:assets" + ); + assert_eq!( + detail("agc_list_account_assets", serde_json::json!({})), + "正在读取账户素材" + ); + assert_eq!( + detail( + "agc_import_account_assets", + serde_json::json!({ "localPaths": ["assets/a.png", "assets/b.png"] }) + ), + "正在导入素材:2 项" + ); + assert_eq!( + detail("agc_create_or_derive_resource", serde_json::json!({})), + "正在创建素材资源" + ); + assert_eq!( + detail("agc_remove_background", serde_json::json!({})), + "正在去除图片背景" + ); + assert_eq!( + detail("agc_browser_playtest", serde_json::json!({})), + "正在试玩游戏" + ); + assert_eq!( + detail( + "agc_web_search", + serde_json::json!({ "query": "tauri webview" }) + ), + "正在搜索资料:tauri webview" + ); + } + + #[test] + fn direct_mcp_tool_details_do_not_expose_unreviewed_names_or_unsafe_paths() { + let unknown = direct_codex_item_intermediate_text(&serde_json::json!({ + "type": "mcpToolCall", + "tool": "SECRET_TOOL_/private/project", + "arguments": { "prompt": "Bearer secret-token /private/project" } + })) + .expect("safe unknown-tool detail"); + assert_eq!(unknown, "正在调用工具"); + assert!(!unknown.contains("SECRET_TOOL")); + assert!(!unknown.contains("/private/project")); + assert!(!unknown.contains("secret-token")); + + assert_eq!( + direct_codex_item_intermediate_text(&serde_json::json!({ + "type": "mcpToolCall", + "tool": "agc_write_file", + "arguments": { "path": "C:/Users/private/secret.txt" } + })) + .as_deref(), + Some("正在写入文件") + ); + assert_eq!( + direct_codex_item_intermediate_text(&serde_json::json!({ + "type": "mcpToolCall", + "tool": "agc_write_file", + "arguments": { "path": "../outside.txt" } + })) + .as_deref(), + Some("正在写入文件") + ); + } + #[test] fn direct_preparing_notifications_emit_thinking_activity_without_raw_text() { let reasoning = serde_json::json!({ "delta": "hidden reasoning must not leak" }); diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime.rs index c8f3f2063..7915b3a98 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime.rs @@ -3697,12 +3697,23 @@ fn project_direct_codex_accumulated_text( } fn is_direct_codex_item_started_work_detail(value: &str) -> bool { - const PREFIXES: [&str; 5] = [ - "正在执行:", - "正在调用", - "正在修改:", - "正在联网搜索", + const PREFIXES: [&str; 16] = [ + "正在写入文件:", + "正在浏览项目文件", + "正在读取素材库", + "正在读取账户素材", + "正在导入素材", + "正在生成图片", + "正在编辑图片", + "正在准备美术素材", + "正在创建素材资源", + "正在去除图片背景", + "正在试玩游戏", + "正在搜索资料:", + "正在执行命令:", + "正在验证游戏:", "正在整理上下文", + "正在调用工具", ]; PREFIXES.iter().any(|prefix| value.starts_with(prefix)) } @@ -4696,14 +4707,15 @@ mod tests { #[test] fn direct_item_started_work_detail_survives_stream_disabled() { assert!(is_direct_codex_item_started_work_detail( - "正在执行:npm run build" + "正在执行命令:npm run build" )); assert!(is_direct_codex_item_started_work_detail( - "正在调用 agc_read_file:game/index.html" + "正在浏览项目文件:game/index.html" )); assert!(is_direct_codex_item_started_work_detail( - "正在修改:game/player.gd" + "正在写入文件:game/player.gd" )); + assert!(is_direct_codex_item_started_work_detail("正在调用工具")); assert!(!is_direct_codex_item_started_work_detail("阶段性回复")); assert!(!is_direct_codex_item_started_work_detail( "hidden reasoning must not leak" diff --git a/apps/ai-game-creator-shell/src/App.tsx b/apps/ai-game-creator-shell/src/App.tsx index 04f33762a..2030ac659 100644 --- a/apps/ai-game-creator-shell/src/App.tsx +++ b/apps/ai-game-creator-shell/src/App.tsx @@ -319,6 +319,23 @@ function directCodexActivityDetail( } } +const DIRECT_CODEX_SPECIFIC_WORK_DETAIL_PREFIXES = [ + '正在写入文件:', + '正在浏览项目文件', + '正在读取素材库', + '正在读取账户素材', + '正在导入素材', + '正在生成图片', + '正在编辑图片', + '正在准备美术素材', + '正在创建素材资源', + '正在去除图片背景', + '正在试玩游戏', + '正在搜索资料:', + '正在执行命令:', + '正在验证游戏:', +] as const; + function directCodexProcessDetail({ accumulatedText, activity, @@ -348,7 +365,9 @@ function directCodexProcessDetail({ } function isDirectCodexSpecificWorkDetail(text: string) { - return /^(?:正在执行:|正在调用|正在修改:)/u.test(text); + return DIRECT_CODEX_SPECIFIC_WORK_DETAIL_PREFIXES.some((prefix) => + text.startsWith(prefix), + ); } function directCodexTransientReplyText({ diff --git a/apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts b/apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts index f890d531b..79815398b 100644 --- a/apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts +++ b/apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts @@ -5755,7 +5755,7 @@ export function registerProjectSupervisorSurfaceTests() { sequence: 5, status: 'running', activity: 'command-exec', - accumulatedText: `正在执行:npm run smoke,${'heartbeat 不得覆盖具体命令。'.repeat(12)}`, + accumulatedText: `正在执行命令:npm run smoke,${'heartbeat 不得覆盖具体命令。'.repeat(12)}`, updatedAt: 5000, }, }); @@ -5774,6 +5774,35 @@ export function registerProjectSupervisorSurfaceTests() { within(streamingProcessCard).getByText(/heartbeat 不得覆盖具体命令/u), ).not.toBeNull(); expect(within(streamingProcessCard).queryByText('正在执行命令')).toBeNull(); + await act(async () => { + directTurnUpdateHandler?.({ + payload: { + projectPath, + turnId: firstTurnId, + sequence: 7, + status: 'running', + activity: 'controlled-tool', + accumulatedText: `正在写入文件:game/index.html,${'写入详情需要保持展开状态。'.repeat(12)}`, + updatedAt: 5200, + }, + }); + directTurnUpdateHandler?.({ + payload: { + projectPath, + turnId: firstTurnId, + sequence: 8, + status: 'running', + activity: 'controlled-tool', + updatedAt: 5300, + }, + }); + }); + expect( + within(streamingProcessCard).getByText( + /正在写入文件:game\/index\.html/u, + ), + ).not.toBeNull(); + expect(within(streamingProcessCard).queryByText('正在调用工具')).toBeNull(); expect( within(supervisorSurface).getByLabelText('陶泥儿实时回复').textContent, ).toBe('DIRECT_STREAM:先完成正式客户端玩法拆解'); diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index 15be79fd2..d3fe15e0f 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -18,9 +18,9 @@ ## 2026-09-02 Direct 过程卡按回合阶段状态驱动 - 背景:DirectProject 结果卡把工具活动词、中间文本和真实回复增量都当成“实时回复”,标题随最近一次事件跳动;上游常整包返回正文时还叠加合成打字机,用户看到的是行为名而非当前阶段。 -- 决策:Direct 过程卡顶部标题只由 `GameCreatorDirectTurnUpdateStatus` 决定(accepted=需求已接收 / running=任务执行中 / streaming=回复生成中 / finalizing=结果整理中 / completed=回复已生成 / failed=处理失败),小字只展示当前正在执行的具体内容并统一加“正在”前缀;真实回复增量(AccumulatedText)才标记 streaming,计划、推理、工具输出与 Activity 一律 running。生成中的累计回复直接作为 assistant 消息气泡在会话列表中原位更新,不再拼进过程卡;进入 finalizing / completed 时保留完整累计回复直到正式消息接管,失败时清除未完成正文。移除合成打字机回放;工具说明/中间文本不再触发 streaming。计划/推理通知收敛为 `preparing` 活动并在界面显示“正在思考中”,原始推理/计划正文不进入 UI,思考期的心跳按 1.2s 限流。命令/文件/工具执行细节(例如“正在执行:<命令>”)与回复流解耦,`stream=false` 时仍展示在过程卡;同一 command-exec 后续无正文的活动心跳不得用通用“正在执行命令”覆盖已展示的具体命令。展开/收起是同一 `project + clientTurnId` 内的持久状态,内容更新不重置,切换新回合才收起;展开详情的滚动条轨道和角落保持透明。 +- 决策:Direct 过程卡顶部标题只由 `GameCreatorDirectTurnUpdateStatus` 决定(accepted=需求已接收 / running=任务执行中 / streaming=回复生成中 / finalizing=结果整理中 / completed=回复已生成 / failed=处理失败),小字只展示当前正在执行的具体内容并统一加“正在”前缀;真实回复增量(AccumulatedText)才标记 streaming,计划、推理、工具输出与 Activity 一律 running。生成中的累计回复直接作为 assistant 消息气泡在会话列表中原位更新,不再拼进过程卡;进入 finalizing / completed 时保留完整累计回复直到正式消息接管,失败时清除未完成正文。移除合成打字机回放;工具说明/中间文本不再触发 streaming。计划/推理通知收敛为 `preparing` 活动并在界面显示“正在思考中”,原始推理/计划正文不进入 UI,思考期的心跳按 1.2s 限流。命令/文件/工具执行细节与回复流解耦,`stream=false` 时仍展示在过程卡;MCP 工具按用户语义显示(例如 `agc_write_file` 为“正在写入文件:<项目相对路径>”、图片/素材/搜索/试玩分别显示生成、导入、搜索、试玩等动作),未知工具只显示“正在调用工具”不暴露内部工具名;命令显示“正在执行命令:<命令>”,验证类命令显示“正在验证游戏:<命令>”。同一活动后续无正文的心跳不得用通用文案覆盖已展示的具体工作。展开/收起是同一 `project + clientTurnId` 内的持久状态,内容更新不重置,切换新回合才收起;展开详情的滚动条轨道和角落保持透明。 - 影响范围:`apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime.rs` 的 DirectProject observer、`apps/ai-game-creator-shell/src/App.tsx` 的事件投影、`ProjectSupervisorView` 过程卡渲染与对应 AppSurface 回归。 -- 验证方式:Rust 单测证明只有开启流式时的 AccumulatedText 是 streaming、preparing 通知只产生 thinking 活动词且不携带原始推理文本、执行细节在 `stream=false` 时仍保留;AppSurface 覆盖接受态、preparing 显示“正在思考中”、running 长文本展开、command-exec 心跳不覆盖具体命令、streaming 正文进入 assistant 气泡且过程卡只显示阶段、同一回合后续 running 不覆盖正文也不收起、失败后清除未完成正文、正式消息接管不重复;样式核对确认展开详情的滚动条轨道与角落透明;AGC typecheck、全量 appSurface、rustfmt、`npm run check:encoding`、`git diff --check` 通过。 +- 验证方式:Rust 单测证明只有开启流式时的 AccumulatedText 是 streaming、preparing 通知只产生 thinking 活动词且不携带原始推理文本、执行细节在 `stream=false` 时仍保留,并覆盖全部 AGC MCP 工具语义、未知工具不泄漏、绝对路径 / 上跳路径不展示;AppSurface 覆盖接受态、preparing 显示“正在思考中”、running 长文本展开、command-exec 与写文件心跳不覆盖具体工作、streaming 正文进入 assistant 气泡且过程卡只显示阶段、同一回合后续 running 不覆盖正文也不收起、失败后清除未完成正文、正式消息接管不重复;样式核对确认展开详情的滚动条轨道与角落透明;AGC typecheck、全量 appSurface、rustfmt、`npm run check:encoding`、`git diff --check` 通过。 - 关联文档:`docs/technical/【技术方案】Direct回合行为审计账本-2026-08-31.md`、分支 `feat/agc-llm-router-official-chain`。 --- From 4b3d27e3d3b4b3574de158358c0385bdbe0b8849 Mon Sep 17 00:00:00 2001 From: suzmii Date: Thu, 3 Sep 2026 10:24:36 +0800 Subject: [PATCH 24/43] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=20AGC=20Router=20?= =?UTF-8?q?=E8=AE=A1=E8=B4=B9=E4=B8=8E=E7=99=BB=E5=BD=95=E5=AE=89=E5=85=A8?= =?UTF-8?q?=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 绑定 LLM 幂等键与请求指纹,阻止跨请求免扣费 放宽登录对 Router 控制面的依赖并迁移 provisioning secret 加强 AGC 配置文件校验与 SSE UTF-8 处理 同步环境配置、架构文档和回归测试 --- .env.example | 9 +- .../src-tauri/src/config.rs | 5 +- .../src-tauri/src/tests/provider.rs | 1 + deploy/env/api-server.env.example | 1 + ...】server-rs与SpacetimeDB数据契约-2026-05-15.md | 1 + ...构】外部OpenAPI与APIKey接入方案-2026-06-19.md | 5 +- server-rs/crates/api-server/src/config.rs | 7 + .../api-server/src/external_api_keys.rs | 91 ++++++++++--- server-rs/crates/api-server/src/llm/mod.rs | 121 ++++++++++++++++-- 9 files changed, 205 insertions(+), 36 deletions(-) diff --git a/.env.example b/.env.example index e03bafae7..d8988060b 100644 --- a/.env.example +++ b/.env.example @@ -8,11 +8,10 @@ LLM_BASE_URL="https://api.vectorengine.cn/v1" # but it should not be relied on by browser code. LLM_API_KEY="" -# Temporary server-private AGC Router fallback until Router provisioning exists. -# Never expose this value to AGC clients or browser code. -GENARRATIVE_AGC_ROUTER_FALLBACK_API_KEY="" -GENARRATIVE_AGC_ROUTER_FALLBACK_API_KEY_FILE="" -GENARRATIVE_AGC_ROUTER_FALLBACK_ACCOUNT_ID="server-fallback" +# Router account provisioning secret (server-side only). Prefer the protected +# file form in production; never expose either value to clients or commit it. +GENARRATIVE_LLM_ROUTER_PROVISIONING_SECRET="" +GENARRATIVE_LLM_ROUTER_PROVISIONING_SECRET_FILE="" # Optional frontend override for the local proxy path. VITE_LLM_PROXY_BASE_URL="/api/llm" diff --git a/apps/ai-game-creator-shell/src-tauri/src/config.rs b/apps/ai-game-creator-shell/src-tauri/src/config.rs index fdec6a901..86f74f3e7 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/config.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/config.rs @@ -3374,11 +3374,10 @@ pub(crate) fn scrub_locked_game_creator_config_file(config: &mut GameCreatorAppC } pub(crate) fn migrate_legacy_game_creator_agent_mode(path: &Path) -> Result<(), String> { - if !path.is_file() { + if !validate_game_creator_config_file_entry(path)? { return Ok(()); } - let content = std::fs::read_to_string(path) - .map_err(|error| format!("读取客户端配置失败:{}: {error}", path.display()))?; + let content = read_game_creator_private_file_to_string(path, "客户端配置文件", 256 * 1024)?; let mut config = serde_json::from_str::(&content) .map_err(|error| format!("解析客户端配置失败:{}: {error}", path.display()))?; let mut changed = false; diff --git a/apps/ai-game-creator-shell/src-tauri/src/tests/provider.rs b/apps/ai-game-creator-shell/src-tauri/src/tests/provider.rs index 265c01415..6175d5e76 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/tests/provider.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/tests/provider.rs @@ -519,6 +519,7 @@ async fn chat_with_game_creator_role_agent_stream_falls_back_once_before_first_d "baseUrl": {base_url:?}, "model": "art-chat-model", "apiKind": "openai_chat", + "webSearchEnabled": false, "maxRetries": 0 }} }} diff --git a/deploy/env/api-server.env.example b/deploy/env/api-server.env.example index 8517ca561..dd543cd4f 100644 --- a/deploy/env/api-server.env.example +++ b/deploy/env/api-server.env.example @@ -84,6 +84,7 @@ GENARRATIVE_LLM_API_KEY= GENARRATIVE_LLM_MODEL=gpt-5.4-mini # LLM Router 正式账号链路:production 固定使用官方地址/模型;管理员 Token 只读受保护文件。 GENARRATIVE_LLM_ROUTER_BASE_URL=https://router.genarrative.world/v1 +GENARRATIVE_LLM_ROUTER_PROVISIONING_SECRET_FILE=/etc/genarrative/secrets/llm-router-provisioning.secret GENARRATIVE_LLM_ROUTER_API_KEY_ENCRYPTION_SECRET_FILE=/etc/genarrative/secrets/llm-router-api-key-encryption.secret GENARRATIVE_LLM_ROUTER_ADMIN_TOKEN_FILE=/etc/genarrative/secrets/llm-router-admin.token VECTOR_ENGINE_BASE_URL=https://api.vectorengine.cn diff --git a/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md b/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md index 41662101e..d081c6249 100644 --- a/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md +++ b/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md @@ -1373,3 +1373,4 @@ RPG 创作入口的配置 ID 是 `rpg`,当前 `visible=true`、`open=true`; - 源码:`server-rs/crates/spacetime-module/src/visual_novel.rs` - 说明:视觉小说公开 source 投影,只暴露 `publication_status = published` 的作品卡片字段,不把完整 `draft` 暴露给公开列表订阅;统一公开列表 / 详情主路径通过 `public_work_gallery_entry` / `public_work_detail_entry` 消费该 view 并映射成跨玩法契约。个人历史、详情、运行态和发布仍按原有 procedure / reducer 路径处理。 - 字段变更:`visible` 控制是否进入公开列表 / 详情,新作品默认 `false`;旧迁移数据由 `migration.rs` 按历史公开默认补 `true`。 +> 2026-09-03 修订:认证成功后的 Router provisioning 改为异步尽力修复,不再阻塞主站登录;LLM 热路径只读取本地已完成的账号密钥,控制面不可用时请求失败关闭。provisioning secret 仅从部署侧受保护环境变量或 secret file 读取,不再内置源码常量。 diff --git a/docs/【后端架构】外部OpenAPI与APIKey接入方案-2026-06-19.md b/docs/【后端架构】外部OpenAPI与APIKey接入方案-2026-06-19.md index d67715fc0..586d46abb 100644 --- a/docs/【后端架构】外部OpenAPI与APIKey接入方案-2026-06-19.md +++ b/docs/【后端架构】外部OpenAPI与APIKey接入方案-2026-06-19.md @@ -215,7 +215,7 @@ SpacetimeDB procedure: ### LLM Router 账号 Key 与 Router 代理 -普通 AGC 发行版不把 Router 当作客户端可配置 Provider,也不把 Router API Key 下发到桌面端。注册成功视为账号已有可用余额;每次密码、手机号或微信认证成功后,api-server 都必须幂等准备该用户对应的 Router 账号和 API Key,只有准备成功后才发放登录会话,正式记录使用 `purpose=llm-router` 并保存服务端加密凭据。当前按 New API 管理接口执行正式 provisioning:由于 `username`、`password`、`display_name` 均限制 20 个字符,用户名固定为 `agc_user_` 加 11 位 URL-safe SHA-256 短码,密码为基于完整 owner `user_id` 与固定版本 provisioning secret 稳定派生的 20 位 hex,展示名与短用户名一致;完整 owner `user_id` 写入 New API 用户 `remark`,本地 `llm_router_account.owner_user_id` 仍是平台权威映射。服务端先查询远端用户:已存在则直接登录,不重复注册;确认不存在时才由管理员创建普通用户,查询用户 ID,设置 `remark=<完整 owner user_id>` 与用户分组 `taonier`,再登录、查询并复用固定标识 `agc_auto_generate` 的 Token(Token/API Key 固定使用 `default` 分组;已有固定 Token 若分组不是 `default`,登录恢复时先通过 Token 更新接口纠正),签发 API Key。每次新建或准备 API Key 时,服务端在签发前查询该 Router 用户的固定套餐 `plan_id=1`;无 active 订阅、订阅已过期或剩余时间不超过 24 小时时调用管理员订阅接口新建一条订阅,剩余超过 24 小时则复用现有订阅。订阅查询/创建只使用 api-server 私有管理员 Token,不进入客户端或 Router Key;检查锚点是账号认证成功、显式 Router Key 准备接口和新 Key provisioning,不放在 Responses 流式 chunk 中。由于 Router 公共而各部署数据库独立,所有能操作同一 Router 的部署必须使用相同的 provisioning secret。若任一步外部结果不确定,记录进入 reconciliation 状态,禁止重复注册;本地 API Key 写入失败则保留 `key_issued` 状态并用确定的 key id 重试落库。Router 密文加密优先使用 `GENARRATIVE_LLM_ROUTER_API_KEY_ENCRYPTION_SECRET`;缺省时使用带域分离的 `GENARRATIVE_JWT_SECRET` 派生密钥。Router Key 的明文只在 api-server 进程内短暂存在,`/api/profile/api-keys/llm-router` 只返回安全元数据;普通 External Editor Key 仍沿用创建接口明文只显示一次的正式链路。后续请求链路固定为: +普通 AGC 发行版不把 Router 当作客户端可配置 Provider,也不把 Router API Key 下发到桌面端。注册成功视为账号已有可用余额;认证成功后,api-server 异步尽力准备该用户对应的 Router 账号和 API Key,Router 控制面故障不得阻塞主站登录;LLM 请求解析阶段仍要求本地存在合法的已完成 provisioning 凭据,正式记录使用 `purpose=llm-router` 并保存服务端加密凭据。当前按 New API 管理接口执行正式 provisioning:由于 `username`、`password`、`display_name` 均限制 20 个字符,用户名固定为 `agc_user_` 加 11 位 URL-safe SHA-256 短码,密码为基于完整 owner `user_id` 与部署侧受保护 provisioning secret 稳定派生的 20 位 hex,展示名与短用户名一致;完整 owner `user_id` 写入 New API 用户 `remark`,本地 `llm_router_account.owner_user_id` 仍是平台权威映射。服务端先查询远端用户:已存在则直接登录,不重复注册;确认不存在时才由管理员创建普通用户,查询用户 ID,设置 `remark=<完整 owner user_id>` 与用户分组 `taonier`,再登录、查询并复用固定标识 `agc_auto_generate` 的 Token(Token/API Key 固定使用 `default` 分组;已有固定 Token 若分组不是 `default`,登录恢复时先通过 Token 更新接口纠正),签发 API Key。每次新建或准备 API Key 时,服务端在签发前查询该 Router 用户的固定套餐 `plan_id=1`;无 active 订阅、订阅已过期或剩余时间不超过 24 小时时调用管理员订阅接口新建一条订阅,剩余超过 24 小时则复用现有订阅。订阅查询/创建只使用 api-server 私有管理员 Token,不进入客户端或 Router Key;检查锚点是显式 Router Key 准备接口和新 Key provisioning,不放在 Responses 流式 chunk 中。由于 Router 公共而各部署数据库独立,所有能操作同一 Router 的部署必须使用相同的 provisioning secret。若任一步外部结果不确定,记录进入 reconciliation 状态,禁止重复注册;本地 API Key 写入失败则保留 `key_issued` 状态并用确定的 key id 重试落库。Router 密文加密优先使用 `GENARRATIVE_LLM_ROUTER_API_KEY_ENCRYPTION_SECRET`;缺省时使用带域分离的 `GENARRATIVE_JWT_SECRET` 派生密钥。Router Key 的明文只在 api-server 进程内短暂存在,`/api/profile/api-keys/llm-router` 只返回安全元数据;普通 External Editor Key 仍沿用创建接口明文只显示一次的正式链路。后续请求链路固定为: ```text AGC loopback Provider Proxy(Bearer=平台 access token) @@ -224,7 +224,7 @@ AGC loopback Provider Proxy(Bearer=平台 access token) -> POST https://router.genarrative.world/v1/responses(Bearer=) ``` -`/api/llm/responses` 强制覆盖 `model=gpt-5.6-sol`,透传 Responses JSON/SSE 响应;Router 成功返回后按 usage 以临时规则“每开始 1000 token 扣 1 泥点、至少 1 点”写入钱包流水,ledger id 由幂等键(缺省为请求 ID)稳定派生,Router 失败不扣费。Router 明确返回 `401/403` 时,服务端将该用途 Key 标记撤销;网络超时或 provisioning 未取得确定响应时不自动重试。客户端不保存 Router Key,也不把它放进 argv、环境变量、manifest、trace、聊天或普通 IPC。AGC 的运行状态接口与 `/llm-status`、`/llm-routes` 只返回登录/账号凭据状态、官方路由锁定状态和运行参数,不返回 Router 地址、模型、协议名称或任何凭据字段。 +`/api/llm/responses` 强制覆盖 `model=gpt-5.6-sol`,透传 Responses JSON/SSE 响应;Router 成功返回后按 usage 以临时规则“每开始 10,000 token 扣 1 泥点、至少 1 点”写入钱包流水,ledger id 由端点、客户端幂等键和请求指纹稳定派生,Router 失败不扣费。Router 明确返回 `401/403` 时,服务端将该用途 Key 标记撤销;网络超时或 provisioning 未取得确定响应时不自动重试。客户端不保存 Router Key,也不把它放进 argv、环境变量、manifest、trace、聊天或普通 IPC。AGC 的运行状态接口与 `/llm-status`、`/llm-routes` 只返回登录/账号凭据状态、官方路由锁定状态和运行参数,不返回 Router 地址、模型、协议名称或任何凭据字段。 Windows 私有路径由同一套正式准备入口复用:AGC 自有 AppData、凭据目录和 `.agent` 运行态使用 managed 范围;原生文件选择器明确选中的项目根/文件使用 user-selected 范围。两类入口在发现 owner/DACL 权限不足时均允许一次性 UAC helper;helper 只接管严格复核后的普通文件/目录,并写入当前 TokenUser owner、禁止继承且仅含当前用户 ACE 的 DACL。reparse/symlink、非普通对象、祖先类型冲突和未经过正式选择/项目根入口的路径仍失败关闭。 @@ -291,3 +291,4 @@ docs/openapi/genarrative-external-v1.openapi.json - 外部项目接口覆盖当前已有项目管理操作:项目列表、最近项目、创建、读取、重命名、删除和默认画布保存。 - 外部素材生成接口覆盖当前已有编辑器素材操作:生图、重绘 / 调整、规范图生成、宣发素材生成、图标素材生成与拆分、UI 设计图生成与拆分、角色动画、视频、音效和背景音乐。 - 修改 SpacetimeDB schema 后运行 `npm run spacetime:generate` 与 `npm run check:spacetime-schema`。 +> 2026-09-03 修订:认证成功后的 Router provisioning 改为异步尽力修复,不再阻塞主站登录;LLM 请求只使用本地已完成的账号密钥。LLM Router 计费按每开始 10,000 token 至少 1 点结算,客户端幂等键同时绑定端点与规范化请求指纹,禁止跨请求复用账本。账号密码派生根仅从部署侧受保护 secret/file 读取。 diff --git a/server-rs/crates/api-server/src/config.rs b/server-rs/crates/api-server/src/config.rs index e6cd3c7eb..39da78b47 100644 --- a/server-rs/crates/api-server/src/config.rs +++ b/server-rs/crates/api-server/src/config.rs @@ -186,6 +186,8 @@ pub struct AppConfig { /// LLM Router 账号级代理固定配置;普通客户端不能覆盖这些值。 pub llm_router_base_url: String, pub llm_router_model: String, + /// Router 用户密码派生根,仅从部署侧 secret/env/file 读取。 + pub llm_router_provisioning_secret: Option, pub llm_router_api_key_encryption_secret: Option, pub llm_router_admin_token: Option, pub dashscope_base_url: String, @@ -492,6 +494,7 @@ impl Default for AppConfig { llm_retry_backoff_ms: DEFAULT_RETRY_BACKOFF_MS, llm_router_base_url: OFFICIAL_LLM_ROUTER_BASE_URL.to_string(), llm_router_model: OFFICIAL_LLM_ROUTER_MODEL.to_string(), + llm_router_provisioning_secret: None, llm_router_api_key_encryption_secret: None, llm_router_admin_token: None, dashscope_base_url: "https://dashscope.aliyuncs.com/api/v1".to_string(), @@ -1209,6 +1212,10 @@ impl AppConfig { config.llm_retry_backoff_ms = llm_retry_backoff_ms; } + config.llm_router_provisioning_secret = read_secret_env_or_file( + &["GENARRATIVE_LLM_ROUTER_PROVISIONING_SECRET"], + &["GENARRATIVE_LLM_ROUTER_PROVISIONING_SECRET_FILE"], + ); config.llm_router_api_key_encryption_secret = read_secret_env_or_file( &["GENARRATIVE_LLM_ROUTER_API_KEY_ENCRYPTION_SECRET"], &["GENARRATIVE_LLM_ROUTER_API_KEY_ENCRYPTION_SECRET_FILE"], diff --git a/server-rs/crates/api-server/src/external_api_keys.rs b/server-rs/crates/api-server/src/external_api_keys.rs index a3c5492c5..bfba95732 100644 --- a/server-rs/crates/api-server/src/external_api_keys.rs +++ b/server-rs/crates/api-server/src/external_api_keys.rs @@ -56,11 +56,6 @@ const EXTERNAL_API_KEY_PURPOSE_EXTERNAL_EDITOR: &str = "external-editor"; const EXTERNAL_API_KEY_PURPOSE_LLM_ROUTER: &str = "llm-router"; const LLM_ROUTER_RECONCILIATION_ERROR_PREFIX: &str = "llm-router-reconciliation-required:"; const LLM_ROUTER_PROVISIONING_CREDENTIAL_VERSION: u32 = 1; -// 临时方案:公共 Router、各部署数据库独立时,所有能操作同一 Router 的部署必须 -// 使用同一派生根。后续迁移到部署密钥管理时先兼容该 v1 派生根;若更换 secret, -// 必须递增版本并完成账号迁移,不能静默造成已有账号密码漂移。 -const LLM_ROUTER_PROVISIONING_SECRET_V1: &[u8] = - b"Jq67uCuRN1fs1l08e9ayImp4Uzc4TAY1IaP0UmHz5qTM7pQnEgwzFYhF_SPmK1Bm"; type HmacSha256 = Hmac; @@ -347,7 +342,11 @@ pub(crate) async fn ensure_llm_router_account( // safely replaced with the current deterministic values. Unknown remote // outcomes with a known account id remain reconciliation-blocked above. let expected_username = router_username_for_owner(owner_user_id); - let expected_password = generate_router_account_password(owner_user_id)?; + let provisioning_secret = llm_router_provisioning_secret(state)?; + let expected_password = generate_router_account_password_with_secret( + owner_user_id, + provisioning_secret.as_bytes(), + )?; let stale_pending_credentials = persisted.as_ref().is_some_and(|account| { account.router_account_id.is_none() && bundle.as_ref().is_some_and(|value| { @@ -1066,11 +1065,10 @@ fn router_user_id_from_record(record: &ExternalApiKeyRecord) -> Option { }) } -/// Runs the idempotent Router-account preparation after every successful -/// authentication. Callers must complete this step before issuing a session -/// token, so a successfully authenticated user always has a persisted -/// `llm-router` account key. This is the mandatory account provisioning step -/// for authenticated users. +/// Starts best-effort Router-account preparation after successful +/// authentication. Router control-plane availability must not gate the main +/// site session; the LLM hot path validates the locally persisted credential +/// when a request is actually made. pub(crate) async fn ensure_llm_router_account_after_auth_success( state: &AppState, _request_context: &RequestContext, @@ -1084,9 +1082,33 @@ pub(crate) async fn ensure_llm_router_account_after_auth_success( return Ok(()); } - ensure_llm_router_account(state, owner_user_id) - .await - .map(|_| ()) + #[cfg(test)] + { + if let Err(error) = ensure_llm_router_account(state, owner_user_id).await { + tracing::warn!( + user_id = %owner_user_id, + error = %error, + "测试环境登录后 LLM Router 账号准备失败;已允许主站登录" + ); + } + return Ok(()); + } + + #[cfg(not(test))] + { + let state = state.clone(); + let owner_user_id = owner_user_id.to_string(); + tokio::spawn(async move { + if let Err(error) = ensure_llm_router_account(&state, owner_user_id.as_str()).await { + tracing::warn!( + user_id = %owner_user_id, + error = %error, + "登录后 LLM Router 账号准备失败;已允许主站登录,后续 LLM 请求将按需失败关闭" + ); + } + }); + Ok(()) + } } /// Reads an already-provisioned Router credential without contacting the @@ -1327,7 +1349,10 @@ async fn provision_router_account_via_new_api( .or_else(|| pending_login.map(|login| (login.username.clone(), login.password.clone()))) .unwrap_or(( router_username_for_owner(owner_user_id), - generate_router_account_password(owner_user_id)?, + generate_router_account_password_with_secret( + owner_user_id, + llm_router_provisioning_secret(state)?.as_bytes(), + )?, )); let admin_token = state .config @@ -1951,13 +1976,47 @@ fn router_username_for_owner(owner_user_id: &str) -> String { format!("agc_user_{short_code}") } +fn llm_router_provisioning_secret(state: &AppState) -> Result { + if let Some(secret) = state + .config + .llm_router_provisioning_secret + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + { + return Ok(secret.to_string()); + } + #[cfg(test)] + { + return Ok("test-only-router-provisioning-secret".to_string()); + } + #[cfg(not(test))] + { + Err("LLM Router 账号密码派生密钥未配置".to_string()) + } +} + +#[cfg(test)] fn generate_router_account_password(owner_user_id: &str) -> Result { + generate_router_account_password_with_secret( + owner_user_id, + b"test-only-router-provisioning-secret", + ) +} + +fn generate_router_account_password_with_secret( + owner_user_id: &str, + provisioning_secret: &[u8], +) -> Result { let owner_user_id = owner_user_id.trim(); if owner_user_id.is_empty() { return Err("LLM Router 账号密码派生参数缺失".to_string()); } - let mut signer = HmacSha256::new_from_slice(LLM_ROUTER_PROVISIONING_SECRET_V1) + if provisioning_secret.is_empty() { + return Err("LLM Router 账号密码派生密钥未配置".to_string()); + } + let mut signer = HmacSha256::new_from_slice(provisioning_secret) .map_err(|_| "LLM Router 账号密码派生密钥无效".to_string())?; signer.update( format!( diff --git a/server-rs/crates/api-server/src/llm/mod.rs b/server-rs/crates/api-server/src/llm/mod.rs index a762c5a0a..07c357f26 100644 --- a/server-rs/crates/api-server/src/llm/mod.rs +++ b/server-rs/crates/api-server/src/llm/mod.rs @@ -82,6 +82,7 @@ pub async fn proxy_llm_chat_completions( }; let api_kind = LlmApiKind::OpenAiResponses; + let request_fingerprint = serde_json::to_vec(&payload).unwrap_or_default(); let request = LlmRunRequest { model: None, api_kind, @@ -100,7 +101,12 @@ pub async fn proxy_llm_chat_completions( }; if payload.stream { - let billing_key = request_idempotency_key(&headers, request_context.request_id()); + let billing_key = request_billing_key( + &headers, + request_context.request_id(), + "chat-completions", + request_fingerprint.as_slice(), + ); return Ok(stream_llm_chat_completions( llm_client.clone(), request, @@ -132,7 +138,12 @@ pub async fn proxy_llm_chat_completions( &state, authenticated.claims().user_id(), request_context.request_id(), - request_idempotency_key(&headers, request_context.request_id()), + request_billing_key( + &headers, + request_context.request_id(), + "chat-completions", + request_fingerprint.as_slice(), + ), response.usage.as_ref(), ) .await @@ -239,7 +250,7 @@ pub async fn proxy_llm_responses( let client = reqwest::Client::builder() .connect_timeout(std::time::Duration::from_secs(15)) - .timeout(std::time::Duration::from_secs(120)) + .read_timeout(std::time::Duration::from_secs(180)) .redirect(reqwest::redirect::Policy::none()) .build() .map_err(|error| { @@ -294,7 +305,13 @@ pub async fn proxy_llm_responses( } } let upstream_headers = upstream.headers().clone(); - let billing_key = request_idempotency_key(&headers, request_context.request_id()); + let request_fingerprint = serde_json::to_vec(&payload).unwrap_or_default(); + let billing_key = request_billing_key( + &headers, + request_context.request_id(), + "responses", + request_fingerprint.as_slice(), + ); let is_stream = payload .get("stream") .and_then(Value::as_bool) @@ -462,15 +479,33 @@ fn insufficient_mud_points_error() -> AppError { })) } -fn request_idempotency_key(headers: &HeaderMap, fallback: &str) -> String { - headers +fn request_billing_key( + headers: &HeaderMap, + fallback: &str, + endpoint: &str, + request_bytes: &[u8], +) -> String { + let client_key = headers .get("idempotency-key") .or_else(|| headers.get("x-idempotency-key")) .and_then(|value| value.to_str().ok()) .map(str::trim) - .filter(|value| !value.is_empty()) - .unwrap_or(fallback) - .to_string() + .filter(|value| { + !value.is_empty() + && value.len() <= 256 + && value.bytes().all(|byte| (0x21..=0x7e).contains(&byte)) + }); + let Some(client_key) = client_key else { + return fallback.to_string(); + }; + let mut hasher = Sha256::new(); + hasher.update(b"llm-router-billing-key:v2\n"); + hasher.update(endpoint.as_bytes()); + hasher.update(b"\n"); + hasher.update(client_key.as_bytes()); + hasher.update(b"\n"); + hasher.update(request_bytes); + hex::encode(hasher.finalize()) } fn llm_router_ledger_id(owner_user_id: &str, idempotency_key: &str) -> String { @@ -678,6 +713,7 @@ fn stream_responses_with_billing( ) -> impl futures_util::Stream> { async_stream::stream! { let mut pending = String::new(); + let mut utf8_pending = Vec::new(); let mut usage = None; let mut terminal_event = None; let mut completed_seen = false; @@ -688,7 +724,7 @@ fn stream_responses_with_billing( while let Some(chunk) = upstream.next().await { match chunk { Ok(bytes) => { - pending.push_str(String::from_utf8_lossy(bytes.as_ref()).as_ref()); + append_utf8_chunk(&mut pending, &mut utf8_pending, bytes.as_ref()); pending = pending.replace("\r\n", "\n"); while let Some(separator) = pending.find("\n\n") { let raw_event = pending[..separator + 2].to_string(); @@ -700,6 +736,15 @@ fn stream_responses_with_billing( if is_responses_terminal_sse_event(&event) { completed_seen = true; terminal_event = Some(raw_event); + if billing_result.is_none() { + billing_result = Some(settle_llm_router_usage( + &state, + owner_user_id.as_str(), + request_id.as_str(), + idempotency_key.clone(), + usage.as_ref(), + ).await); + } continue; } if is_responses_done_sse_event(&event) { @@ -808,6 +853,32 @@ fn stream_responses_with_billing( } } +fn append_utf8_chunk(pending: &mut String, carry: &mut Vec, bytes: &[u8]) { + carry.extend_from_slice(bytes); + loop { + match std::str::from_utf8(carry.as_slice()) { + Ok(text) => { + pending.push_str(text); + carry.clear(); + break; + } + Err(error) => { + let valid = error.valid_up_to(); + if valid > 0 { + pending.push_str(std::str::from_utf8(&carry[..valid]).unwrap_or_default()); + carry.drain(..valid); + } + if let Some(error_len) = error.error_len() { + pending.push('\u{FFFD}'); + carry.drain(..error_len.min(carry.len())); + continue; + } + break; + } + } + } +} + fn is_responses_terminal_sse_event(event: &str) -> bool { if event.lines().any(|line| { line.strip_prefix("event:") @@ -1645,6 +1716,36 @@ mod tests { assert!(!first.contains("request-1")); } + #[test] + fn request_billing_key_binds_client_key_to_endpoint_and_payload() { + let mut headers = HeaderMap::new(); + headers.insert("idempotency-key", HeaderValue::from_static("fixed")); + let first = request_billing_key(&headers, "request-1", "responses", br#"{"input":"a"}"#); + let replay = request_billing_key(&headers, "request-2", "responses", br#"{"input":"a"}"#); + let different_payload = + request_billing_key(&headers, "request-3", "responses", br#"{"input":"b"}"#); + let different_endpoint = request_billing_key( + &headers, + "request-4", + "chat-completions", + br#"{"input":"a"}"#, + ); + assert_eq!(first, replay); + assert_ne!(first, different_payload); + assert_ne!(first, different_endpoint); + } + + #[test] + fn append_utf8_chunk_preserves_multibyte_characters_split_across_chunks() { + let mut text = String::new(); + let mut carry = Vec::new(); + append_utf8_chunk(&mut text, &mut carry, &[0xe4, 0xb8]); + assert_eq!(text, ""); + append_utf8_chunk(&mut text, &mut carry, &[0xad]); + assert_eq!(text, "中"); + assert!(carry.is_empty()); + } + fn spawn_mock_server(responses: Vec) -> String { let listener = TcpListener::bind("127.0.0.1:0").expect("listener should bind"); let address = listener.local_addr().expect("listener should have addr"); From 9a06945e44c6219beff7a01f592e4bbad7a220ac Mon Sep 17 00:00:00 2001 From: suzmii Date: Thu, 3 Sep 2026 11:10:32 +0800 Subject: [PATCH 25/43] =?UTF-8?q?=E6=81=A2=E5=A4=8D=20Router=20provisionin?= =?UTF-8?q?g=20=E5=9B=BA=E5=AE=9A=E5=AF=86=E9=92=A5=E6=96=B9=E6=A1=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 恢复 api-server 内置 provisioning secret,保持现有账号派生兼容 移除 provisioning secret 的环境变量和示例配置入口 补充阶段性取舍而非设计缺陷的代码注释 同步 Router 账号链路架构文档 --- .env.example | 5 -- deploy/env/api-server.env.example | 1 - ...】server-rs与SpacetimeDB数据契约-2026-05-15.md | 2 +- ...构】外部OpenAPI与APIKey接入方案-2026-06-19.md | 2 +- server-rs/crates/api-server/src/config.rs | 8 +-- .../api-server/src/external_api_keys.rs | 53 ++++--------------- 6 files changed, 12 insertions(+), 59 deletions(-) diff --git a/.env.example b/.env.example index d8988060b..85a1c3c81 100644 --- a/.env.example +++ b/.env.example @@ -8,11 +8,6 @@ LLM_BASE_URL="https://api.vectorengine.cn/v1" # but it should not be relied on by browser code. LLM_API_KEY="" -# Router account provisioning secret (server-side only). Prefer the protected -# file form in production; never expose either value to clients or commit it. -GENARRATIVE_LLM_ROUTER_PROVISIONING_SECRET="" -GENARRATIVE_LLM_ROUTER_PROVISIONING_SECRET_FILE="" - # Optional frontend override for the local proxy path. VITE_LLM_PROXY_BASE_URL="/api/llm" diff --git a/deploy/env/api-server.env.example b/deploy/env/api-server.env.example index dd543cd4f..8517ca561 100644 --- a/deploy/env/api-server.env.example +++ b/deploy/env/api-server.env.example @@ -84,7 +84,6 @@ GENARRATIVE_LLM_API_KEY= GENARRATIVE_LLM_MODEL=gpt-5.4-mini # LLM Router 正式账号链路:production 固定使用官方地址/模型;管理员 Token 只读受保护文件。 GENARRATIVE_LLM_ROUTER_BASE_URL=https://router.genarrative.world/v1 -GENARRATIVE_LLM_ROUTER_PROVISIONING_SECRET_FILE=/etc/genarrative/secrets/llm-router-provisioning.secret GENARRATIVE_LLM_ROUTER_API_KEY_ENCRYPTION_SECRET_FILE=/etc/genarrative/secrets/llm-router-api-key-encryption.secret GENARRATIVE_LLM_ROUTER_ADMIN_TOKEN_FILE=/etc/genarrative/secrets/llm-router-admin.token VECTOR_ENGINE_BASE_URL=https://api.vectorengine.cn diff --git a/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md b/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md index 72f4e7236..3206f9938 100644 --- a/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md +++ b/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md @@ -1380,4 +1380,4 @@ RPG 创作入口的配置 ID 是 `rpg`,当前 `visible=true`、`open=true`; - 源码:`server-rs/crates/spacetime-module/src/visual_novel.rs` - 说明:视觉小说公开 source 投影,只暴露 `publication_status = published` 的作品卡片字段,不把完整 `draft` 暴露给公开列表订阅;统一公开列表 / 详情主路径通过 `public_work_gallery_entry` / `public_work_detail_entry` 消费该 view 并映射成跨玩法契约。个人历史、详情、运行态和发布仍按原有 procedure / reducer 路径处理。 - 字段变更:`visible` 控制是否进入公开列表 / 详情,新作品默认 `false`;旧迁移数据由 `migration.rs` 按历史公开默认补 `true`。 -> 2026-09-03 修订:认证成功后的 Router provisioning 改为异步尽力修复,不再阻塞主站登录;LLM 热路径只读取本地已完成的账号密钥,控制面不可用时请求失败关闭。provisioning secret 仅从部署侧受保护环境变量或 secret file 读取,不再内置源码常量。 +> 2026-09-03 修订:认证成功后的 Router provisioning 改为异步尽力修复,不再阻塞主站登录;LLM 热路径只读取本地已完成的账号密钥,控制面不可用时请求失败关闭。考虑到公共 Router 与独立部署数据库当前必须共享同一派生根,provisioning secret 阶段性固定在 api-server 服务端实现;这是当前阶段的安全与部署取舍,不是设计缺陷,后续统一密钥管理和账号迁移方案落地后再迁移。 diff --git a/docs/【后端架构】外部OpenAPI与APIKey接入方案-2026-06-19.md b/docs/【后端架构】外部OpenAPI与APIKey接入方案-2026-06-19.md index 586d46abb..9d3fe02a2 100644 --- a/docs/【后端架构】外部OpenAPI与APIKey接入方案-2026-06-19.md +++ b/docs/【后端架构】外部OpenAPI与APIKey接入方案-2026-06-19.md @@ -215,7 +215,7 @@ SpacetimeDB procedure: ### LLM Router 账号 Key 与 Router 代理 -普通 AGC 发行版不把 Router 当作客户端可配置 Provider,也不把 Router API Key 下发到桌面端。注册成功视为账号已有可用余额;认证成功后,api-server 异步尽力准备该用户对应的 Router 账号和 API Key,Router 控制面故障不得阻塞主站登录;LLM 请求解析阶段仍要求本地存在合法的已完成 provisioning 凭据,正式记录使用 `purpose=llm-router` 并保存服务端加密凭据。当前按 New API 管理接口执行正式 provisioning:由于 `username`、`password`、`display_name` 均限制 20 个字符,用户名固定为 `agc_user_` 加 11 位 URL-safe SHA-256 短码,密码为基于完整 owner `user_id` 与部署侧受保护 provisioning secret 稳定派生的 20 位 hex,展示名与短用户名一致;完整 owner `user_id` 写入 New API 用户 `remark`,本地 `llm_router_account.owner_user_id` 仍是平台权威映射。服务端先查询远端用户:已存在则直接登录,不重复注册;确认不存在时才由管理员创建普通用户,查询用户 ID,设置 `remark=<完整 owner user_id>` 与用户分组 `taonier`,再登录、查询并复用固定标识 `agc_auto_generate` 的 Token(Token/API Key 固定使用 `default` 分组;已有固定 Token 若分组不是 `default`,登录恢复时先通过 Token 更新接口纠正),签发 API Key。每次新建或准备 API Key 时,服务端在签发前查询该 Router 用户的固定套餐 `plan_id=1`;无 active 订阅、订阅已过期或剩余时间不超过 24 小时时调用管理员订阅接口新建一条订阅,剩余超过 24 小时则复用现有订阅。订阅查询/创建只使用 api-server 私有管理员 Token,不进入客户端或 Router Key;检查锚点是显式 Router Key 准备接口和新 Key provisioning,不放在 Responses 流式 chunk 中。由于 Router 公共而各部署数据库独立,所有能操作同一 Router 的部署必须使用相同的 provisioning secret。若任一步外部结果不确定,记录进入 reconciliation 状态,禁止重复注册;本地 API Key 写入失败则保留 `key_issued` 状态并用确定的 key id 重试落库。Router 密文加密优先使用 `GENARRATIVE_LLM_ROUTER_API_KEY_ENCRYPTION_SECRET`;缺省时使用带域分离的 `GENARRATIVE_JWT_SECRET` 派生密钥。Router Key 的明文只在 api-server 进程内短暂存在,`/api/profile/api-keys/llm-router` 只返回安全元数据;普通 External Editor Key 仍沿用创建接口明文只显示一次的正式链路。后续请求链路固定为: +普通 AGC 发行版不把 Router 当作客户端可配置 Provider,也不把 Router API Key 下发到桌面端。注册成功视为账号已有可用余额;认证成功后,api-server 异步尽力准备该用户对应的 Router 账号和 API Key,Router 控制面故障不得阻塞主站登录;LLM 请求解析阶段仍要求本地存在合法的已完成 provisioning 凭据,正式记录使用 `purpose=llm-router` 并保存服务端加密凭据。当前按 New API 管理接口执行正式 provisioning:由于 `username`、`password`、`display_name` 均限制 20 个字符,用户名固定为 `agc_user_` 加 11 位 URL-safe SHA-256 短码,密码为基于完整 owner `user_id` 与 api-server 内置 provisioning secret 稳定派生的 20 位 hex,展示名与短用户名一致。该 secret 是公共 Router 多部署共享场景下的阶段性安全与部署取舍,不是设计缺陷;后续统一密钥管理和账号迁移方案落地后再迁移。完整 owner `user_id` 写入 New API 用户 `remark`,本地 `llm_router_account.owner_user_id` 仍是平台权威映射。服务端先查询远端用户:已存在则直接登录,不重复注册;确认不存在时才由管理员创建普通用户,查询用户 ID,设置 `remark=<完整 owner user_id>` 与用户分组 `taonier`,再登录、查询并复用固定标识 `agc_auto_generate` 的 Token(Token/API Key 固定使用 `default` 分组;已有固定 Token 若分组不是 `default`,登录恢复时先通过 Token 更新接口纠正),签发 API Key。每次新建或准备 API Key 时,服务端在签发前查询该 Router 用户的固定套餐 `plan_id=1`;无 active 订阅、订阅已过期或剩余时间不超过 24 小时时调用管理员订阅接口新建一条订阅,剩余超过 24 小时则复用现有订阅。订阅查询/创建只使用 api-server 私有管理员 Token,不进入客户端或 Router Key;检查锚点是显式 Router Key 准备接口和新 Key provisioning,不放在 Responses 流式 chunk 中。由于 Router 公共而各部署数据库独立,所有能操作同一 Router 的部署必须使用相同的 provisioning secret。若任一步外部结果不确定,记录进入 reconciliation 状态,禁止重复注册;本地 API Key 写入失败则保留 `key_issued` 状态并用确定的 key id 重试落库。Router 密文加密优先使用 `GENARRATIVE_LLM_ROUTER_API_KEY_ENCRYPTION_SECRET`;缺省时使用带域分离的 `GENARRATIVE_JWT_SECRET` 派生密钥。Router Key 的明文只在 api-server 进程内短暂存在,`/api/profile/api-keys/llm-router` 只返回安全元数据;普通 External Editor Key 仍沿用创建接口明文只显示一次的正式链路。后续请求链路固定为: ```text AGC loopback Provider Proxy(Bearer=平台 access token) diff --git a/server-rs/crates/api-server/src/config.rs b/server-rs/crates/api-server/src/config.rs index 39da78b47..0390efd86 100644 --- a/server-rs/crates/api-server/src/config.rs +++ b/server-rs/crates/api-server/src/config.rs @@ -186,8 +186,7 @@ pub struct AppConfig { /// LLM Router 账号级代理固定配置;普通客户端不能覆盖这些值。 pub llm_router_base_url: String, pub llm_router_model: String, - /// Router 用户密码派生根,仅从部署侧 secret/env/file 读取。 - pub llm_router_provisioning_secret: Option, + /// Router 凭据本地加密密钥;缺省时从 JWT secret 做域分离派生。 pub llm_router_api_key_encryption_secret: Option, pub llm_router_admin_token: Option, pub dashscope_base_url: String, @@ -494,7 +493,6 @@ impl Default for AppConfig { llm_retry_backoff_ms: DEFAULT_RETRY_BACKOFF_MS, llm_router_base_url: OFFICIAL_LLM_ROUTER_BASE_URL.to_string(), llm_router_model: OFFICIAL_LLM_ROUTER_MODEL.to_string(), - llm_router_provisioning_secret: None, llm_router_api_key_encryption_secret: None, llm_router_admin_token: None, dashscope_base_url: "https://dashscope.aliyuncs.com/api/v1".to_string(), @@ -1212,10 +1210,6 @@ impl AppConfig { config.llm_retry_backoff_ms = llm_retry_backoff_ms; } - config.llm_router_provisioning_secret = read_secret_env_or_file( - &["GENARRATIVE_LLM_ROUTER_PROVISIONING_SECRET"], - &["GENARRATIVE_LLM_ROUTER_PROVISIONING_SECRET_FILE"], - ); config.llm_router_api_key_encryption_secret = read_secret_env_or_file( &["GENARRATIVE_LLM_ROUTER_API_KEY_ENCRYPTION_SECRET"], &["GENARRATIVE_LLM_ROUTER_API_KEY_ENCRYPTION_SECRET_FILE"], diff --git a/server-rs/crates/api-server/src/external_api_keys.rs b/server-rs/crates/api-server/src/external_api_keys.rs index bfba95732..861bee8e6 100644 --- a/server-rs/crates/api-server/src/external_api_keys.rs +++ b/server-rs/crates/api-server/src/external_api_keys.rs @@ -56,6 +56,12 @@ const EXTERNAL_API_KEY_PURPOSE_EXTERNAL_EDITOR: &str = "external-editor"; const EXTERNAL_API_KEY_PURPOSE_LLM_ROUTER: &str = "llm-router"; const LLM_ROUTER_RECONCILIATION_ERROR_PREFIX: &str = "llm-router-reconciliation-required:"; const LLM_ROUTER_PROVISIONING_CREDENTIAL_VERSION: u32 = 1; +// 阶段性取舍而非设计缺陷:公共 Router 与独立部署数据库当前仍需共享同一 +// 派生根。暂时将 provisioning secret 固定在 api-server 服务端,避免把它 +// 暴露为客户端或部署侧可随意覆盖的配置项;后续统一密钥管理和账号迁移方案 +// 落地后,再按 credential version 迁移,不能静默造成已有账号密码漂移。 +const LLM_ROUTER_PROVISIONING_SECRET_V1: &[u8] = + b"Jq67uCuRN1fs1l08e9ayImp4Uzc4TAY1IaP0UmHz5qTM7pQnEgwzFYhF_SPmK1Bm"; type HmacSha256 = Hmac; @@ -342,11 +348,7 @@ pub(crate) async fn ensure_llm_router_account( // safely replaced with the current deterministic values. Unknown remote // outcomes with a known account id remain reconciliation-blocked above. let expected_username = router_username_for_owner(owner_user_id); - let provisioning_secret = llm_router_provisioning_secret(state)?; - let expected_password = generate_router_account_password_with_secret( - owner_user_id, - provisioning_secret.as_bytes(), - )?; + let expected_password = generate_router_account_password(owner_user_id)?; let stale_pending_credentials = persisted.as_ref().is_some_and(|account| { account.router_account_id.is_none() && bundle.as_ref().is_some_and(|value| { @@ -1349,10 +1351,7 @@ async fn provision_router_account_via_new_api( .or_else(|| pending_login.map(|login| (login.username.clone(), login.password.clone()))) .unwrap_or(( router_username_for_owner(owner_user_id), - generate_router_account_password_with_secret( - owner_user_id, - llm_router_provisioning_secret(state)?.as_bytes(), - )?, + generate_router_account_password(owner_user_id)?, )); let admin_token = state .config @@ -1976,47 +1975,13 @@ fn router_username_for_owner(owner_user_id: &str) -> String { format!("agc_user_{short_code}") } -fn llm_router_provisioning_secret(state: &AppState) -> Result { - if let Some(secret) = state - .config - .llm_router_provisioning_secret - .as_deref() - .map(str::trim) - .filter(|value| !value.is_empty()) - { - return Ok(secret.to_string()); - } - #[cfg(test)] - { - return Ok("test-only-router-provisioning-secret".to_string()); - } - #[cfg(not(test))] - { - Err("LLM Router 账号密码派生密钥未配置".to_string()) - } -} - -#[cfg(test)] fn generate_router_account_password(owner_user_id: &str) -> Result { - generate_router_account_password_with_secret( - owner_user_id, - b"test-only-router-provisioning-secret", - ) -} - -fn generate_router_account_password_with_secret( - owner_user_id: &str, - provisioning_secret: &[u8], -) -> Result { let owner_user_id = owner_user_id.trim(); if owner_user_id.is_empty() { return Err("LLM Router 账号密码派生参数缺失".to_string()); } - if provisioning_secret.is_empty() { - return Err("LLM Router 账号密码派生密钥未配置".to_string()); - } - let mut signer = HmacSha256::new_from_slice(provisioning_secret) + let mut signer = HmacSha256::new_from_slice(LLM_ROUTER_PROVISIONING_SECRET_V1) .map_err(|_| "LLM Router 账号密码派生密钥无效".to_string())?; signer.update( format!( From ac0f6bb783387c21914e390bfe78319f178aa673 Mon Sep 17 00:00:00 2001 From: suzmii Date: Thu, 3 Sep 2026 11:22:29 +0800 Subject: [PATCH 26/43] =?UTF-8?q?Revert=20"=E6=81=A2=E5=A4=8D=20Router=20p?= =?UTF-8?q?rovisioning=20=E5=9B=BA=E5=AE=9A=E5=AF=86=E9=92=A5=E6=96=B9?= =?UTF-8?q?=E6=A1=88"?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This reverts commit 9a06945e44c6219beff7a01f592e4bbad7a220ac. --- .env.example | 5 ++ deploy/env/api-server.env.example | 1 + ...】server-rs与SpacetimeDB数据契约-2026-05-15.md | 2 +- ...构】外部OpenAPI与APIKey接入方案-2026-06-19.md | 2 +- server-rs/crates/api-server/src/config.rs | 8 ++- .../api-server/src/external_api_keys.rs | 53 +++++++++++++++---- 6 files changed, 59 insertions(+), 12 deletions(-) diff --git a/.env.example b/.env.example index 85a1c3c81..d8988060b 100644 --- a/.env.example +++ b/.env.example @@ -8,6 +8,11 @@ LLM_BASE_URL="https://api.vectorengine.cn/v1" # but it should not be relied on by browser code. LLM_API_KEY="" +# Router account provisioning secret (server-side only). Prefer the protected +# file form in production; never expose either value to clients or commit it. +GENARRATIVE_LLM_ROUTER_PROVISIONING_SECRET="" +GENARRATIVE_LLM_ROUTER_PROVISIONING_SECRET_FILE="" + # Optional frontend override for the local proxy path. VITE_LLM_PROXY_BASE_URL="/api/llm" diff --git a/deploy/env/api-server.env.example b/deploy/env/api-server.env.example index 8517ca561..dd543cd4f 100644 --- a/deploy/env/api-server.env.example +++ b/deploy/env/api-server.env.example @@ -84,6 +84,7 @@ GENARRATIVE_LLM_API_KEY= GENARRATIVE_LLM_MODEL=gpt-5.4-mini # LLM Router 正式账号链路:production 固定使用官方地址/模型;管理员 Token 只读受保护文件。 GENARRATIVE_LLM_ROUTER_BASE_URL=https://router.genarrative.world/v1 +GENARRATIVE_LLM_ROUTER_PROVISIONING_SECRET_FILE=/etc/genarrative/secrets/llm-router-provisioning.secret GENARRATIVE_LLM_ROUTER_API_KEY_ENCRYPTION_SECRET_FILE=/etc/genarrative/secrets/llm-router-api-key-encryption.secret GENARRATIVE_LLM_ROUTER_ADMIN_TOKEN_FILE=/etc/genarrative/secrets/llm-router-admin.token VECTOR_ENGINE_BASE_URL=https://api.vectorengine.cn diff --git a/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md b/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md index 3206f9938..72f4e7236 100644 --- a/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md +++ b/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md @@ -1380,4 +1380,4 @@ RPG 创作入口的配置 ID 是 `rpg`,当前 `visible=true`、`open=true`; - 源码:`server-rs/crates/spacetime-module/src/visual_novel.rs` - 说明:视觉小说公开 source 投影,只暴露 `publication_status = published` 的作品卡片字段,不把完整 `draft` 暴露给公开列表订阅;统一公开列表 / 详情主路径通过 `public_work_gallery_entry` / `public_work_detail_entry` 消费该 view 并映射成跨玩法契约。个人历史、详情、运行态和发布仍按原有 procedure / reducer 路径处理。 - 字段变更:`visible` 控制是否进入公开列表 / 详情,新作品默认 `false`;旧迁移数据由 `migration.rs` 按历史公开默认补 `true`。 -> 2026-09-03 修订:认证成功后的 Router provisioning 改为异步尽力修复,不再阻塞主站登录;LLM 热路径只读取本地已完成的账号密钥,控制面不可用时请求失败关闭。考虑到公共 Router 与独立部署数据库当前必须共享同一派生根,provisioning secret 阶段性固定在 api-server 服务端实现;这是当前阶段的安全与部署取舍,不是设计缺陷,后续统一密钥管理和账号迁移方案落地后再迁移。 +> 2026-09-03 修订:认证成功后的 Router provisioning 改为异步尽力修复,不再阻塞主站登录;LLM 热路径只读取本地已完成的账号密钥,控制面不可用时请求失败关闭。provisioning secret 仅从部署侧受保护环境变量或 secret file 读取,不再内置源码常量。 diff --git a/docs/【后端架构】外部OpenAPI与APIKey接入方案-2026-06-19.md b/docs/【后端架构】外部OpenAPI与APIKey接入方案-2026-06-19.md index 9d3fe02a2..586d46abb 100644 --- a/docs/【后端架构】外部OpenAPI与APIKey接入方案-2026-06-19.md +++ b/docs/【后端架构】外部OpenAPI与APIKey接入方案-2026-06-19.md @@ -215,7 +215,7 @@ SpacetimeDB procedure: ### LLM Router 账号 Key 与 Router 代理 -普通 AGC 发行版不把 Router 当作客户端可配置 Provider,也不把 Router API Key 下发到桌面端。注册成功视为账号已有可用余额;认证成功后,api-server 异步尽力准备该用户对应的 Router 账号和 API Key,Router 控制面故障不得阻塞主站登录;LLM 请求解析阶段仍要求本地存在合法的已完成 provisioning 凭据,正式记录使用 `purpose=llm-router` 并保存服务端加密凭据。当前按 New API 管理接口执行正式 provisioning:由于 `username`、`password`、`display_name` 均限制 20 个字符,用户名固定为 `agc_user_` 加 11 位 URL-safe SHA-256 短码,密码为基于完整 owner `user_id` 与 api-server 内置 provisioning secret 稳定派生的 20 位 hex,展示名与短用户名一致。该 secret 是公共 Router 多部署共享场景下的阶段性安全与部署取舍,不是设计缺陷;后续统一密钥管理和账号迁移方案落地后再迁移。完整 owner `user_id` 写入 New API 用户 `remark`,本地 `llm_router_account.owner_user_id` 仍是平台权威映射。服务端先查询远端用户:已存在则直接登录,不重复注册;确认不存在时才由管理员创建普通用户,查询用户 ID,设置 `remark=<完整 owner user_id>` 与用户分组 `taonier`,再登录、查询并复用固定标识 `agc_auto_generate` 的 Token(Token/API Key 固定使用 `default` 分组;已有固定 Token 若分组不是 `default`,登录恢复时先通过 Token 更新接口纠正),签发 API Key。每次新建或准备 API Key 时,服务端在签发前查询该 Router 用户的固定套餐 `plan_id=1`;无 active 订阅、订阅已过期或剩余时间不超过 24 小时时调用管理员订阅接口新建一条订阅,剩余超过 24 小时则复用现有订阅。订阅查询/创建只使用 api-server 私有管理员 Token,不进入客户端或 Router Key;检查锚点是显式 Router Key 准备接口和新 Key provisioning,不放在 Responses 流式 chunk 中。由于 Router 公共而各部署数据库独立,所有能操作同一 Router 的部署必须使用相同的 provisioning secret。若任一步外部结果不确定,记录进入 reconciliation 状态,禁止重复注册;本地 API Key 写入失败则保留 `key_issued` 状态并用确定的 key id 重试落库。Router 密文加密优先使用 `GENARRATIVE_LLM_ROUTER_API_KEY_ENCRYPTION_SECRET`;缺省时使用带域分离的 `GENARRATIVE_JWT_SECRET` 派生密钥。Router Key 的明文只在 api-server 进程内短暂存在,`/api/profile/api-keys/llm-router` 只返回安全元数据;普通 External Editor Key 仍沿用创建接口明文只显示一次的正式链路。后续请求链路固定为: +普通 AGC 发行版不把 Router 当作客户端可配置 Provider,也不把 Router API Key 下发到桌面端。注册成功视为账号已有可用余额;认证成功后,api-server 异步尽力准备该用户对应的 Router 账号和 API Key,Router 控制面故障不得阻塞主站登录;LLM 请求解析阶段仍要求本地存在合法的已完成 provisioning 凭据,正式记录使用 `purpose=llm-router` 并保存服务端加密凭据。当前按 New API 管理接口执行正式 provisioning:由于 `username`、`password`、`display_name` 均限制 20 个字符,用户名固定为 `agc_user_` 加 11 位 URL-safe SHA-256 短码,密码为基于完整 owner `user_id` 与部署侧受保护 provisioning secret 稳定派生的 20 位 hex,展示名与短用户名一致;完整 owner `user_id` 写入 New API 用户 `remark`,本地 `llm_router_account.owner_user_id` 仍是平台权威映射。服务端先查询远端用户:已存在则直接登录,不重复注册;确认不存在时才由管理员创建普通用户,查询用户 ID,设置 `remark=<完整 owner user_id>` 与用户分组 `taonier`,再登录、查询并复用固定标识 `agc_auto_generate` 的 Token(Token/API Key 固定使用 `default` 分组;已有固定 Token 若分组不是 `default`,登录恢复时先通过 Token 更新接口纠正),签发 API Key。每次新建或准备 API Key 时,服务端在签发前查询该 Router 用户的固定套餐 `plan_id=1`;无 active 订阅、订阅已过期或剩余时间不超过 24 小时时调用管理员订阅接口新建一条订阅,剩余超过 24 小时则复用现有订阅。订阅查询/创建只使用 api-server 私有管理员 Token,不进入客户端或 Router Key;检查锚点是显式 Router Key 准备接口和新 Key provisioning,不放在 Responses 流式 chunk 中。由于 Router 公共而各部署数据库独立,所有能操作同一 Router 的部署必须使用相同的 provisioning secret。若任一步外部结果不确定,记录进入 reconciliation 状态,禁止重复注册;本地 API Key 写入失败则保留 `key_issued` 状态并用确定的 key id 重试落库。Router 密文加密优先使用 `GENARRATIVE_LLM_ROUTER_API_KEY_ENCRYPTION_SECRET`;缺省时使用带域分离的 `GENARRATIVE_JWT_SECRET` 派生密钥。Router Key 的明文只在 api-server 进程内短暂存在,`/api/profile/api-keys/llm-router` 只返回安全元数据;普通 External Editor Key 仍沿用创建接口明文只显示一次的正式链路。后续请求链路固定为: ```text AGC loopback Provider Proxy(Bearer=平台 access token) diff --git a/server-rs/crates/api-server/src/config.rs b/server-rs/crates/api-server/src/config.rs index 0390efd86..39da78b47 100644 --- a/server-rs/crates/api-server/src/config.rs +++ b/server-rs/crates/api-server/src/config.rs @@ -186,7 +186,8 @@ pub struct AppConfig { /// LLM Router 账号级代理固定配置;普通客户端不能覆盖这些值。 pub llm_router_base_url: String, pub llm_router_model: String, - /// Router 凭据本地加密密钥;缺省时从 JWT secret 做域分离派生。 + /// Router 用户密码派生根,仅从部署侧 secret/env/file 读取。 + pub llm_router_provisioning_secret: Option, pub llm_router_api_key_encryption_secret: Option, pub llm_router_admin_token: Option, pub dashscope_base_url: String, @@ -493,6 +494,7 @@ impl Default for AppConfig { llm_retry_backoff_ms: DEFAULT_RETRY_BACKOFF_MS, llm_router_base_url: OFFICIAL_LLM_ROUTER_BASE_URL.to_string(), llm_router_model: OFFICIAL_LLM_ROUTER_MODEL.to_string(), + llm_router_provisioning_secret: None, llm_router_api_key_encryption_secret: None, llm_router_admin_token: None, dashscope_base_url: "https://dashscope.aliyuncs.com/api/v1".to_string(), @@ -1210,6 +1212,10 @@ impl AppConfig { config.llm_retry_backoff_ms = llm_retry_backoff_ms; } + config.llm_router_provisioning_secret = read_secret_env_or_file( + &["GENARRATIVE_LLM_ROUTER_PROVISIONING_SECRET"], + &["GENARRATIVE_LLM_ROUTER_PROVISIONING_SECRET_FILE"], + ); config.llm_router_api_key_encryption_secret = read_secret_env_or_file( &["GENARRATIVE_LLM_ROUTER_API_KEY_ENCRYPTION_SECRET"], &["GENARRATIVE_LLM_ROUTER_API_KEY_ENCRYPTION_SECRET_FILE"], diff --git a/server-rs/crates/api-server/src/external_api_keys.rs b/server-rs/crates/api-server/src/external_api_keys.rs index 861bee8e6..bfba95732 100644 --- a/server-rs/crates/api-server/src/external_api_keys.rs +++ b/server-rs/crates/api-server/src/external_api_keys.rs @@ -56,12 +56,6 @@ const EXTERNAL_API_KEY_PURPOSE_EXTERNAL_EDITOR: &str = "external-editor"; const EXTERNAL_API_KEY_PURPOSE_LLM_ROUTER: &str = "llm-router"; const LLM_ROUTER_RECONCILIATION_ERROR_PREFIX: &str = "llm-router-reconciliation-required:"; const LLM_ROUTER_PROVISIONING_CREDENTIAL_VERSION: u32 = 1; -// 阶段性取舍而非设计缺陷:公共 Router 与独立部署数据库当前仍需共享同一 -// 派生根。暂时将 provisioning secret 固定在 api-server 服务端,避免把它 -// 暴露为客户端或部署侧可随意覆盖的配置项;后续统一密钥管理和账号迁移方案 -// 落地后,再按 credential version 迁移,不能静默造成已有账号密码漂移。 -const LLM_ROUTER_PROVISIONING_SECRET_V1: &[u8] = - b"Jq67uCuRN1fs1l08e9ayImp4Uzc4TAY1IaP0UmHz5qTM7pQnEgwzFYhF_SPmK1Bm"; type HmacSha256 = Hmac; @@ -348,7 +342,11 @@ pub(crate) async fn ensure_llm_router_account( // safely replaced with the current deterministic values. Unknown remote // outcomes with a known account id remain reconciliation-blocked above. let expected_username = router_username_for_owner(owner_user_id); - let expected_password = generate_router_account_password(owner_user_id)?; + let provisioning_secret = llm_router_provisioning_secret(state)?; + let expected_password = generate_router_account_password_with_secret( + owner_user_id, + provisioning_secret.as_bytes(), + )?; let stale_pending_credentials = persisted.as_ref().is_some_and(|account| { account.router_account_id.is_none() && bundle.as_ref().is_some_and(|value| { @@ -1351,7 +1349,10 @@ async fn provision_router_account_via_new_api( .or_else(|| pending_login.map(|login| (login.username.clone(), login.password.clone()))) .unwrap_or(( router_username_for_owner(owner_user_id), - generate_router_account_password(owner_user_id)?, + generate_router_account_password_with_secret( + owner_user_id, + llm_router_provisioning_secret(state)?.as_bytes(), + )?, )); let admin_token = state .config @@ -1975,13 +1976,47 @@ fn router_username_for_owner(owner_user_id: &str) -> String { format!("agc_user_{short_code}") } +fn llm_router_provisioning_secret(state: &AppState) -> Result { + if let Some(secret) = state + .config + .llm_router_provisioning_secret + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + { + return Ok(secret.to_string()); + } + #[cfg(test)] + { + return Ok("test-only-router-provisioning-secret".to_string()); + } + #[cfg(not(test))] + { + Err("LLM Router 账号密码派生密钥未配置".to_string()) + } +} + +#[cfg(test)] fn generate_router_account_password(owner_user_id: &str) -> Result { + generate_router_account_password_with_secret( + owner_user_id, + b"test-only-router-provisioning-secret", + ) +} + +fn generate_router_account_password_with_secret( + owner_user_id: &str, + provisioning_secret: &[u8], +) -> Result { let owner_user_id = owner_user_id.trim(); if owner_user_id.is_empty() { return Err("LLM Router 账号密码派生参数缺失".to_string()); } - let mut signer = HmacSha256::new_from_slice(LLM_ROUTER_PROVISIONING_SECRET_V1) + if provisioning_secret.is_empty() { + return Err("LLM Router 账号密码派生密钥未配置".to_string()); + } + let mut signer = HmacSha256::new_from_slice(provisioning_secret) .map_err(|_| "LLM Router 账号密码派生密钥无效".to_string())?; signer.update( format!( From 4f79a275a8f7e104e9ca3bf31ebba6e7a5e6da69 Mon Sep 17 00:00:00 2001 From: suzmii Date: Thu, 3 Sep 2026 13:39:32 +0800 Subject: [PATCH 27/43] =?UTF-8?q?=E7=BE=8E=E5=8C=96=20AGC=20=E5=AE=A2?= =?UTF-8?q?=E6=88=B7=E7=AB=AF=E8=AE=BE=E7=BD=AE=E9=A1=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 收敛账号与角色协作展示文案 增加账号状态检测动画和状态卡片样式 将流式输出与联网检索整理为单列 更新设置页回归测试与配置护栏 --- .../scripts/check-config.mjs | 3 +- .../runtime-config/RuntimeConfigDialog.tsx | 104 ++++++++++-------- apps/ai-game-creator-shell/src/styles.css | 63 +++++++++++ .../appSurface/runtime-settings.suite.ts | 92 +++++++++++++--- 4 files changed, 201 insertions(+), 61 deletions(-) diff --git a/apps/ai-game-creator-shell/scripts/check-config.mjs b/apps/ai-game-creator-shell/scripts/check-config.mjs index 5e76d0a32..53a7f7c07 100644 --- a/apps/ai-game-creator-shell/scripts/check-config.mjs +++ b/apps/ai-game-creator-shell/scripts/check-config.mjs @@ -1757,7 +1757,8 @@ for (const snippet of [ "'read_game_creator_app_config'", "'write_game_creator_app_config'", 'aria-label="运行时配置"', - '官方账号服务(固定)', + '智能创作', + '账号状态', 'External Editor Base URL', 'External Editor API Key', 'runtime_config.save', diff --git a/apps/ai-game-creator-shell/src/features/runtime-config/RuntimeConfigDialog.tsx b/apps/ai-game-creator-shell/src/features/runtime-config/RuntimeConfigDialog.tsx index 8cd344cea..e315ba8ac 100644 --- a/apps/ai-game-creator-shell/src/features/runtime-config/RuntimeConfigDialog.tsx +++ b/apps/ai-game-creator-shell/src/features/runtime-config/RuntimeConfigDialog.tsx @@ -124,7 +124,7 @@ const runtimeSettingsSections = [ { id: 'agents', label: 'Agent 分工', - description: '查看各角色运行状态', + description: '角色协作状态', icon: Bot, }, { @@ -258,6 +258,7 @@ export function RuntimeConfigDialog({ const [runtimeConfigBusy, setRuntimeConfigBusy] = useState(false); const [effectiveLlmConfigStatus, setEffectiveLlmConfigStatus] = useState(llmConfigStatus); + const [llmConfigChecking, setLlmConfigChecking] = useState(false); const [activeSection, setActiveSection] = useState('general'); const [clientExtensions, setClientExtensions] = useState< @@ -502,6 +503,7 @@ export function RuntimeConfigDialog({ ); setRuntimeConfigDraft(config); setRuntimeConfigStatus(`已读取:${result.path}`); + setLlmConfigChecking(true); try { const status = await invoke( 'check_game_creator_llm_config', @@ -510,6 +512,8 @@ export function RuntimeConfigDialog({ } catch { // Older test fixtures and non-Tauri previews may not expose the // diagnostic command; the config read itself remains usable. + } finally { + setLlmConfigChecking(false); } onLog?.('runtime_config.read'); } catch (error) { @@ -730,13 +734,7 @@ export function RuntimeConfigDialog({ <>

工作方式 - 陶泥儿智能创作(固定) - 需求将由官方智能服务执行 -
-
- 智能服务 - 官方账号服务(固定) - 登录后自动使用当前账号权限。 + 智能创作
{(() => { const credential = formatAccountCredentialState( @@ -744,17 +742,28 @@ export function RuntimeConfigDialog({ ); return (
- 账号权限 - {credential.label} - - {credential.tone === 'success' - ? '当前账号已具备智能创作权限。' - : '请返回登录页完成登录或重新登录;普通用户无需填写任何智能服务凭据。'} - + 账号状态 + + {llmConfigChecking ? ( + <> +
); })()} @@ -780,34 +789,36 @@ export function RuntimeConfigDialog({ ))} - - +
+ + +
) : null} @@ -912,9 +923,8 @@ export function RuntimeConfigDialog({ ) : null} {activeSection === 'agents' ? (
- 角色服务 - 所有角色使用统一账号服务 - 普通用户无需配置服务连接信息。 + 角色协作 + 已启用
) : null} {activeSection === 'connections' ? ( diff --git a/apps/ai-game-creator-shell/src/styles.css b/apps/ai-game-creator-shell/src/styles.css index e4281b392..ef59e7073 100644 --- a/apps/ai-game-creator-shell/src/styles.css +++ b/apps/ai-game-creator-shell/src/styles.css @@ -3901,6 +3901,69 @@ h2 { align-content: start; } +.runtime-settings-readonly-field, +.runtime-settings-option-stack > label { + display: grid; + gap: 7px; + min-width: 0; + padding: 13px; + border: 1px solid var(--platform-subpanel-border); + border-radius: 12px; + background: linear-gradient( + 145deg, + var(--runtime-settings-subpanel-fill), + color-mix(in srgb, var(--runtime-settings-subpanel-fill) 78%, #f0e7dc) + ); + color: var(--platform-text-base); +} + +.runtime-settings-readonly-field > span, +.runtime-settings-option-stack > label { + font-size: 11px; +} + +.runtime-settings-readonly-field > span { + color: var(--platform-text-soft); +} + +.runtime-settings-readonly-field > strong { + display: inline-flex; + align-items: center; + gap: 6px; + min-height: 20px; + color: var(--platform-text-strong); + font-size: 13px; + font-weight: 800; +} + +.runtime-settings-readonly-field[data-tone='success'] { + border-color: color-mix( + in srgb, + var(--platform-success-border) 70%, + transparent + ); + background: linear-gradient(145deg, #fbfffc, #f1faf2); +} + +.runtime-settings-readonly-field[data-checking='true'] { + border-color: var(--platform-nav-active-border); + background: linear-gradient(145deg, #fffdf9, #f8f1e7); +} + +.runtime-settings-account-state[data-checking='true'] > strong { + color: var(--platform-accent); +} + +.runtime-settings-readonly-field .is-spinning { + animation: runtime-settings-spin 0.9s linear infinite; +} + +.runtime-settings-option-stack { + display: grid; + gap: 10px; + min-width: 0; +} + .runtime-settings-fields > label { padding: 13px; border: 1px solid var(--platform-subpanel-border); diff --git a/apps/ai-game-creator-shell/tests/appSurface/runtime-settings.suite.ts b/apps/ai-game-creator-shell/tests/appSurface/runtime-settings.suite.ts index ece3e4022..a35eb7162 100644 --- a/apps/ai-game-creator-shell/tests/appSurface/runtime-settings.suite.ts +++ b/apps/ai-game-creator-shell/tests/appSurface/runtime-settings.suite.ts @@ -255,6 +255,69 @@ export function registerAgentStatusDerivationTests() { } export function registerRuntimeSettingsTests() { + it('shows a checking animation while account status is being verified', async () => { + let resolveAccountCheck!: (status: unknown) => void; + const accountCheck = new Promise((resolve) => { + resolveAccountCheck = resolve; + }); + const invoke = vi.fn((command: string) => { + if (command === 'read_game_creator_app_config') { + return Promise.resolve({ + path: '/home/test/AppData/game-creator.config.json', + config: { + agentMode: 'codex_app_server', + llm: { + apiKey: '', + baseUrl: '', + model: '', + apiKind: 'openai_responses', + reasoningEffort: 'high', + stream: true, + webSearchEnabled: true, + contextWindowTokens: 128000, + autoCompactTokenLimit: 64000, + toolOutputTokenLimit: 12000, + requestTimeoutMs: 180000, + maxRetries: 2, + retryBackoffMs: 500, + }, + agentLlm: {}, + editorApi: { baseUrl: '', apiKey: '' }, + }, + }); + } + if (command === 'check_game_creator_llm_config') { + return accountCheck; + } + throw new Error(`unexpected invoke ${command}`); + }); + window.__TAURI__ = { core: { invoke } }; + renderLauncherAt('/?launcher'); + + fireEvent.click(screen.getByRole('button', { name: '配置' })); + const accountState = await screen.findByTestId( + 'runtime-settings-account-credential-state', + ); + await waitFor(() => { + expect(accountState.getAttribute('data-checking')).toBe('true'); + }); + expect(accountState.querySelector('.is-spinning')).not.toBeNull(); + expect(screen.getByText('正在检测')).not.toBeNull(); + + await act(async () => { + resolveAccountCheck({ + accountCredentialState: 'ready', + configured: true, + }); + }); + + await waitFor(() => { + expect(accountState.getAttribute('data-checking')).toBe('false'); + expect(accountState.querySelector('.is-spinning')).toBeNull(); + }); + expect(screen.getByText('账号权限已就绪')).not.toBeNull(); + }); + it('distinguishes loading the client extension list from an empty list', async () => { let resolveExtensions!: (items: unknown[]) => void; const extensionsRead = new Promise((resolve) => { @@ -404,9 +467,11 @@ export function registerRuntimeSettingsTests() { renderLauncherAt('/?launcher'); fireEvent.click(screen.getByRole('button', { name: '配置' })); - expect(await screen.findByText('陶泥儿智能创作(固定)')).not.toBeNull(); + expect(await screen.findByText('智能创作')).not.toBeNull(); expect(screen.queryByLabelText('Agent 模式')).toBeNull(); - expect(screen.getByText('官方账号服务(固定)')).not.toBeNull(); + expect(screen.getByText('账号状态')).not.toBeNull(); + expect(screen.queryByText('官方账号服务(固定)')).toBeNull(); + expect(screen.queryByText('普通用户无需填写任何智能服务凭据。')).toBeNull(); expect( screen.queryByText(/router\.genarrative\.world|gpt-5\.6-sol/), ).toBeNull(); @@ -477,7 +542,7 @@ export function registerRuntimeSettingsTests() { renderLauncherAt('/?launcher'); fireEvent.click(screen.getByRole('button', { name: '配置' })); - expect(await screen.findByText('陶泥儿智能创作(固定)')).not.toBeNull(); + expect(await screen.findByText('智能创作')).not.toBeNull(); expect(screen.queryByLabelText('Agent 模式')).toBeNull(); fireEvent.click(screen.getByRole('button', { name: '保存' })); @@ -531,7 +596,7 @@ export function registerRuntimeSettingsTests() { fireEvent.click(screen.getByRole('button', { name: '配置' })); const dialog = await screen.findByRole('dialog', { name: '运行时配置' }); - expect(screen.getByText('官方账号服务(固定)')).not.toBeNull(); + expect(screen.getByText('账号状态')).not.toBeNull(); expect( screen.queryByText(/router\.genarrative\.world|gpt-5\.6-sol/), ).toBeNull(); @@ -822,7 +887,7 @@ export function registerPublishedRuntimeSettingsTests() { fireEvent.click(screen.getByRole('button', { name: '配置' })); - expect(await screen.findByText('官方账号服务(固定)')).not.toBeNull(); + expect(await screen.findByText('账号状态')).not.toBeNull(); expect( screen.queryByText(/router\.genarrative\.world|gpt-5\.6-sol/), ).toBeNull(); @@ -847,7 +912,8 @@ export function registerPublishedRuntimeSettingsTests() { expect(screen.queryByLabelText('External Editor Base URL')).toBeNull(); expect(screen.queryByLabelText('External Editor API Key')).toBeNull(); fireEvent.click(screen.getByRole('button', { name: /Agent 分工/ })); - expect(screen.getByText('所有角色使用统一账号服务')).not.toBeNull(); + expect(screen.getByText('已启用')).not.toBeNull(); + expect(screen.queryByText('所有角色使用统一账号服务')).toBeNull(); fireEvent.click(screen.getByRole('button', { name: /常用设置/ })); expect(screen.getByLabelText('联网检索')).toHaveProperty('checked', false); fireEvent.click(screen.getByRole('button', { name: /高级参数/ })); @@ -864,7 +930,7 @@ export function registerPublishedRuntimeSettingsTests() { '12000', ); fireEvent.click(screen.getByRole('button', { name: /Agent 分工/ })); - expect(screen.getByText('所有角色使用统一账号服务')).not.toBeNull(); + expect(screen.getByText('已启用')).not.toBeNull(); fireEvent.click(screen.getByRole('button', { name: /常用设置/ })); expect(screen.getByLabelText('推理档')).toHaveProperty('value', 'medium'); @@ -895,7 +961,7 @@ export function registerPublishedRuntimeSettingsTests() { target: { value: '800' }, }); fireEvent.click(screen.getByRole('button', { name: /Agent 分工/ })); - expect(screen.getByText('所有角色使用统一账号服务')).not.toBeNull(); + expect(screen.getByText('已启用')).not.toBeNull(); fireEvent.click(screen.getByRole('button', { name: /连接与工具/ })); expect(screen.queryByLabelText('External Editor Base URL')).toBeNull(); expect(screen.queryByLabelText('External Editor API Key')).toBeNull(); @@ -943,7 +1009,7 @@ export function registerPublishedRuntimeSettingsTests() { fireEvent.click(screen.getByRole('button', { name: /常用设置/ })); expect(screen.getByLabelText('联网检索')).toHaveProperty('checked', true); fireEvent.click(screen.getByRole('button', { name: /Agent 分工/ })); - expect(screen.getByText('所有角色使用统一账号服务')).not.toBeNull(); + expect(screen.getByText('已启用')).not.toBeNull(); expect(screen.getByLabelText('聊天').textContent).not.toContain( 'unit-new-secret-value', ); @@ -983,9 +1049,9 @@ export function registerPublishedRuntimeSettingsTests() { expect(screen.getByText('已恢复默认配置,保存后生效')).not.toBeNull(); fireEvent.click(screen.getByRole('button', { name: /常用设置/ })); - expect(screen.getByText('陶泥儿智能创作(固定)')).not.toBeNull(); + expect(screen.getByText('智能创作')).not.toBeNull(); expect(screen.queryByLabelText('Agent 模式')).toBeNull(); - expect(screen.getByText('官方账号服务(固定)')).not.toBeNull(); + expect(screen.getByText('账号状态')).not.toBeNull(); expect( screen.queryByText(/router\.genarrative\.world|gpt-5\.6-sol/), ).toBeNull(); @@ -995,7 +1061,7 @@ export function registerPublishedRuntimeSettingsTests() { expect(screen.getByLabelText('推理档')).toHaveProperty('value', 'max'); expect(screen.getByLabelText('联网检索')).toHaveProperty('checked', true); fireEvent.click(screen.getByRole('button', { name: /Agent 分工/ })); - expect(screen.getByText('所有角色使用统一账号服务')).not.toBeNull(); + expect(screen.getByText('已启用')).not.toBeNull(); fireEvent.click(screen.getByRole('button', { name: /高级参数/ })); expect(screen.getByLabelText('上下文窗口 tokens')).toHaveProperty( 'value', @@ -1086,7 +1152,7 @@ export function registerPublishedRuntimeSettingsTests() { renderAppAt('/'); fireEvent.click(screen.getByRole('button', { name: '配置' })); - expect(await screen.findByText('官方账号服务(固定)')).not.toBeNull(); + expect(await screen.findByText('账号状态')).not.toBeNull(); expect(screen.queryByLabelText('LLM Provider')).toBeNull(); expect(screen.queryByLabelText('LLM API Key')).toBeNull(); expect(screen.queryByLabelText('LLM Base URL')).toBeNull(); From 8aaa3f5c7be26840c28ddd69f00d39f1989d6d14 Mon Sep 17 00:00:00 2001 From: suzmii Date: Thu, 3 Sep 2026 14:24:40 +0800 Subject: [PATCH 28/43] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E8=BF=90=E8=A1=8C?= =?UTF-8?q?=E6=97=B6=E9=85=8D=E7=BD=AE=E8=AF=8A=E6=96=AD=E9=98=BB=E5=A1=9E?= =?UTF-8?q?=E7=AA=97=E5=8F=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 将 Codex CLI 与 app-server 探测移入 Tokio 阻塞线程池 保持 GameAgent 运行时配置读写流程不变 增加异步 LLM 配置诊断回归测试并补充技术方案约束 --- .../src-tauri/src/commands.rs | 10 +++++-- .../src-tauri/src/tests/configuration.rs | 27 +++++++++++++++++++ ...案】AI游戏创作Agent Runtime V1.1-2026-07-12.md | 1 + 3 files changed, 36 insertions(+), 2 deletions(-) diff --git a/apps/ai-game-creator-shell/src-tauri/src/commands.rs b/apps/ai-game-creator-shell/src-tauri/src/commands.rs index 3d6744e87..248cb1348 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/commands.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/commands.rs @@ -1832,8 +1832,14 @@ pub(crate) fn schedule_game_creator_agent_ready_tasks( } #[tauri::command] -pub(crate) fn check_game_creator_llm_config() -> GameCreatorLlmConfigStatus { - check_game_creator_llm_config_from_config() +pub(crate) async fn check_game_creator_llm_config() -> Result { + // Configuration reads/writes are short local file operations, but the + // diagnostic status path may synchronously spawn Codex CLI and run + // `app-server --help`. Keep that blocking probe off Tauri's async/window + // thread so opening or saving runtime settings never freezes the shell. + tokio::task::spawn_blocking(check_game_creator_llm_config_from_config) + .await + .map_err(|error| format!("LLM 配置诊断任务意外终止:{error}")) } #[tauri::command] diff --git a/apps/ai-game-creator-shell/src-tauri/src/tests/configuration.rs b/apps/ai-game-creator-shell/src-tauri/src/tests/configuration.rs index 21336885b..b797683fe 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/tests/configuration.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/tests/configuration.rs @@ -1097,6 +1097,33 @@ fn llm_config_check_reports_agent_specific_config_paths() { fs::remove_dir_all(root).ok(); } +#[tokio::test] +async fn llm_config_diagnostic_command_runs_asynchronously() { + let root = unique_project_path(); + fs::create_dir_all(&root).expect("create runtime config dir"); + let _guard = use_test_runtime_config_dir(root.clone()); + fs::write( + root.join(GAME_CREATOR_CONFIG_FILE_NAME), + r#"{ + "agentMode": "provider", + "llm": { + "apiKey": "test-key", + "baseUrl": "https://example.test/v1", + "model": "test-model" + } +} +"#, + ) + .expect("write runtime config"); + + let status = crate::commands::check_game_creator_llm_config() + .await + .expect("run config diagnostic command"); + assert!(status.configured, "{status:?}"); + + fs::remove_dir_all(root).ok(); +} + #[test] fn llm_status_cli_lines_include_agent_errors_without_leaking_keys() { let status = GameCreatorLlmConfigStatus { diff --git a/docs/technical/【技术方案】AI游戏创作Agent Runtime V1.1-2026-07-12.md b/docs/technical/【技术方案】AI游戏创作Agent Runtime V1.1-2026-07-12.md index 39177166c..876086c00 100644 --- a/docs/technical/【技术方案】AI游戏创作Agent Runtime V1.1-2026-07-12.md +++ b/docs/technical/【技术方案】AI游戏创作Agent Runtime V1.1-2026-07-12.md @@ -1617,6 +1617,7 @@ V1.51 在不替换现有 Provider、Runner 和 Runtime 事实源的前提下增 ### 配置与验收 - `check_game_creator_llm_config` 在 CLI 模式只检查固定 Codex 可执行文件可启动并返回受支持版本,不要求 LLM API Key;Provider 模式保持既有检查。配置 UI 显示全局模式,始终保留旧 LLM/逐 Agent/MCP/Editor 配置;切换模式不清空任何旧值。 +- Tauri 配置页触发的 `check_game_creator_llm_config` 必须通过阻塞线程池执行 Codex CLI / app-server 探测,不能在窗口线程同步等待;配置文件读写不得因诊断探测阻塞整个 GameAgent 窗口。 - fake Codex fixture 必须覆盖 argv/stdin、structured calls、final text、非零退出、挂起取消、损坏/超限 JSONL、无 shell/MCP 参数和并发节点;真实 smoke 只复用用户已有 Codex 登录态。旧 Provider smoke 继续作为回退模式门禁,不能被新 CLI smoke 替代。 ## V1.52 Codex app-server 长期节点 Agent 模式 From 9ae3aba092d1e56c8047341117dbd4a5b44698d8 Mon Sep 17 00:00:00 2001 From: suzmii Date: Thu, 3 Sep 2026 14:35:22 +0800 Subject: [PATCH 29/43] =?UTF-8?q?=E6=B8=85=E7=90=86=E5=B7=B2=E9=80=80?= =?UTF-8?q?=E5=BD=B9=E5=B7=A5=E5=85=B7=E6=B5=8B=E8=AF=95=E5=B9=B6=E8=A1=A5?= =?UTF-8?q?=E5=85=85=20Router=20=E8=B4=A6=E5=8F=B7=E6=96=87=E6=A1=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 删除已移除 Skill 引用工具的遗留专用测试 补充 llm_router_account 表结构与用途说明 --- .../src-tauri/src/agent/direct_tools_mcp.rs | 22 ------------------- ...】server-rs与SpacetimeDB数据契约-2026-05-15.md | 7 ++++++ 2 files changed, 7 insertions(+), 22 deletions(-) diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tools_mcp.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tools_mcp.rs index 031ab5474..30e3b0bea 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tools_mcp.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tools_mcp.rs @@ -1583,26 +1583,4 @@ mod tests { assert_eq!(response["isError"], true); assert!(response.to_string().contains("未审核字段")); } - - #[test] - fn skill_resource_tool_rejects_unreviewed_paths() { - let accepted = call_agc_read_skill_resource(&json!({ - "skillName": "agc-project-structure", - "relativePath": "references/structure-contract.md" - })); - assert_eq!(accepted["isError"], false); - assert!(accepted.to_string().contains("drive prefix")); - - let denied = call_agc_read_skill_resource(&json!({ - "skillName": "agc-project-structure", - "relativePath": "../../auth.json" - })); - assert_eq!(denied["isError"], true); - - let denied_windows_absolute = call_agc_read_skill_resource(&json!({ - "skillName": "agc-project-structure", - "relativePath": r"C:\temp\SKILL.md" - })); - assert_eq!(denied_windows_absolute["isError"], true); - } } diff --git a/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md b/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md index 72f4e7236..57843104c 100644 --- a/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md +++ b/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md @@ -639,6 +639,13 @@ Responses 的终态载荷既是工具调用的恢复源,也是正文的恢复 - 2026-09-02 修订:`/api/llm/responses` 与 `/api/llm/chat/completions` 在解析 Router 凭据和发起上游请求前先读取用户 `wallet_balance`;余额为 `0` 时直接返回 `409 MUD_POINTS_INSUFFICIENT`,不创建、续期或使用 Router 账号。余额读取失败同样失败关闭,返回泥点余额暂不可用。 - Windows 私有文件准备:AGC 自有 AppData、凭据目录和 `.agent` 运行态继续使用 managed 范围;用户通过原生选择器明确选中的项目根或文件,若 owner/DACL 仅因权限不足无法读取,则由一次性 UAC helper 在严格复核普通文件/目录、非 reparse/symlink、路径类型和目标 TokenUser 后接管并收紧为当前用户私有 DACL。项目放在当前 profile 之外(例如其他磁盘)不再因为路径位置被拒绝;未经过原生选择器或 AGC 项目根入口的内部路径仍不获得任意提权资格。 +### `llm_router_account` + +- Rust 结构体:`LlmRouterAccount` +- 源码:`server-rs/crates/spacetime-module/src/llm_router_account.rs` +- 作用:记录用户与官方 LLM Router 账号的稳定映射、账号生命周期、远端账号元数据、订阅检查状态、重试 / 对账状态和加密凭据版本。Router 明文 Key 仍只保存在 `external_api_key` 的加密字段中,账号读取和写入统一通过对应 procedure 与 `spacetime-client` facade 完成。 +- 索引:`by_llm_router_account_owner_route` 用于按 owner 与路由来源读取账号;`by_llm_router_account_status_next_retry` 用于按状态和下次重试时间扫描待对账账号。 + ### `error_report` - Rust 结构体:`ErrorReport` From e34d797336946c56d2f6d778cf46e1991115385c Mon Sep 17 00:00:00 2001 From: suzmii Date: Thu, 3 Sep 2026 15:59:19 +0800 Subject: [PATCH 30/43] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=20AGC=20=E7=A1=AE?= =?UTF-8?q?=E5=AE=9A=E6=80=A7=20E2E=20=E4=B8=8E=20master=20=E5=90=88?= =?UTF-8?q?=E5=B9=B6=E5=90=8E=E7=9A=84=E8=BF=90=E8=A1=8C=E9=93=BE=E8=B7=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 合并 master 后 isolated AppData 配置改为私有副本,避免 Windows 安全校验拒绝硬链接。 只读 runner 状态命令显式配置 config dir,避免误读为 runner 未启用。 为 debug 构建增加仅 E2E 显式开启的确定性 Provider 路由开关,并补充回归测试。 兼容 Windows 对私有副本权限位的校验差异。 --- ...ent-runtime-deterministic-playable-e2e.mjs | 1 + .../harness/app-data.mjs | 7 +++--- .../src-tauri/src/config.rs | 25 +++++++++++++++---- .../src-tauri/src/main.rs | 2 +- .../src-tauri/src/tests/configuration.rs | 17 +++++++++++++ 5 files changed, 43 insertions(+), 9 deletions(-) diff --git a/apps/ai-game-creator-shell/scripts/agent-runtime-deterministic-playable-e2e.mjs b/apps/ai-game-creator-shell/scripts/agent-runtime-deterministic-playable-e2e.mjs index ba8b9b3eb..c6b20f6dd 100644 --- a/apps/ai-game-creator-shell/scripts/agent-runtime-deterministic-playable-e2e.mjs +++ b/apps/ai-game-creator-shell/scripts/agent-runtime-deterministic-playable-e2e.mjs @@ -1948,6 +1948,7 @@ async function runE2e(options) { ...process.env, NO_COLOR: '1', [platformSessionFixtureEnv]: fixturePath, + GENARRATIVE_AGC_DEBUG_PROVIDER_E2E: '1', }), ); childReport = parseChildReport(childResult); diff --git a/apps/ai-game-creator-shell/scripts/agent-runtime-real-e2e/harness/app-data.mjs b/apps/ai-game-creator-shell/scripts/agent-runtime-real-e2e/harness/app-data.mjs index f283ecf8e..3d8e052c6 100644 --- a/apps/ai-game-creator-shell/scripts/agent-runtime-real-e2e/harness/app-data.mjs +++ b/apps/ai-game-creator-shell/scripts/agent-runtime-real-e2e/harness/app-data.mjs @@ -773,7 +773,8 @@ export async function prepareIsolatedSuiteAppData({ isScopedAgentsSuite() || isProjectSkillSuite() || isParallelReadSuite() || - isSupervisorSwarmSuite() + isSupervisorSwarmSuite() || + isSupervisorAutonomousPlayableLaneDefenseSuite() ? 'private-copy' : 'hardlink'; try { @@ -796,7 +797,7 @@ export async function prepareIsolatedSuiteAppData({ storageMode === 'private-copy' && (linkedMetadata.dev !== source.metadata.dev || linkedMetadata.ino !== source.metadata.ino) && - (linkedMetadata.mode & 0o077) === 0; + (process.platform === 'win32' || (linkedMetadata.mode & 0o077) === 0); const hardlinkValid = storageMode === 'hardlink' && linkedMetadata.dev === source.metadata.dev && @@ -1976,7 +1977,7 @@ export async function verifyIsolatedSuiteConfigLinksUnchanged() { linkedMetadata.ino === link.linkedIno && (linkedMetadata.dev !== sourceMetadata.dev || linkedMetadata.ino !== sourceMetadata.ino) && - (linkedMetadata.mode & 0o077) === 0 + (process.platform === 'win32' || (linkedMetadata.mode & 0o077) === 0) : linkedMetadata.dev === link.dev && linkedMetadata.ino === link.ino; const sourceMetadataStable = sourceMetadata.mode === link.sourceMode && diff --git a/apps/ai-game-creator-shell/src-tauri/src/config.rs b/apps/ai-game-creator-shell/src-tauri/src/config.rs index 86f74f3e7..93aa0efe2 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/config.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/config.rs @@ -3446,18 +3446,33 @@ pub(crate) fn migrate_legacy_game_creator_agent_mode(path: &Path) -> Result<(), /// Returns whether a real AGC build must use the authenticated API Server /// proxy instead of any persisted provider credentials. /// -/// Debug and release binaries intentionally share this decision. The only -/// exception is the Rust unit-test build, where existing tests may install an -/// explicit loopback provider fixture; that fixture is never compiled into a -/// shipped binary and is not a runtime fallback. +/// Debug and release binaries intentionally share this decision. The two +/// exceptions are the Rust unit-test build and the explicitly env-gated debug +/// deterministic-provider E2E; their loopback fixtures are never compiled into +/// or enabled inside a shipped release binary. pub(crate) fn game_creator_official_llm_route_locked() -> bool { - game_creator_official_llm_route_locked_for_build(cfg!(test)) + !debug_provider_e2e_route_unlocked() + && game_creator_official_llm_route_locked_for_build(cfg!(test)) } pub(crate) fn game_creator_official_llm_route_locked_for_build(is_test_build: bool) -> bool { !is_test_build } +fn debug_provider_e2e_route_unlocked() -> bool { + debug_provider_e2e_route_unlocked_for_build( + cfg!(debug_assertions), + std::env::var_os("GENARRATIVE_AGC_DEBUG_PROVIDER_E2E").as_deref(), + ) +} + +pub(crate) fn debug_provider_e2e_route_unlocked_for_build( + debug_assertions: bool, + value: Option<&std::ffi::OsStr>, +) -> bool { + debug_assertions && value == Some(std::ffi::OsStr::new("1")) +} + pub(crate) fn lock_game_creator_app_config_to_official_route(config: &mut GameCreatorAppConfig) { if !game_creator_official_llm_route_locked() { return; diff --git a/apps/ai-game-creator-shell/src-tauri/src/main.rs b/apps/ai-game-creator-shell/src-tauri/src/main.rs index 53530b4a4..12a7e65a7 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/main.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/main.rs @@ -2316,7 +2316,7 @@ fn main() { app_log!("agent.runner.failed: {error}"); std::process::exit(1); } - if command.requires_external_agent_runner() { + if command.requires_external_agent_runner() || command.is_read_only_status() { let configured = if command.is_read_only_status() { configure_external_agent_runner_read_only(&config_dir) } else { diff --git a/apps/ai-game-creator-shell/src-tauri/src/tests/configuration.rs b/apps/ai-game-creator-shell/src-tauri/src/tests/configuration.rs index b797683fe..aebc0ea4d 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/tests/configuration.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/tests/configuration.rs @@ -330,6 +330,23 @@ fn official_llm_route_is_locked_for_debug_and_release_platform_builds() { assert!(!game_creator_official_llm_route_locked_for_build(true)); } +#[test] +fn debug_provider_e2e_flag_only_unlocks_debug_platform_build() { + assert!(debug_provider_e2e_route_unlocked_for_build( + true, + Some(std::ffi::OsStr::new("1")) + )); + assert!(!debug_provider_e2e_route_unlocked_for_build( + false, + Some(std::ffi::OsStr::new("1")) + )); + assert!(!debug_provider_e2e_route_unlocked_for_build( + true, + Some(std::ffi::OsStr::new("0")) + )); + assert!(!debug_provider_e2e_route_unlocked_for_build(true, None)); +} + #[test] fn codex_app_server_requires_responses_route_and_disables_native_web_search() { let mut llm = GameCreatorLlmConfig::default(); From 6d41dc2f89befed37489df1339dc89b32eb8075b Mon Sep 17 00:00:00 2001 From: suzmii Date: Thu, 3 Sep 2026 19:03:40 +0800 Subject: [PATCH 31/43] =?UTF-8?q?=E5=88=87=E6=8D=A2=20LLM=20Router=20?= =?UTF-8?q?=E5=87=AD=E6=8D=AE=E5=88=B0=E4=B8=93=E7=94=A8=E8=B4=A6=E5=8F=B7?= =?UTF-8?q?=E8=A1=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 将 Router 账号、API Key 元数据和密文统一保存到 llm_router_account - 增加 Router API Key 的进程内缓存并清理旧 external_api_key 兼容路径 - 更新 SpacetimeDB 绑定、后端架构文档和决策记录 --- .../shared-memory/decision-log.md | 8 +- ...】server-rs与SpacetimeDB数据契约-2026-05-15.md | 8 +- ...构】外部OpenAPI与APIKey接入方案-2026-06-19.md | 8 +- .../api-server/src/external_api_keys.rs | 915 ++++-------------- server-rs/crates/api-server/src/llm/mod.rs | 31 +- .../src/active/mapper/llm_router_account.rs | 28 +- .../llm_router_account_snapshot_type.rs | 7 +- .../llm_router_account_type.rs | 24 +- .../llm_router_account_upsert_input_type.rs | 7 +- .../src/llm_router_account.rs | 50 +- 10 files changed, 317 insertions(+), 769 deletions(-) diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index 9138f5f4d..96e9a95bf 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -7974,17 +7974,17 @@ CI 上 `background_agent_runtime_recovers_stale_running_before_pending_task` 在 ## 2026-08-29 AGC 官方 LLM 代理与 Windows 私有路径修复 ## 2026-08-29 AGC 官方 LLM 代理与 Windows 私有路径修复 -- AGC 正式发行版不再让用户配置 Provider、Base URL、模型或 API Key。客户端只携带登录 access token 调用 `api-server`;`api-server` 按 access token 的 owner 查询 `external_api_key` 中 `purpose=llm-router` 的账号记录,解密服务端密文后调用固定 `https://router.genarrative.world/v1` 的 `gpt-5.6-sol` Responses 路由。真实 Router Key 不进入聊天、manifest、trace、日志、项目文件、Codex argv/环境变量或普通 IPC payload。 +- AGC 正式发行版不再让用户配置 Provider、Base URL、模型或 API Key。客户端只携带登录 access token 调用 `api-server`;`api-server` 按 access token 的 owner 查询 `llm_router_account`,解密服务端密文后调用固定 `https://router.genarrative.world/v1` 的 `gpt-5.6-sol` Responses 路由。真实 Router Key 不进入聊天、manifest、trace、日志、项目文件、Codex argv/环境变量或普通 IPC payload。 - 注册成功后视为账号已有余额;当前不实现真实扣费,LLM 代理在 Router 成功返回后再记泥点,扣费失败只记录日志,不影响已经成功的响应。注册入口和首次 LLM 请求都会幂等确保账号 Router Key;api-server 只通过 New API 管理员 Token 执行正式“创建用户 → 查询用户 ID → 设置分组 → 登录 → 创建无限额度 token → 签发 API Key”流程并加密落库,不再生成或使用任何 Router fallback token。远端结果不确定时写入 reconciliation 标记;本地签发落库失败可安全重试,不制造第二个账号。客户端不会退回手工 Key;真实管理员 Token、注册和生产 Router 联通仍待受控部署 smoke。 -- `external_api_key` 继续复用一次性明文返回和 hash/prefix 元数据链路;LLM Router 用途固定名称 `llm-router`、`llm:responses` scope,并额外保存加密密文、Router account id 和固定路由元数据。登出、切换账号或服务器只清理进程内 access token/Provider Proxy,不删除其它账号或设备的本地/远端 Key;Router 确定返回 401/403 时标记当前账号 Key revoked。 +- `external_api_key` 继续复用一次性明文返回和 hash/prefix 元数据链路,仅承载普通外部 OpenAPI/MCP Key。LLM Router 的 `llm-router` 用途、`llm:responses` scope、加密密文、Router account id 和固定路由元数据统一保存在 `llm_router_account`。登出、切换账号或服务器只清理进程内 access token/Provider Proxy,不删除其它账号或设备的本地/远端 Key;Router 确定返回 401/403 时标记当前账号 Key revoked。 - 后台只允许管理员通过专用 API Key 查询接口按 owner、公开用户编号、keyId、精确 prefix、名称、时间、状态和 purpose 筛选;未给出 owner/keyId/prefix 时拒绝无界扫描,永不返回 `key_hash`、密文或原始表行。通用 `external_api_key` 表浏览被拒绝。 - Windows 私有路径严格拒绝 reparse/symlink、非普通对象、路径类型冲突和候选路径冲突。只有本次调用新建的目录/临时文件可在普通进程内初始化 owner;owner 已正确但仅继承 ACL 不合规时,正式 prepare 入口先完成归属校验,再通过当前用户私有、禁止继承、单一 ACE 的 DACL 收紧。用户通过原生选择器明确选中的项目根/文件,或 AGC managed 路径,在发现 owner/DACL 权限不足时由一次性 UAC helper 将普通对象接管为当前 TokenUser 并复核;取消/失败保持失败关闭,未经过正式选择或项目根入口的内部路径不得触发任意提权。 - 规划、Runtime sidecar、UI workflow、资源桥、Skill 隔离目录和图片读取统一经过私有路径准备,并在原子写入后复核类型、owner/DACL 与文件身份。真实 Windows UAC、foreign owner 修复、继承 DACL 收紧、注册后 Router 签发和生产 `/v1/responses` 联通仍需在受控实机/部署环境验证。 ## 2026-08-31 LLM Router 独立账号与后置扣费修订 -- 每个 Genarrative 用户在认证成功后都必须幂等准备独立 Router 账号:api-server 使用管理员 Token 创建随机密码普通用户,查询用户 ID,设置用户 `group=taonier`,登录、创建或复用固定标识 `agc_auto_generate` 的无限额度 Token(Token/API Key 使用 `default` 分组;发现旧 Token 为其它分组时先更新为 `default`)并签发 API Key。Router 账号用户名、随机密码、access token(如需)和 API Key 作为一个服务端加密 bundle 保存到 `llm_router_account.credential_ciphertext`,脱敏账号信息保存到 `llm_router_account.account_json`,并在 `external_api_key` 中保存正式 `purpose=llm-router` 行与 `credential_version`;客户端和普通用户永远不可见 Router Key。管理员 Token 仅存在 api-server 私有配置,不写入数据库或日志;Router 凭据只来源于这条正式账号流程。 -- 该账号 provisioning 使用持久 saga 状态:远端注册、登录、token 或 Key 签发结果不确定时进入 `unknown` / `reconciliation_required`,禁止重复注册;远端 Key 已确定签发但本地 `external_api_key` 写入失败时保持 `key_issued`,后续使用确定 key id 重试落库。Router 确定返回 401/403 时撤销当前 Key 并把账号状态置为 `retryable`,复用已保存的账号密码重新签发替代 Key。 +- 每个 Genarrative 用户在认证成功后都必须幂等准备独立 Router 账号:api-server 使用管理员 Token 创建随机密码普通用户,查询用户 ID,设置用户 `group=taonier`,登录、创建或复用固定标识 `agc_auto_generate` 的无限额度 Token(Token/API Key 使用 `default` 分组;发现旧 Token 为其它分组时先更新为 `default`)并签发 API Key。Router 账号用户名、随机密码、access token(如需)和 API Key 作为一个服务端加密 bundle 保存到 `llm_router_account.credential_ciphertext`,脱敏账号信息和 API Key 核心字段保存到 `llm_router_account`;客户端和普通用户永远不可见 Router Key。管理员 Token 仅存在 api-server 私有配置,不写入数据库或日志;Router 凭据只来源于这条正式账号流程。 +- 该账号 provisioning 使用持久 saga 状态:远端注册、登录、token 或 Key 签发结果不确定时进入 `unknown` / `reconciliation_required`,禁止重复注册;远端 Key 已确定签发但本地 `llm_router_account` 写入失败时保持 `key_issued`,后续使用确定 key id 重试落库。Router 确定返回 401/403 时撤销当前 Key 并把账号状态置为 `retryable`,复用已保存的账号密码重新签发替代 Key。 - AGC 调用固定为客户端 access token -> api-server -> Router。Router 成功返回后再扣泥点,按临时规则每开始 10,000 token 扣 1 点且至少 1 点;ledger id 由 `Idempotency-Key`(缺省请求 ID)哈希得到,Router 失败不扣费。余额足够时全额扣除;余额不足时按当前可消费余额扣光,差额记为赠送并继续返回已成功的 LLM 响应。该计费规则是过渡实现,待产品定价确认后替换,不能视为 Router 真实成本结算。 - AGC 状态面收口:Tauri `check_game_creator_llm_config`、`/llm-status` 与 `/llm-routes` 只返回账号凭据状态、官方路由锁定状态和运行参数;不序列化 Router 地址、模型、协议名或任何密钥/凭据字段,内部固定路由仅留在运行时配置与服务端代理中。 diff --git a/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md b/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md index f448eb648..3aefaa859 100644 --- a/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md +++ b/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md @@ -631,9 +631,9 @@ Responses 的终态载荷既是工具调用的恢复源,也是正文的恢复 - Rust 结构体:`ExternalApiKey` - 源码:`server-rs/crates/spacetime-module/src/external_api_key_storage.rs` -- 说明:外部 OpenAPI 与 LLM Router 使用的账号级 API Key 凭据表,公开元数据只包含 key prefix、作用域、用途、撤销状态和使用时间;服务端内部还保存 Router Key 的 AES-256-GCM 密文、Router 账号标识和固定上游元数据,明文永不通过登录态接口、SpacetimeDB 表查询或客户端返回。普通 External Editor Key 的明文只在 `/api/profile/api-keys` 创建接口返回一次,后端只保存 `key_hash` 与 `key_prefix`;正式 Router 账号用途固定为 `purpose=llm-router`,每次认证成功后由 api-server 幂等确保并保存,已有合法记录直接复用。Router 账号由 New API 管理 Token 完成创建、用户分组设置、登录、无限额度 token 和 API Key 签发:用户分组固定为 `taonier`,AGC Token/API Key 固定为 `default` 分组;已有固定 Token 若分组不是 `default`,登录恢复时先通过 Token 更新接口纠正。Router 凭据只来源于这条正式账号流程。Router 密文加密优先使用专用 `GENARRATIVE_LLM_ROUTER_API_KEY_ENCRYPTION_SECRET`;缺省时使用带域分离的 JWT secret 派生值。生产环境应显式配置专用 secret,轮换时先完成旧密文对账,不能静默创建第二个 active Key。`/api/profile/api-keys/llm-router` 只返回安全元数据,不返回 Router 明文 Key。API Key 管理接口不写入外部 OpenAPI JSON。`purpose=external-editor` 是默认用途,v1 默认作用域为 `editor:project`、`editor:canvas`、`editor:image-generate`、`editor:asset`;其中 `editor:project` 覆盖项目列表、最近项目、创建、读取、重命名和删除,`editor:canvas` 覆盖默认画布布局保存,`editor:image-generate` 覆盖编辑器现有图片生成、重绘 / 调整、去背景、规范图、宣发素材、图标 spritesheet 生成 / 拆分、UI 设计图素材拆分、角色动画、视频、音效和背景音乐生成,`editor:asset` 覆盖素材直传凭证、素材对象确认、签名读取、账号级素材库和项目画布资源记录操作。`purpose=llm-router` 是 LLM Router 正式账号 Key,作用域固定为 `llm:responses`。 +- 说明:本表只承载普通外部 OpenAPI/MCP API Key。明文只在 `/api/profile/api-keys` 创建接口返回一次,服务端保存 `key_hash`、`key_prefix`、作用域、用途和撤销状态;本需求不向该表增加 Router 字段,也不写入 `purpose=llm-router` 数据。`purpose=external-editor` 是默认用途,v1 默认作用域为 `editor:project`、`editor:canvas`、`editor:image-generate`、`editor:asset`。 - 索引:`by_external_api_key_owner_user_id` 用于登录态 API Key 列表;`key_hash` 唯一索引用于外部 API 鉴权。 -- 2026-08-31 修订:LLM Router 行在上述字段末尾追加 `provider_account_json`(Router 账号 JSON,普通 External Editor Key 为 `NULL`)与 `credential_version`(默认 `1`)。每次认证成功由 api-server 按稳定用户名/密码先查询并恢复远端用户;确认不存在时再通过 New API 管理 Token 完成“创建用户 → 查询用户 ID → 设置 `taonier` 分组 → 登录 → 查询并复用固定标识 `agc_auto_generate` 的 Token(不存在才创建)→ 签发 API Key”,再把账号状态与加密凭据写入 `llm_router_account` / `external_api_key`;Router 凭据只来源于正式账号流程。公共 Router、独立数据库的部署必须共享同一版本 provisioning secret,数据库缺行时才能恢复同一远端账号。Responses 请求成功后再按 usage 写入钱包扣费流水,失败请求不扣费。 +- 2026-08-31 修订:Router 账号状态、API Key 核心字段、账号元数据和加密凭据统一写入 `llm_router_account`;`external_api_key` 保持普通外部 OpenAPI/MCP Key 的原有链路不变。公共 Router、独立数据库的部署必须共享同一版本 provisioning secret,数据库缺行时才能恢复同一远端账号。Responses 请求成功后再按 usage 写入钱包扣费流水,失败请求不扣费。 - 2026-09-01 修订:Router provisioning 允许所有环境使用官方固定 Router 控制面,以便独立开发数据库通过完整 owner `user_id` 的稳定派生凭据和 `agc_auto_generate` Token 复用同一远端账号/Key。由于 New API 的 `username`、`password`、`display_name` 均限制 20 个字符,用户名固定为 `agc_user_` 加 11 位 URL-safe SHA-256 短码,密码为基于完整 owner `user_id` 与 provisioning secret 派生的 20 位 hex;完整 owner `user_id` 通过 New API 用户 `remark` 字段保存,并在本地 `llm_router_account.owner_user_id` 保留权威映射。非官方公网地址仍拒绝,loopback 仅用于本地 fixture。使用共享官方 Router 的非生产环境启动时告警,提醒会触及线上账号与额度。api-server 不再提供任何 fallback Key 路径;没有已完成 provisioning 的账号行时,LLM 请求必须在本地解析阶段失败关闭。 - 2026-09-01 修订:每次账号认证、Router Key 准备或 LLM 请求解析既有账号时,api-server 都会在同一 owner 级 provisioning 锁内检查固定套餐 `plan_id=1`。没有 active 订阅、订阅已过期或 `end_time` 距当前 Unix 秒不超过 24 小时时,使用管理员接口新建一条订阅;超过 24 小时则复用现有订阅。订阅检查不进入 Responses 流式 chunk,管理员 Token 缺失时仅保留启动告警并跳过续期检查。 - 2026-09-02 修订:LLM Router 成功返回后按“每开始 10,000 token 扣 1 点、至少 1 点”后置结算。余额足够时全额扣除;余额不足时在同一钱包事务内扣除当前可消费余额并记录 `waivedPoints` 差额,响应仍正常返回。该规则为过渡产品策略,待真实定价接入后替换。 @@ -642,9 +642,11 @@ Responses 的终态载荷既是工具调用的恢复源,也是正文的恢复 ### `llm_router_account` +- 当前 AGC Router 需求由本表单独承载:API Key 核心字段、加密凭据、Router 账号元数据、生命周期与 provisioning 状态均在本表;不再依赖 `external_api_key`。api-server 首次需要上游调用时解密凭据,并按 owner + route 使用 10 分钟进程内缓存;轮换或撤销时清理缓存。 + - Rust 结构体:`LlmRouterAccount` - 源码:`server-rs/crates/spacetime-module/src/llm_router_account.rs` -- 作用:记录用户与官方 LLM Router 账号的稳定映射、账号生命周期、远端账号元数据、订阅检查状态、重试 / 对账状态和加密凭据版本。Router 明文 Key 仍只保存在 `external_api_key` 的加密字段中,账号读取和写入统一通过对应 procedure 与 `spacetime-client` facade 完成。 +- 作用:记录用户与官方 LLM Router 账号的稳定映射、API Key 核心字段、账号生命周期、远端账号元数据、订阅检查状态、重试 / 对账状态和加密凭据版本。Router 明文 Key 只在 api-server 进程内短暂存在,数据库仅保存加密凭据;账号读取和写入统一通过对应 procedure 与 `spacetime-client` facade 完成。 - 索引:`by_llm_router_account_owner_route` 用于按 owner 与路由来源读取账号;`by_llm_router_account_status_next_retry` 用于按状态和下次重试时间扫描待对账账号。 ### `error_report` diff --git a/docs/【后端架构】外部OpenAPI与APIKey接入方案-2026-06-19.md b/docs/【后端架构】外部OpenAPI与APIKey接入方案-2026-06-19.md index 586d46abb..43208c976 100644 --- a/docs/【后端架构】外部OpenAPI与APIKey接入方案-2026-06-19.md +++ b/docs/【后端架构】外部OpenAPI与APIKey接入方案-2026-06-19.md @@ -215,12 +215,14 @@ SpacetimeDB procedure: ### LLM Router 账号 Key 与 Router 代理 -普通 AGC 发行版不把 Router 当作客户端可配置 Provider,也不把 Router API Key 下发到桌面端。注册成功视为账号已有可用余额;认证成功后,api-server 异步尽力准备该用户对应的 Router 账号和 API Key,Router 控制面故障不得阻塞主站登录;LLM 请求解析阶段仍要求本地存在合法的已完成 provisioning 凭据,正式记录使用 `purpose=llm-router` 并保存服务端加密凭据。当前按 New API 管理接口执行正式 provisioning:由于 `username`、`password`、`display_name` 均限制 20 个字符,用户名固定为 `agc_user_` 加 11 位 URL-safe SHA-256 短码,密码为基于完整 owner `user_id` 与部署侧受保护 provisioning secret 稳定派生的 20 位 hex,展示名与短用户名一致;完整 owner `user_id` 写入 New API 用户 `remark`,本地 `llm_router_account.owner_user_id` 仍是平台权威映射。服务端先查询远端用户:已存在则直接登录,不重复注册;确认不存在时才由管理员创建普通用户,查询用户 ID,设置 `remark=<完整 owner user_id>` 与用户分组 `taonier`,再登录、查询并复用固定标识 `agc_auto_generate` 的 Token(Token/API Key 固定使用 `default` 分组;已有固定 Token 若分组不是 `default`,登录恢复时先通过 Token 更新接口纠正),签发 API Key。每次新建或准备 API Key 时,服务端在签发前查询该 Router 用户的固定套餐 `plan_id=1`;无 active 订阅、订阅已过期或剩余时间不超过 24 小时时调用管理员订阅接口新建一条订阅,剩余超过 24 小时则复用现有订阅。订阅查询/创建只使用 api-server 私有管理员 Token,不进入客户端或 Router Key;检查锚点是显式 Router Key 准备接口和新 Key provisioning,不放在 Responses 流式 chunk 中。由于 Router 公共而各部署数据库独立,所有能操作同一 Router 的部署必须使用相同的 provisioning secret。若任一步外部结果不确定,记录进入 reconciliation 状态,禁止重复注册;本地 API Key 写入失败则保留 `key_issued` 状态并用确定的 key id 重试落库。Router 密文加密优先使用 `GENARRATIVE_LLM_ROUTER_API_KEY_ENCRYPTION_SECRET`;缺省时使用带域分离的 `GENARRATIVE_JWT_SECRET` 派生密钥。Router Key 的明文只在 api-server 进程内短暂存在,`/api/profile/api-keys/llm-router` 只返回安全元数据;普通 External Editor Key 仍沿用创建接口明文只显示一次的正式链路。后续请求链路固定为: +本次 AGC Router 需求不改造原有 `external_api_key`。Router 账号、API Key 核心字段、生命周期和加密凭据统一保存在 `llm_router_account`;明文凭据只在 api-server 进程内短暂存在,并按 owner + route 进行 10 分钟内存缓存,轮换或撤销时立即清理。 + +普通 AGC 发行版不把 Router 当作客户端可配置 Provider,也不把 Router API Key 下发到桌面端。注册成功视为账号已有可用余额;认证成功后,api-server 异步尽力准备该用户对应的 Router 账号和 API Key,Router 控制面故障不得阻塞主站登录;LLM 请求解析阶段只读取 `llm_router_account` 中合法的已完成 provisioning 凭据。当前按 New API 管理接口执行正式 provisioning:由于 `username`、`password`、`display_name` 均限制 20 个字符,用户名固定为 `agc_user_` 加 11 位 URL-safe SHA-256 短码,密码为基于完整 owner `user_id` 与部署侧受保护 provisioning secret 稳定派生的 20 位 hex,展示名与短用户名一致;完整 owner `user_id` 写入 New API 用户 `remark`,本地 `llm_router_account.owner_user_id` 仍是平台权威映射。服务端先查询远端用户:已存在则直接登录,不重复注册;确认不存在时才由管理员创建普通用户,查询用户 ID,设置 `remark=<完整 owner user_id>` 与用户分组 `taonier`,再登录、查询并复用固定标识 `agc_auto_generate` 的 Token(Token/API Key 固定使用 `default` 分组;已有固定 Token 若分组不是 `default`,登录恢复时先通过 Token 更新接口纠正),签发 API Key。每次新建或准备 API Key 时,服务端在签发前查询该 Router 用户的固定套餐 `plan_id=1`;无 active 订阅、订阅已过期或剩余时间不超过 24 小时时调用管理员订阅接口新建一条订阅,剩余超过 24 小时则复用现有订阅。订阅查询/创建只使用 api-server 私有管理员 Token,不进入客户端或 Router Key;检查锚点是显式 Router Key 准备接口和新 Key provisioning,不放在 Responses 流式 chunk 中。由于 Router 公共而各部署数据库独立,所有能操作同一 Router 的部署必须使用相同的 provisioning secret。若任一步外部结果不确定,记录进入 reconciliation 状态,禁止重复注册;本地 API Key 写入失败则保留 `key_issued` 状态并用确定的 key id 重试落库。Router 密文加密优先使用 `GENARRATIVE_LLM_ROUTER_API_KEY_ENCRYPTION_SECRET`;缺省时使用带域分离的 `GENARRATIVE_JWT_SECRET` 派生密钥。Router Key 的明文只在 api-server 进程内短暂存在;缓存命中时不访问数据库,缓存未命中时从 `llm_router_account` 解密并写入 10 分钟进程内缓存;`/api/profile/api-keys/llm-router` 只返回安全元数据;普通 External Editor Key 仍沿用创建接口明文只显示一次的正式链路。后续请求链路固定为: ```text AGC loopback Provider Proxy(Bearer=平台 access token) -> POST /api/llm/responses - -> api-server 从 access token 得到 userId,查询并解密 external_api_key + -> api-server 从 access token 得到 userId,查询并解密 llm_router_account -> POST https://router.genarrative.world/v1/responses(Bearer=) ``` @@ -230,7 +232,7 @@ Windows 私有路径由同一套正式准备入口复用:AGC 自有 AppData、 管理员 Token 仅由 api-server 私有配置 `GENARRATIVE_LLM_ROUTER_ADMIN_TOKEN` 或对应文件提供,不进入客户端、数据库、日志或请求 payload。当前仓库无法验证真实管理员 Token、真实账号 Key 与生产 Router `/v1/responses` 联通;这些仍需在受控部署 smoke 中完成,客户端不得退回手工 Key。 -运行环境隔离是 provisioning 的前置门禁:所有环境都允许使用官方固定 Router 地址,开发环境因此可以通过完整 owner `user_id` 派生的稳定短用户名、稳定密码和 `agc_auto_generate` Token 复用共享 Router 账号;完整 owner `user_id` 同步写入 New API 用户 `remark`,便于 Router 侧统计回溯平台账号。非官方公网地址仍被拒绝,loopback 地址仅用于本地 fixture。使用共享官方 Router 的开发环境会实际创建/使用线上账号与额度,启动时必须告警。测试代码也不再通过进程级 fallback Key 旁路数据库;只有显式、按 owner 绑定的“已完成 provisioning” fixture 才能模拟已有账号,未提供 fixture 时必须经过 `external_api_key`/`llm_router_account` 读取或正式 provisioning,不能访问 Router。数据库记录缺失时,稳定短用户名/密码先尝试恢复远端账号;只有确认远端不存在才注册,避免跨部署数据库为空时重复创建用户。 +运行环境隔离是 provisioning 的前置门禁:所有环境都允许使用官方固定 Router 地址,开发环境因此可以通过完整 owner `user_id` 派生的稳定短用户名、稳定密码和 `agc_auto_generate` Token 复用共享 Router 账号;完整 owner `user_id` 同步写入 New API 用户 `remark`,便于 Router 侧统计回溯平台账号。非官方公网地址仍被拒绝,loopback 地址仅用于本地 fixture。使用共享官方 Router 的开发环境会实际创建/使用线上账号与额度,启动时必须告警。测试代码也不再通过进程级 fallback Key 旁路数据库;只有显式、按 owner 绑定的“已完成 provisioning” fixture 才能模拟已有账号,未提供 fixture 时必须经过 `llm_router_account` 读取或正式 provisioning,不能访问 Router。数据库记录缺失时,稳定短用户名/密码先尝试恢复远端账号;只有确认远端不存在才注册,避免跨部署数据库为空时重复创建用户。 ## 素材生成与落库 diff --git a/server-rs/crates/api-server/src/external_api_keys.rs b/server-rs/crates/api-server/src/external_api_keys.rs index bfba95732..45217ccd6 100644 --- a/server-rs/crates/api-server/src/external_api_keys.rs +++ b/server-rs/crates/api-server/src/external_api_keys.rs @@ -23,6 +23,7 @@ use spacetime_client::{ }; use std::collections::HashMap; use std::sync::{Arc, OnceLock}; +use std::time::{Duration, Instant}; use tokio::sync::Mutex; use crate::{ @@ -56,11 +57,20 @@ const EXTERNAL_API_KEY_PURPOSE_EXTERNAL_EDITOR: &str = "external-editor"; const EXTERNAL_API_KEY_PURPOSE_LLM_ROUTER: &str = "llm-router"; const LLM_ROUTER_RECONCILIATION_ERROR_PREFIX: &str = "llm-router-reconciliation-required:"; const LLM_ROUTER_PROVISIONING_CREDENTIAL_VERSION: u32 = 1; +const LLM_ROUTER_CREDENTIAL_CACHE_TTL: Duration = Duration::from_secs(600); type HmacSha256 = Hmac; static LLM_ROUTER_PROVISION_LOCKS: OnceLock>>>> = OnceLock::new(); +static LLM_ROUTER_CREDENTIAL_CACHE: OnceLock>> = + OnceLock::new(); + +struct CachedRouterCredential { + expires_at: Instant, + api_key: String, + key_id: String, +} struct ProvisionedRouterCredential { provider_account_id: String, @@ -84,9 +94,8 @@ struct RouterTokenMatch { } /// The Router API key and the credentials used to obtain it are one private -/// server-side secret. The existing `secret_ciphertext` column stores this -/// bundle for LLM Router rows; legacy rows containing an encrypted raw key remain -/// readable through `decrypt_router_api_key`. +/// server-side secret. The dedicated `llm_router_account.credential_ciphertext` +/// column stores this bundle; plaintext never leaves api-server. #[derive(Debug, Deserialize, Serialize)] struct RouterCredentialSecret { version: u32, @@ -100,6 +109,10 @@ fn llm_router_provision_locks() -> &'static Mutex> LLM_ROUTER_PROVISION_LOCKS.get_or_init(|| Mutex::new(HashMap::new())) } +fn llm_router_credential_cache() -> &'static Mutex> { + LLM_ROUTER_CREDENTIAL_CACHE.get_or_init(|| Mutex::new(HashMap::new())) +} + #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] pub struct ExternalApiKeyCreateRequest { @@ -212,24 +225,24 @@ pub async fn ensure_llm_router_api_key( Extension(request_context): Extension, Extension(authenticated): Extension, ) -> Result, AppError> { - let key = ensure_llm_router_account(&state, authenticated.claims().user_id()) + let account = ensure_llm_router_account(&state, authenticated.claims().user_id()) .await .map_err(|message| { AppError::from_status(StatusCode::SERVICE_UNAVAILABLE).with_message(message) })?; Ok(json_success_body( Some(&request_context), - json!({ "key": external_api_key_payload_from_record(key) }), + json!({ "key": external_api_key_payload_from_router_account(account) }), )) } /// Ensures that the authenticated account has one server-owned Router key. /// The plaintext key never leaves this function and is persisted only as -/// authenticated encryption ciphertext in the existing external_api_key row. +/// authenticated encryption ciphertext in the dedicated llm_router_account row. pub(crate) async fn ensure_llm_router_account( state: &AppState, owner_user_id: &str, -) -> Result { +) -> Result { let owner_user_id = owner_user_id.trim(); if owner_user_id.is_empty() { return Err("LLM Router 账号缺少 owner_user_id".to_string()); @@ -247,58 +260,47 @@ pub(crate) async fn ensure_llm_router_account( ) }; let _guard = provision_lock.lock().await; - let existing = state - .spacetime_client() - .list_external_api_keys(owner_user_id.to_string()) - .await - .map_err(|error| format!("读取 LLM Router 账号失败:{error}"))? - .into_iter() - .collect::>(); let configured_route = state.config.llm_router_base_url.trim_end_matches('/'); - let configured_model = state.config.llm_router_model.trim(); let encryption_secret = state .config .effective_llm_router_api_key_encryption_secret() .ok_or_else(|| "LLM Router 账号密钥加密配置缺失".to_string())?; - let existing_account = select_existing_llm_router_account( - &existing, - configured_route, - configured_model, - encryption_secret.as_str(), - )?; - if let Some(existing) = existing_account { - tracing::debug!( - owner_user_id, - key_id = %existing.key_id, - "复用已存在的 LLM Router external_api_key" - ); - ensure_existing_llm_router_account_contract(state, existing).await?; - return Ok(existing.clone()); - } let account_key = derive_llm_router_account_key(owner_user_id, configured_route); let mut persisted = state .spacetime_client() .get_llm_router_account(owner_user_id.to_string(), configured_route.to_string()) .await .map_err(|error| format!("读取 LLM Router 账号状态失败:{error}"))?; + if let Some(existing) = persisted.as_ref().filter(|account| { + account.status == "active" + && account.revoked_at.is_none() + && account + .credential_ciphertext + .as_deref() + .is_some_and(|value| !value.trim().is_empty()) + }) { + tracing::debug!( + owner_user_id, + key_id = %existing.key_id, + "复用已存在的 LLM Router 账号 Key" + ); + ensure_existing_llm_router_account_contract(state, existing).await?; + return Ok(existing.clone()); + } - // A previous process may have revoked the external row before it crashed - // while updating the saga row. Repair that local state from the authoritative - // key list before deciding whether the account is blocked or reusable. + // A previous process may have revoked the Router account before it crashed + // while updating the saga row. Repair that local state before deciding whether + // the account is blocked or reusable. if let Some(account) = persisted.as_ref() - && let Some(external_key_id) = account.external_api_key_id.as_deref() - && existing - .iter() - .any(|record| record.key_id == external_key_id && record.revoked_at.is_some()) + && account.revoked_at.is_some() { let repaired = upsert_llm_router_account_state( state, account.account_key.as_str(), owner_user_id, configured_route, - external_key_id, + account.key_id.as_str(), account.router_account_id.clone(), - None, account.credential_ciphertext.clone(), account.account_json.clone(), "retryable", @@ -361,8 +363,9 @@ pub(crate) async fn ensure_llm_router_account( let key_id = persisted .as_ref() - .and_then(|account| account.external_api_key_id.clone()) - .unwrap_or_else(|| derive_llm_router_key_id(owner_user_id, configured_route, &existing)); + .map(|account| account.key_id.clone()) + .filter(|value| !value.trim().is_empty()) + .unwrap_or_else(|| derive_llm_router_key_id(owner_user_id, configured_route)); let mut reusable_login = None; let mut generated_new_pending_credentials = false; @@ -370,21 +373,8 @@ pub(crate) async fn ensure_llm_router_account( // A historical `llm-router` row may predate the saga table. Reuse its // encrypted username/password when the old key was revoked; otherwise // a fresh Router user would be created for the same Genarrative owner. - if let Some(login) = find_reusable_router_account_login( - &existing, - configured_route, - configured_model, - encryption_secret.as_str(), - )? { - reusable_login = Some(login.clone()); - bundle = Some(RouterCredentialSecret { - version: 1, - api_key: String::new(), - username: Some(login.username), - password: Some(login.password), - access_token: None, - }); - } + // The dedicated row already owns the encrypted login bundle; no + // historical API-key row is consulted for Router recovery. } if bundle.is_none() { @@ -406,7 +396,6 @@ pub(crate) async fn ensure_llm_router_account( configured_route, &key_id, None, - None, Some(ciphertext), persisted .as_ref() @@ -427,23 +416,7 @@ pub(crate) async fn ensure_llm_router_account( .as_ref() .is_some_and(|value| matches!(value.status.as_str(), "key_issued" | "active")) { - let key = persist_llm_router_external_key( - state, - owner_user_id, - configured_route, - configured_model, - &key_id, - existing_bundle, - persisted - .as_ref() - .and_then(|value| value.router_account_id.clone()), - persisted - .as_ref() - .and_then(|value| value.account_json.clone()), - encryption_secret.as_str(), - ) - .await?; - upsert_llm_router_account_state( + let active_account = upsert_llm_router_account_state( state, &account_key, owner_user_id, @@ -452,10 +425,10 @@ pub(crate) async fn ensure_llm_router_account( persisted .as_ref() .and_then(|value| value.router_account_id.clone()), - Some(key_id.clone()), - persisted - .as_ref() - .and_then(|value| value.credential_ciphertext.clone()), + Some(encrypt_router_credential_secret( + existing_bundle, + encryption_secret.as_str(), + )?), persisted .as_ref() .and_then(|value| value.account_json.clone()), @@ -466,7 +439,8 @@ pub(crate) async fn ensure_llm_router_account( None, ) .await?; - return Ok(key); + ensure_existing_llm_router_account_contract(state, &active_account).await?; + return Ok(active_account); } } @@ -511,7 +485,6 @@ pub(crate) async fn ensure_llm_router_account( persisted .as_ref() .and_then(|value| value.router_account_id.clone()), - None, pending_ciphertext.clone(), persisted .as_ref() @@ -548,7 +521,6 @@ pub(crate) async fn ensure_llm_router_account( persisted .as_ref() .and_then(|value| value.router_account_id.clone()), - None, pending_ciphertext, persisted .as_ref() @@ -586,7 +558,6 @@ pub(crate) async fn ensure_llm_router_account( configured_route, &key_id, Some(provisioned.provider_account_id.clone()), - None, Some(secret_ciphertext.clone()), provisioned.provider_account_json.clone(), "key_issued", @@ -596,103 +567,29 @@ pub(crate) async fn ensure_llm_router_account( None, ) .await?; - let key = state - .spacetime_client() - .create_external_api_key(ExternalApiKeyCreateRecordInput { - key_id: key_id.clone(), - owner_user_id: owner_user_id.to_string(), - name: LLM_ROUTER_TOKEN_IDENTIFIER.to_string(), - key_prefix: external_api_key_prefix(provisioned.raw_key.as_str()), - key_hash: hash_llm_router_key( - owner_user_id, - key_id.as_str(), - provisioned.raw_key.as_str(), - ), - scopes: LLM_ROUTER_API_KEY_SCOPES - .iter() - .map(|scope| (*scope).to_string()) - .collect(), - now_micros: current_utc_micros(), - purpose: EXTERNAL_API_KEY_PURPOSE_LLM_ROUTER.to_string(), - secret_ciphertext: Some(secret_ciphertext.clone()), - provider_account_id: Some(provisioned.provider_account_id.clone()), - provider_base_url: Some(state.config.llm_router_base_url.clone()), - provider_model: Some(state.config.llm_router_model.clone()), - provider_account_json: provisioned.provider_account_json.clone(), - credential_version: 1, - }) - .await; - match key { - Ok(key) => { - upsert_llm_router_account_state( - state, - &account_key, - owner_user_id, - configured_route, - &key_id, - Some(provisioned.provider_account_id.clone()), - Some(key_id.clone()), - Some(secret_ciphertext), - provisioned.provider_account_json.clone(), - "active", - attempt_count, - None, - None, - None, - ) - .await?; - tracing::info!( - owner_user_id, - key_id = %key.key_id, - router_account_id = ?provisioned.provider_account_id, - "LLM Router 账号与 external_api_key provisioning 完成" - ); - Ok(key) - } - Err(error) => { - // Multiple api-server instances do not share the in-process mutex. - // If another instance won the insert race, reuse its validated row - // instead of reporting a spurious failure or provisioning again. - if let Ok(refreshed) = state - .spacetime_client() - .list_external_api_keys(owner_user_id.to_string()) - .await - { - if let Ok(Some(existing)) = select_existing_llm_router_account( - &refreshed, - configured_route, - configured_model, - encryption_secret.as_str(), - ) { - return Ok(existing.clone()); - } - } - // The remote key has already been issued and the local write uses a - // deterministic key id. Keep the saga at key_issued so the next - // authenticated request can safely retry the idempotent local write; - // reconciliation_required is reserved for an uncertain remote side - // effect (registration/login/token/key issuance). - let message = format!("LLM Router Key 已签发但本地落库未完成:{error}"); - let _ = upsert_llm_router_account_state( - state, - &account_key, - owner_user_id, - configured_route, - &key_id, - Some(provisioned.provider_account_id), - None, - Some(secret_ciphertext), - provisioned.provider_account_json, - "key_issued", - attempt_count, - None, - Some(current_utc_micros().saturating_add(60_000_000)), - Some(message.clone()), - ) - .await; - Err(message) - } - } + let account = upsert_llm_router_account_state( + state, + &account_key, + owner_user_id, + configured_route, + &key_id, + Some(provisioned.provider_account_id.clone()), + Some(secret_ciphertext), + provisioned.provider_account_json.clone(), + "active", + attempt_count, + None, + None, + None, + ) + .await?; + tracing::info!( + owner_user_id, + key_id = %account.key_id, + router_account_id = ?provisioned.provider_account_id, + "LLM Router 账号与 API Key provisioning 完成" + ); + Ok(account) } async fn upsert_llm_router_account_state( @@ -700,9 +597,8 @@ async fn upsert_llm_router_account_state( account_key: &str, owner_user_id: &str, route_origin: &str, - external_api_key_id: &str, + key_id: &str, router_account_id: Option, - persisted_external_api_key_id: Option, credential_ciphertext: Option, account_json: Option, status: &str, @@ -711,73 +607,59 @@ async fn upsert_llm_router_account_state( next_retry_at_micros: Option, last_error: Option, ) -> Result { + let key_material = credential_ciphertext.as_deref().and_then(|ciphertext| { + state + .config + .effective_llm_router_api_key_encryption_secret() + .and_then(|secret| decrypt_router_credential_secret(ciphertext, secret.as_str()).ok()) + .filter(|bundle| !bundle.api_key.trim().is_empty()) + .map(|bundle| { + let raw_key = bundle.api_key.trim().to_string(); + ( + external_api_key_prefix(raw_key.as_str()), + hash_llm_router_key(owner_user_id, key_id, raw_key.as_str()), + ) + }) + }); state .spacetime_client() .upsert_llm_router_account(LlmRouterAccountUpsertRecordInput { account_key: account_key.to_string(), owner_user_id: owner_user_id.to_string(), route_origin: route_origin.to_string(), - idempotency_key: external_api_key_id.to_string(), + idempotency_key: key_id.to_string(), router_account_id, - external_api_key_id: persisted_external_api_key_id, credential_ciphertext, account_json, status: status.to_string(), attempt_count, lease_until_micros, next_retry_at_micros, - last_error, + last_error: last_error.clone(), credential_version: 1, now_micros: current_utc_micros(), + key_id: key_id.to_string(), + key_name: LLM_ROUTER_TOKEN_IDENTIFIER.to_string(), + key_prefix: key_material.as_ref().map(|(prefix, _)| prefix.clone()), + key_hash: key_material.map(|(_, hash)| hash), + last_used_at_micros: None, + revoked_at_micros: last_error + .as_deref() + .filter(|value| { + value.contains("Router 返回 401/403") || value.contains("Router Key 已撤销") + }) + .map(|_| current_utc_micros()), }) .await .map_err(|error| format!("保存 LLM Router 账号状态失败:{error}")) } -async fn persist_llm_router_external_key( - state: &AppState, - owner_user_id: &str, - route_origin: &str, - model: &str, - key_id: &str, - bundle: &RouterCredentialSecret, - router_account_id: Option, - account_json: Option, - encryption_secret: &str, -) -> Result { - let raw_key = normalize_router_api_key(bundle.api_key.as_str())?; - let secret_ciphertext = encrypt_router_credential_secret(bundle, encryption_secret)?; - state - .spacetime_client() - .create_external_api_key(ExternalApiKeyCreateRecordInput { - key_id: key_id.to_string(), - owner_user_id: owner_user_id.to_string(), - name: LLM_ROUTER_TOKEN_IDENTIFIER.to_string(), - key_prefix: external_api_key_prefix(raw_key.as_str()), - key_hash: hash_llm_router_key(owner_user_id, key_id, raw_key.as_str()), - scopes: LLM_ROUTER_API_KEY_SCOPES - .iter() - .map(|scope| (*scope).to_string()) - .collect(), - now_micros: current_utc_micros(), - purpose: EXTERNAL_API_KEY_PURPOSE_LLM_ROUTER.to_string(), - secret_ciphertext: Some(secret_ciphertext), - provider_account_id: router_account_id, - provider_base_url: Some(route_origin.to_string()), - provider_model: Some(model.to_string()), - provider_account_json: account_json, - credential_version: 1, - }) - .await - .map_err(|error| format!("LLM Router Key 补写失败:{error}")) -} - /// Ensures the Router user has the fixed AGC subscription before the server /// exposes/reuses its API key. Subscription management is deliberately tied to /// authentication/key preparation rather than the streaming response path. async fn ensure_existing_llm_router_account_contract( state: &AppState, - record: &ExternalApiKeyRecord, + record: &LlmRouterAccountRecord, ) -> Result<(), String> { let Some(admin_token) = state .config @@ -796,7 +678,7 @@ async fn ensure_existing_llm_router_account_contract( .effective_llm_router_api_key_encryption_secret() .ok_or_else(|| "LLM Router 账号密钥加密配置缺失".to_string())?; let bundle = record - .secret_ciphertext + .credential_ciphertext .as_deref() .filter(|value| !value.trim().is_empty()) .map(|ciphertext| decrypt_router_credential_secret(ciphertext, encryption_secret.as_str())) @@ -820,7 +702,7 @@ async fn ensure_existing_llm_router_account_contract( .ok_or_else(|| { format!("{LLM_ROUTER_RECONCILIATION_ERROR_PREFIX}无法通过用户名查询 Router 用户 ID") })?; - if let Some(recorded_router_user_id) = router_user_id_from_record(record) + if let Some(recorded_router_user_id) = router_user_id_from_account(record) && recorded_router_user_id != router_user_id { return Err(format!( @@ -1044,14 +926,14 @@ async fn ensure_router_subscription( Ok(()) } -fn router_user_id_from_record(record: &ExternalApiKeyRecord) -> Option { +fn router_user_id_from_account(record: &LlmRouterAccountRecord) -> Option { record - .provider_account_id + .router_account_id .as_deref() .and_then(|value| value.trim().parse::().ok()) .or_else(|| { record - .provider_account_json + .account_json .as_deref() .and_then(|value| serde_json::from_str::(value).ok()) .and_then(|value| { @@ -1124,28 +1006,61 @@ pub(crate) async fn read_active_llm_router_credentials( return Err("LLM Router 账号缺少 owner_user_id".to_string()); } ensure_llm_router_target_allowed(state)?; + let cache_key = format!( + "{}|{}", + owner_user_id, + state.config.llm_router_base_url.trim_end_matches('/') + ); + let now = Instant::now(); + let mut cache = llm_router_credential_cache().lock().await; + if let Some(cached) = cache + .get(cache_key.as_str()) + .filter(|entry| entry.expires_at > now) + { + return Ok(Some(( + state + .config + .llm_router_base_url + .trim_end_matches('/') + .to_string(), + cached.api_key.clone(), + cached.key_id.clone(), + ))); + } + cache.remove(cache_key.as_str()); + drop(cache); let encryption_secret = state .config .effective_llm_router_api_key_encryption_secret() .ok_or_else(|| "LLM Router 账号密钥加密配置缺失".to_string())?; - let records = state + let Some(account) = state .spacetime_client() - .list_external_api_keys(owner_user_id.to_string()) + .get_llm_router_account( + owner_user_id.to_string(), + state + .config + .llm_router_base_url + .trim_end_matches('/') + .to_string(), + ) .await - .map_err(|error| format!("读取 LLM Router 账号失败:{error}"))?; - let Some(record) = select_existing_llm_router_account( - records.as_slice(), - state.config.llm_router_base_url.trim_end_matches('/'), - state.config.llm_router_model.trim(), - encryption_secret.as_str(), - )? + .map_err(|error| format!("读取 LLM Router 账号失败:{error}"))? + .filter(|account| account.status == "active" && account.revoked_at.is_none()) else { return Ok(None); }; - let ciphertext = record.secret_ciphertext.as_deref().ok_or_else(|| { + let ciphertext = account.credential_ciphertext.as_deref().ok_or_else(|| { format!("{LLM_ROUTER_RECONCILIATION_ERROR_PREFIX}active Router Key 密文缺失") })?; let api_key = decrypt_router_api_key(ciphertext, encryption_secret.as_str())?; + llm_router_credential_cache().lock().await.insert( + cache_key, + CachedRouterCredential { + expires_at: Instant::now() + LLM_ROUTER_CREDENTIAL_CACHE_TTL, + api_key: api_key.clone(), + key_id: account.key_id.clone(), + }, + ); Ok(Some(( state .config @@ -1153,150 +1068,26 @@ pub(crate) async fn read_active_llm_router_credentials( .trim_end_matches('/') .to_string(), api_key, - record.key_id.clone(), + account.key_id, ))) } -/// Validates the persisted LLM Router state before provisioning another Router key. -/// -/// The table intentionally has no composite unique constraint because legacy -/// external-editor rows and historical deployments must remain readable. We -/// therefore enforce the LLM Router invariant at the api-server boundary: at most one -/// active row, exact route/model metadata, and decryptable ciphertext. -fn select_existing_llm_router_account<'a>( - records: &'a [ExternalApiKeyRecord], - configured_route: &str, - configured_model: &str, - encryption_secret: &str, -) -> Result, String> { - let active = records - .iter() - .filter(|record| { - is_llm_router_purpose(record.purpose.as_str()) && record.revoked_at.is_none() - }) - .collect::>(); - if active.len() > 1 { - return Err(format!( - "{LLM_ROUTER_RECONCILIATION_ERROR_PREFIX}账号存在多个 active Router Key,请联系支持完成对账" - )); - } - let Some(record) = active.first().copied() else { - return Ok(None); - }; - - let metadata_matches = record - .provider_base_url - .as_deref() - .map(|value| value.trim_end_matches('/')) - == Some(configured_route) - && record.provider_model.as_deref().map(str::trim) == Some(configured_model) - && record - .provider_account_id - .as_deref() - .is_some_and(|value| !value.trim().is_empty()); - let Some(ciphertext) = record - .secret_ciphertext - .as_deref() - .filter(|value| !value.trim().is_empty()) - else { - return Err(format!( - "{LLM_ROUTER_RECONCILIATION_ERROR_PREFIX}active Router Key 密文缺失,请联系支持完成对账" - )); - }; - if !metadata_matches { - return Err(format!( - "{LLM_ROUTER_RECONCILIATION_ERROR_PREFIX}active Router Key 路由元数据不一致,请联系支持完成对账" - )); - } - decrypt_router_api_key(ciphertext, encryption_secret).map_err(|error| { - format!("{LLM_ROUTER_RECONCILIATION_ERROR_PREFIX}active Router Key 密文无法校验:{error}") - })?; - Ok(Some(record)) -} - -/// Finds the one historical Router account whose encrypted bundle still -/// contains the username/password needed to sign a replacement API key after -/// the previous key was revoked. Current deterministic credentials are derived -/// directly when the local row is missing; this helper only covers legacy rows -/// that still need their stored username/password for recovery. -fn find_reusable_router_account_login( - records: &[ExternalApiKeyRecord], - configured_route: &str, - configured_model: &str, - encryption_secret: &str, -) -> Result, String> { - let mut candidates = Vec::new(); - for record in records.iter().filter(|record| { - is_llm_router_purpose(record.purpose.as_str()) && record.revoked_at.is_some() - }) { - let route_matches = record - .provider_base_url - .as_deref() - .map(|value| value.trim_end_matches('/')) - == Some(configured_route) - && record.provider_model.as_deref().map(str::trim) == Some(configured_model); - if !route_matches { - continue; - } - let Some(ciphertext) = record - .secret_ciphertext - .as_deref() - .filter(|value| !value.trim().is_empty()) - else { - continue; - }; - let bundle = - decrypt_router_credential_secret(ciphertext, encryption_secret).map_err(|error| { - format!( - "{LLM_ROUTER_RECONCILIATION_ERROR_PREFIX}历史 Router 账号凭据无法校验:{error}" - ) - })?; - let (Some(username), Some(password)) = (bundle.username, bundle.password) else { - continue; - }; - candidates.push(RouterAccountLogin { username, password }); - } - - match candidates.len() { - 0 => Ok(None), - 1 => Ok(candidates.pop()), - _ => Err(format!( - "{LLM_ROUTER_RECONCILIATION_ERROR_PREFIX}存在多个可复用的历史 Router 账号凭据,请联系支持完成对账" - )), - } -} - -fn is_llm_router_purpose(value: &str) -> bool { - value.trim() == EXTERNAL_API_KEY_PURPOSE_LLM_ROUTER -} - pub(crate) async fn revoke_llm_router_account( state: &AppState, owner_user_id: &str, key_id: &str, ) -> Result<(), String> { - state - .spacetime_client() - .revoke_llm_router_api_key(ExternalApiKeyRevokeRecordInput { - key_id: key_id.to_string(), - owner_user_id: owner_user_id.to_string(), - revoked_at_micros: current_utc_micros(), - }) - .await - .map_err(|error| format!("标记 LLM Router 账号 Key 失效失败:{error}"))?; - - // Keep the durable saga in a retryable state after a deterministic Router - // 401/403. The encrypted username/password bundle is retained so the next - // request can log in again and issue a replacement key without registering - // a second Router account. Clearing external_api_key_id also forces a new - // deterministic generation id for the replacement row. let route_origin = state.config.llm_router_base_url.trim_end_matches('/'); + llm_router_credential_cache() + .lock() + .await + .remove(format!("{}|{}", owner_user_id, route_origin).as_str()); if let Some(account) = state .spacetime_client() .get_llm_router_account(owner_user_id.to_string(), route_origin.to_string()) .await .map_err(|error| format!("读取 LLM Router 账号失效状态失败:{error}"))? - .filter(|account| account.external_api_key_id.as_deref() == Some(key_id)) + .filter(|account| account.key_id == key_id) { upsert_llm_router_account_state( state, @@ -1305,7 +1096,6 @@ pub(crate) async fn revoke_llm_router_account( route_origin, key_id, account.router_account_id.clone(), - None, account.credential_ciphertext.clone(), account.account_json.clone(), "retryable", @@ -2258,22 +2048,12 @@ fn provider_value_to_string(value: &Value) -> Option { .filter(|value| !value.is_empty()) } -fn derive_llm_router_key_id( - owner_user_id: &str, - route_origin: &str, - records: &[ExternalApiKeyRecord], -) -> String { - let generation = records - .iter() - .filter(|record| is_llm_router_purpose(record.purpose.as_str())) - .count() - .saturating_add(1); +fn derive_llm_router_key_id(owner_user_id: &str, route_origin: &str) -> String { let fingerprint = Sha256::digest( format!( - "{}\n{}\n{}", + "{}\n{}", owner_user_id.trim(), - route_origin.trim_end_matches('/'), - generation + route_origin.trim_end_matches('/') ) .as_bytes(), ); @@ -2321,59 +2101,6 @@ fn normalize_new_api_relay_api_key(value: &str) -> Result { normalize_router_api_key(value) } -fn sanitize_provider_account_json(value: &Value) -> Option { - let sanitized = sanitize_provider_account_value(value); - (!sanitized.is_null()).then(|| sanitized.to_string()) -} - -fn sanitize_provider_account_value(value: &Value) -> Value { - match value { - Value::Object(object) => Value::Object( - object - .iter() - .filter(|(key, _)| !is_sensitive_provider_field(key)) - .map(|(key, value)| (key.clone(), sanitize_provider_account_value(value))) - .collect(), - ), - Value::Array(values) => { - Value::Array(values.iter().map(sanitize_provider_account_value).collect()) - } - other => other.clone(), - } -} - -fn is_sensitive_provider_field(key: &str) -> bool { - matches!( - key.chars() - .filter(|character| character.is_ascii_alphanumeric()) - .collect::() - .to_ascii_lowercase() - .as_str(), - "apikey" - | "accesstoken" - | "refreshtoken" - | "authorization" - | "secret" - | "password" - | "credential" - | "credentials" - ) -} - -pub(crate) fn encrypt_router_api_key(raw_key: &str, secret: &str) -> Result { - encrypt_router_secret_payload( - &serde_json::to_vec(&RouterCredentialSecret { - version: 1, - api_key: normalize_router_api_key(raw_key)?, - username: None, - password: None, - access_token: None, - }) - .map_err(|error| format!("LLM Router API Key 序列化失败:{error}"))?, - secret, - ) -} - fn encrypt_router_credential_secret( secret_payload: &RouterCredentialSecret, secret: &str, @@ -2469,18 +2196,6 @@ pub async fn revoke_external_api_key( Extension(request_context): Extension, Extension(authenticated): Extension, ) -> Result, AppError> { - let records = state - .spacetime_client() - .list_external_api_keys(authenticated.claims().user_id().to_string()) - .await - .map_err(map_external_api_key_error)?; - if records - .iter() - .any(|record| record.key_id == key_id && is_llm_router_purpose(record.purpose.as_str())) - { - return Err(AppError::from_status(StatusCode::FORBIDDEN) - .with_message("LLM Router Key 由服务端管理,不能通过个人 API Key 接口撤销")); - } let key = state .spacetime_client() .revoke_external_api_key(ExternalApiKeyRevokeRecordInput { @@ -2539,6 +2254,25 @@ fn external_api_key_payload_from_record(record: ExternalApiKeyRecord) -> Externa } } +fn external_api_key_payload_from_router_account( + account: LlmRouterAccountRecord, +) -> ExternalApiKeyPayload { + ExternalApiKeyPayload { + key_id: account.key_id, + name: account.key_name, + key_prefix: account.key_prefix.unwrap_or_default(), + scopes: LLM_ROUTER_API_KEY_SCOPES + .iter() + .map(|scope| (*scope).to_string()) + .collect(), + purpose: EXTERNAL_API_KEY_PURPOSE_LLM_ROUTER.to_string(), + created_at: account.created_at, + last_used_at: account.last_used_at, + revoked_at: account.revoked_at, + updated_at: account.updated_at, + } +} + pub(crate) fn map_external_api_key_error(error: SpacetimeClientError) -> AppError { match error { SpacetimeClientError::Procedure(message) @@ -2581,35 +2315,6 @@ mod tests { use crate::{config::AppConfig, state::AppState}; - fn llm_router_record( - key_id: &str, - route: Option<&str>, - model: Option<&str>, - ciphertext: Option<&str>, - revoked: bool, - ) -> ExternalApiKeyRecord { - ExternalApiKeyRecord { - key_id: key_id.to_string(), - owner_user_id: "user-1".to_string(), - name: LLM_ROUTER_TOKEN_IDENTIFIER.to_string(), - key_prefix: "tnr_sk_fixture".to_string(), - scopes: vec!["llm:responses".to_string()], - purpose: EXTERNAL_API_KEY_PURPOSE_LLM_ROUTER.to_string(), - created_at: "2026-08-29T00:00:00Z".to_string(), - last_used_at: None, - revoked_at: revoked.then(|| "2026-08-29T01:00:00Z".to_string()), - updated_at: "2026-08-29T00:00:00Z".to_string(), - secret_ciphertext: ciphertext.map(|value| value.to_string()), - provider_account_id: Some("llm-router-account-fixture".to_string()), - provider_base_url: route.map(|value| value.to_string()), - provider_model: model.map(|value| value.to_string()), - provider_account_json: Some( - json!({"accountId":"llm-router-account-fixture"}).to_string(), - ), - credential_version: 1, - } - } - #[test] fn generated_external_api_key_uses_public_prefix_only_for_display() { let raw_key = generate_external_api_key_secret(); @@ -2993,19 +2698,30 @@ mod tests { "fixture-encryption-secret", ) .expect("credential should encrypt"); - let mut record = llm_router_record( - "key-active", - Some(base_url.as_str()), - Some("gpt-5.6-sol"), - Some(ciphertext.as_str()), - false, - ); - record.owner_user_id = owner_user_id.to_string(); - record.provider_account_id = Some("42".to_string()); - record.provider_account_json = - Some(json!({"routerUserId": 42, "username": username}).to_string()); - - ensure_existing_llm_router_account_contract(&state, &record) + let account = LlmRouterAccountRecord { + account_key: "fixture-account".to_string(), + owner_user_id: owner_user_id.to_string(), + route_origin: base_url.trim_end_matches('/').to_string(), + idempotency_key: "key-active".to_string(), + router_account_id: Some("42".to_string()), + credential_ciphertext: Some(ciphertext), + account_json: Some(json!({"routerUserId": 42, "username": username}).to_string()), + status: "active".to_string(), + attempt_count: 0, + lease_until_micros: None, + next_retry_at_micros: None, + last_error: None, + credential_version: 1, + created_at: "2026-08-29T00:00:00Z".to_string(), + updated_at: "2026-08-29T00:00:00Z".to_string(), + key_id: "key-active".to_string(), + key_name: LLM_ROUTER_TOKEN_IDENTIFIER.to_string(), + key_prefix: Some("tnr_sk_fixture".to_string()), + key_hash: None, + last_used_at: None, + revoked_at: None, + }; + ensure_existing_llm_router_account_contract(&state, &account) .await .expect("active Router contract should repair"); @@ -3105,159 +2821,6 @@ mod tests { ); } - #[test] - fn llm_router_key_id_is_stable_and_generation_scoped() { - let first_generation = - derive_llm_router_key_id("user-1", "https://router.example/v1/", &[]); - let same_generation = derive_llm_router_key_id("user-1", "https://router.example/v1", &[]); - assert_eq!(first_generation, same_generation); - - let first_row = llm_router_record( - &first_generation, - Some("https://router.example/v1"), - Some("gpt-5.6-sol"), - None, - true, - ); - let second_generation = - derive_llm_router_key_id("user-1", "https://router.example/v1", &[first_row]); - assert_ne!(first_generation, second_generation); - - let different_owner = derive_llm_router_key_id("user-2", "https://router.example/v1", &[]); - let different_route = derive_llm_router_key_id("user-1", "https://router.example/v2", &[]); - assert_ne!(first_generation, different_owner); - assert_ne!(first_generation, different_route); - } - - #[test] - fn existing_llm_router_account_requires_validated_single_active_row() { - let ciphertext = encrypt_router_api_key("tnr_sk_fixture_123", "test-secret") - .expect("fixture ciphertext"); - let record = llm_router_record( - "key-1", - Some("https://router.example/v1"), - Some("gpt-5.6-sol"), - Some(&ciphertext), - false, - ); - let single = [record.clone()]; - let selected = select_existing_llm_router_account( - &single, - "https://router.example/v1", - "gpt-5.6-sol", - "test-secret", - ) - .expect("valid LLM Router row should be reusable") - .expect("active LLM Router row"); - assert_eq!(selected.key_id, "key-1"); - - let duplicate_error = select_existing_llm_router_account( - &[ - record.clone(), - llm_router_record( - "key-2", - Some("https://router.example/v1"), - Some("gpt-5.6-sol"), - Some(&ciphertext), - false, - ), - ], - "https://router.example/v1", - "gpt-5.6-sol", - "test-secret", - ) - .expect_err("duplicate active rows must reconcile"); - assert!(duplicate_error.starts_with(LLM_ROUTER_RECONCILIATION_ERROR_PREFIX)); - } - - #[test] - fn existing_llm_router_account_rejects_missing_or_corrupt_state() { - let missing_ciphertext = select_existing_llm_router_account( - &[llm_router_record( - "key-1", - Some("https://router.example/v1"), - Some("gpt-5.6-sol"), - None, - false, - )], - "https://router.example/v1", - "gpt-5.6-sol", - "test-secret", - ) - .expect_err("missing ciphertext must reconcile"); - assert!(missing_ciphertext.starts_with(LLM_ROUTER_RECONCILIATION_ERROR_PREFIX)); - - let corrupt_ciphertext = select_existing_llm_router_account( - &[llm_router_record( - "key-1", - Some("https://router.example/v1"), - Some("gpt-5.6-sol"), - Some("not-a-ciphertext"), - false, - )], - "https://router.example/v1", - "gpt-5.6-sol", - "test-secret", - ) - .expect_err("corrupt ciphertext must reconcile"); - assert!(corrupt_ciphertext.starts_with(LLM_ROUTER_RECONCILIATION_ERROR_PREFIX)); - - let metadata_error = select_existing_llm_router_account( - &[llm_router_record( - "key-1", - Some("https://router.example/v2"), - Some("gpt-5.6-sol"), - Some("not-a-ciphertext"), - false, - )], - "https://router.example/v1", - "gpt-5.6-sol", - "test-secret", - ) - .expect_err("route drift must reconcile"); - assert!(metadata_error.starts_with(LLM_ROUTER_RECONCILIATION_ERROR_PREFIX)); - } - - #[test] - fn revoked_llm_router_account_is_not_reused() { - let ciphertext = encrypt_router_api_key("tnr_sk_fixture_123", "test-secret") - .expect("fixture ciphertext"); - let revoked = [llm_router_record( - "key-1", - Some("https://router.example/v1"), - Some("gpt-5.6-sol"), - Some(&ciphertext), - true, - )]; - let selected = select_existing_llm_router_account( - &revoked, - "https://router.example/v1", - "gpt-5.6-sol", - "test-secret", - ) - .expect("revoked rows should not block provisioning"); - assert!(selected.is_none()); - } - - #[test] - fn provider_account_metadata_drops_plaintext_credentials() { - let sanitized = sanitize_provider_account_json(&json!({ - "accountId": "router-account-1", - "displayName": "fixture", - "apiKey": "tnr_sk_should-not-persist-here", - "nested": { - "access_token": "secret", - "region": "cn" - }, - "keyId": "safe-key-id" - })) - .expect("metadata should remain after redaction"); - assert!(!sanitized.contains("tnr_sk_should-not-persist-here")); - assert!(!sanitized.contains("access_token")); - assert!(sanitized.contains("router-account-1")); - assert!(sanitized.contains("safe-key-id")); - } - #[test] fn router_credential_bundle_round_trip_keeps_password_server_side() { let bundle = RouterCredentialSecret { @@ -3281,74 +2844,6 @@ mod tests { assert!(!ciphertext.contains("Router-random-password")); } - #[test] - fn revoked_router_account_can_reuse_saved_login_credentials() { - let bundle = RouterCredentialSecret { - version: 1, - api_key: "sk-old-router-key".to_string(), - username: Some("router_user".to_string()), - password: Some("Router-random-password".to_string()), - access_token: None, - }; - let ciphertext = encrypt_router_credential_secret(&bundle, "test-secret") - .expect("credential bundle should encrypt"); - let record = llm_router_record( - "key-revoked", - Some("https://router.example/v1"), - Some("gpt-5.6-sol"), - Some(&ciphertext), - true, - ); - - let login = find_reusable_router_account_login( - &[record], - "https://router.example/v1", - "gpt-5.6-sol", - "test-secret", - ) - .expect("historical credentials should be inspectable") - .expect("historical credentials should be reusable"); - assert_eq!(login.username, "router_user"); - assert_eq!(login.password, "Router-random-password"); - } - - #[test] - fn multiple_reusable_router_accounts_require_reconciliation() { - let bundle = RouterCredentialSecret { - version: 1, - api_key: "sk-old-router-key".to_string(), - username: Some("router_user".to_string()), - password: Some("Router-random-password".to_string()), - access_token: None, - }; - let ciphertext = encrypt_router_credential_secret(&bundle, "test-secret") - .expect("credential bundle should encrypt"); - let records = [ - llm_router_record( - "key-revoked-1", - Some("https://router.example/v1"), - Some("gpt-5.6-sol"), - Some(&ciphertext), - true, - ), - llm_router_record( - "key-revoked-2", - Some("https://router.example/v1"), - Some("gpt-5.6-sol"), - Some(&ciphertext), - true, - ), - ]; - let error = find_reusable_router_account_login( - &records, - "https://router.example/v1", - "gpt-5.6-sol", - "test-secret", - ) - .expect_err("ambiguous historical credentials must reconcile"); - assert!(error.starts_with(LLM_ROUTER_RECONCILIATION_ERROR_PREFIX)); - } - #[test] fn router_control_origin_removes_responses_path() { assert_eq!( diff --git a/server-rs/crates/api-server/src/llm/mod.rs b/server-rs/crates/api-server/src/llm/mod.rs index 07c357f26..d9b5fcf6f 100644 --- a/server-rs/crates/api-server/src/llm/mod.rs +++ b/server-rs/crates/api-server/src/llm/mod.rs @@ -1006,37 +1006,16 @@ async fn resolve_llm_router_credentials( } // No fixture means the request must follow the same provisioning/read path - // as production. In particular, a missing external_api_key row must never - // be replaced by a process-wide test key. An already-provisioned row is - // read locally on the hot path; Router account repair and subscription - // checks run from the authentication/key-preparation anchor instead. + // as production. The dedicated llm_router_account row is authoritative. if let Some(credentials) = crate::external_api_keys::read_active_llm_router_credentials(state, owner_user_id).await? { return Ok(credentials); } - let record = crate::external_api_keys::ensure_llm_router_account(state, owner_user_id).await?; - let ciphertext = record - .secret_ciphertext - .as_deref() - .ok_or_else(|| "LLM Router 账号密钥缺失,请重新登录后重试".to_string())?; - let encryption_secret = state - .config - .effective_llm_router_api_key_encryption_secret() - .ok_or_else(|| "LLM Router 账号密钥加密配置缺失".to_string())?; - let api_key = - crate::external_api_keys::decrypt_router_api_key(ciphertext, encryption_secret.as_str())?; - // The Router origin is an application invariant. Do not let mutable - // metadata in the key row turn into a user-controlled upstream URL. - Ok(( - state - .config - .llm_router_base_url - .trim_end_matches('/') - .to_string(), - api_key, - record.key_id, - )) + crate::external_api_keys::ensure_llm_router_account(state, owner_user_id).await?; + crate::external_api_keys::read_active_llm_router_credentials(state, owner_user_id) + .await? + .ok_or_else(|| "LLM Router 账号密钥缺失,请重新登录后重试".to_string()) } #[cfg(test)] diff --git a/server-rs/crates/spacetime-client/src/active/mapper/llm_router_account.rs b/server-rs/crates/spacetime-client/src/active/mapper/llm_router_account.rs index d58c847c0..322df9061 100644 --- a/server-rs/crates/spacetime-client/src/active/mapper/llm_router_account.rs +++ b/server-rs/crates/spacetime-client/src/active/mapper/llm_router_account.rs @@ -7,7 +7,6 @@ pub struct LlmRouterAccountRecord { pub route_origin: String, pub idempotency_key: String, pub router_account_id: Option, - pub external_api_key_id: Option, pub credential_ciphertext: Option, pub account_json: Option, pub status: String, @@ -18,6 +17,12 @@ pub struct LlmRouterAccountRecord { pub credential_version: u32, pub created_at: String, pub updated_at: String, + pub key_id: String, + pub key_name: String, + pub key_prefix: Option, + pub key_hash: Option, + pub last_used_at: Option, + pub revoked_at: Option, } #[derive(Clone, Debug, PartialEq, Eq)] @@ -27,7 +32,6 @@ pub struct LlmRouterAccountUpsertRecordInput { pub route_origin: String, pub idempotency_key: String, pub router_account_id: Option, - pub external_api_key_id: Option, pub credential_ciphertext: Option, pub account_json: Option, pub status: String, @@ -37,6 +41,12 @@ pub struct LlmRouterAccountUpsertRecordInput { pub last_error: Option, pub credential_version: u32, pub now_micros: i64, + pub key_id: String, + pub key_name: String, + pub key_prefix: Option, + pub key_hash: Option, + pub last_used_at_micros: Option, + pub revoked_at_micros: Option, } impl From @@ -49,7 +59,6 @@ impl From route_origin: input.route_origin, idempotency_key: input.idempotency_key, router_account_id: input.router_account_id, - external_api_key_id: input.external_api_key_id, credential_ciphertext: input.credential_ciphertext, account_json: input.account_json, status: input.status, @@ -59,6 +68,12 @@ impl From last_error: input.last_error, credential_version: input.credential_version, now_micros: input.now_micros, + key_id: input.key_id, + key_name: input.key_name, + key_prefix: input.key_prefix, + key_hash: input.key_hash, + last_used_at_micros: input.last_used_at_micros, + revoked_at_micros: input.revoked_at_micros, } } } @@ -72,7 +87,6 @@ fn map_snapshot( route_origin: snapshot.route_origin, idempotency_key: snapshot.idempotency_key, router_account_id: snapshot.router_account_id, - external_api_key_id: snapshot.external_api_key_id, credential_ciphertext: snapshot.credential_ciphertext, account_json: snapshot.account_json, status: snapshot.status, @@ -83,6 +97,12 @@ fn map_snapshot( credential_version: snapshot.credential_version, created_at: format_timestamp_micros(snapshot.created_at_micros), updated_at: format_timestamp_micros(snapshot.updated_at_micros), + key_id: snapshot.key_id, + key_name: snapshot.key_name, + key_prefix: snapshot.key_prefix, + key_hash: snapshot.key_hash, + last_used_at: snapshot.last_used_at_micros.map(format_timestamp_micros), + revoked_at: snapshot.revoked_at_micros.map(format_timestamp_micros), } } diff --git a/server-rs/crates/spacetime-client/src/module_bindings/llm_router_account_snapshot_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/llm_router_account_snapshot_type.rs index 737d80203..bcb6d3cec 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/llm_router_account_snapshot_type.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/llm_router_account_snapshot_type.rs @@ -12,7 +12,6 @@ pub struct LlmRouterAccountSnapshot { pub route_origin: String, pub idempotency_key: String, pub router_account_id: Option, - pub external_api_key_id: Option, pub credential_ciphertext: Option, pub account_json: Option, pub status: String, @@ -23,6 +22,12 @@ pub struct LlmRouterAccountSnapshot { pub credential_version: u32, pub created_at_micros: i64, pub updated_at_micros: i64, + pub key_id: String, + pub key_name: String, + pub key_prefix: Option, + pub key_hash: Option, + pub last_used_at_micros: Option, + pub revoked_at_micros: Option, } impl __sdk::InModule for LlmRouterAccountSnapshot { diff --git a/server-rs/crates/spacetime-client/src/module_bindings/llm_router_account_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/llm_router_account_type.rs index bd6dd9248..cf3d4f5b1 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/llm_router_account_type.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/llm_router_account_type.rs @@ -12,7 +12,6 @@ pub struct LlmRouterAccount { pub route_origin: String, pub idempotency_key: String, pub router_account_id: Option, - pub external_api_key_id: Option, pub credential_ciphertext: Option, pub account_json: Option, pub status: String, @@ -23,6 +22,12 @@ pub struct LlmRouterAccount { pub credential_version: u32, pub created_at: __sdk::Timestamp, pub updated_at: __sdk::Timestamp, + pub key_id: String, + pub key_name: String, + pub key_prefix: Option, + pub key_hash: Option, + pub last_used_at: Option<__sdk::Timestamp>, + pub revoked_at: Option<__sdk::Timestamp>, } impl __sdk::InModule for LlmRouterAccount { @@ -38,7 +43,6 @@ pub struct LlmRouterAccountCols { pub route_origin: __sdk::__query_builder::Col, pub idempotency_key: __sdk::__query_builder::Col, pub router_account_id: __sdk::__query_builder::Col>, - pub external_api_key_id: __sdk::__query_builder::Col>, pub credential_ciphertext: __sdk::__query_builder::Col>, pub account_json: __sdk::__query_builder::Col>, pub status: __sdk::__query_builder::Col, @@ -49,6 +53,12 @@ pub struct LlmRouterAccountCols { pub credential_version: __sdk::__query_builder::Col, pub created_at: __sdk::__query_builder::Col, pub updated_at: __sdk::__query_builder::Col, + pub key_id: __sdk::__query_builder::Col, + pub key_name: __sdk::__query_builder::Col, + pub key_prefix: __sdk::__query_builder::Col>, + pub key_hash: __sdk::__query_builder::Col>, + pub last_used_at: __sdk::__query_builder::Col>, + pub revoked_at: __sdk::__query_builder::Col>, } impl __sdk::__query_builder::HasCols for LlmRouterAccount { @@ -60,10 +70,6 @@ impl __sdk::__query_builder::HasCols for LlmRouterAccount { route_origin: __sdk::__query_builder::Col::new(table_name, "route_origin"), idempotency_key: __sdk::__query_builder::Col::new(table_name, "idempotency_key"), router_account_id: __sdk::__query_builder::Col::new(table_name, "router_account_id"), - external_api_key_id: __sdk::__query_builder::Col::new( - table_name, - "external_api_key_id", - ), credential_ciphertext: __sdk::__query_builder::Col::new( table_name, "credential_ciphertext", @@ -80,6 +86,12 @@ impl __sdk::__query_builder::HasCols for LlmRouterAccount { credential_version: __sdk::__query_builder::Col::new(table_name, "credential_version"), created_at: __sdk::__query_builder::Col::new(table_name, "created_at"), updated_at: __sdk::__query_builder::Col::new(table_name, "updated_at"), + key_id: __sdk::__query_builder::Col::new(table_name, "key_id"), + key_name: __sdk::__query_builder::Col::new(table_name, "key_name"), + key_prefix: __sdk::__query_builder::Col::new(table_name, "key_prefix"), + key_hash: __sdk::__query_builder::Col::new(table_name, "key_hash"), + last_used_at: __sdk::__query_builder::Col::new(table_name, "last_used_at"), + revoked_at: __sdk::__query_builder::Col::new(table_name, "revoked_at"), } } } diff --git a/server-rs/crates/spacetime-client/src/module_bindings/llm_router_account_upsert_input_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/llm_router_account_upsert_input_type.rs index 8ab79b5b2..57faecffa 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/llm_router_account_upsert_input_type.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/llm_router_account_upsert_input_type.rs @@ -12,7 +12,6 @@ pub struct LlmRouterAccountUpsertInput { pub route_origin: String, pub idempotency_key: String, pub router_account_id: Option, - pub external_api_key_id: Option, pub credential_ciphertext: Option, pub account_json: Option, pub status: String, @@ -22,6 +21,12 @@ pub struct LlmRouterAccountUpsertInput { pub last_error: Option, pub credential_version: u32, pub now_micros: i64, + pub key_id: String, + pub key_name: String, + pub key_prefix: Option, + pub key_hash: Option, + pub last_used_at_micros: Option, + pub revoked_at_micros: Option, } impl __sdk::InModule for LlmRouterAccountUpsertInput { diff --git a/server-rs/crates/spacetime-module/src/llm_router_account.rs b/server-rs/crates/spacetime-module/src/llm_router_account.rs index 9bbb6bb12..d59cc6016 100644 --- a/server-rs/crates/spacetime-module/src/llm_router_account.rs +++ b/server-rs/crates/spacetime-module/src/llm_router_account.rs @@ -1,11 +1,6 @@ use crate::*; -/// Durable state for the per-user Router provisioning saga. -/// -/// The table stores only server-encrypted credential material. It is deliberately -/// separate from `external_api_key`: this row records the remote account lifecycle -/// and retry/reconciliation state, while `external_api_key` remains the formal API -/// key record used by request-time authentication and billing metadata. +/// Durable state for the per-user Router account and API Key. #[spacetimedb::table( accessor = llm_router_account, index( @@ -24,7 +19,6 @@ pub struct LlmRouterAccount { pub route_origin: String, pub idempotency_key: String, pub router_account_id: Option, - pub external_api_key_id: Option, pub credential_ciphertext: Option, pub account_json: Option, pub status: String, @@ -35,6 +29,12 @@ pub struct LlmRouterAccount { pub credential_version: u32, pub created_at: Timestamp, pub updated_at: Timestamp, + pub key_id: String, + pub key_name: String, + pub key_prefix: Option, + pub key_hash: Option, + pub last_used_at: Option, + pub revoked_at: Option, } #[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)] @@ -44,7 +44,6 @@ pub struct LlmRouterAccountUpsertInput { pub route_origin: String, pub idempotency_key: String, pub router_account_id: Option, - pub external_api_key_id: Option, pub credential_ciphertext: Option, pub account_json: Option, pub status: String, @@ -54,6 +53,12 @@ pub struct LlmRouterAccountUpsertInput { pub last_error: Option, pub credential_version: u32, pub now_micros: i64, + pub key_id: String, + pub key_name: String, + pub key_prefix: Option, + pub key_hash: Option, + pub last_used_at_micros: Option, + pub revoked_at_micros: Option, } #[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)] @@ -69,7 +74,6 @@ pub struct LlmRouterAccountSnapshot { pub route_origin: String, pub idempotency_key: String, pub router_account_id: Option, - pub external_api_key_id: Option, pub credential_ciphertext: Option, pub account_json: Option, pub status: String, @@ -80,6 +84,12 @@ pub struct LlmRouterAccountSnapshot { pub credential_version: u32, pub created_at_micros: i64, pub updated_at_micros: i64, + pub key_id: String, + pub key_name: String, + pub key_prefix: Option, + pub key_hash: Option, + pub last_used_at_micros: Option, + pub revoked_at_micros: Option, } #[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)] @@ -143,7 +153,6 @@ fn upsert_llm_router_account( route_origin, idempotency_key, router_account_id: normalize_optional(input.router_account_id), - external_api_key_id: normalize_optional(input.external_api_key_id), credential_ciphertext: normalize_optional(input.credential_ciphertext), account_json: normalize_optional(input.account_json), status, @@ -154,6 +163,16 @@ fn upsert_llm_router_account( credential_version: input.credential_version.max(1), created_at: now, updated_at: now, + key_id: required(input.key_id, "key_id")?, + key_name: required(input.key_name, "key_name")?, + key_prefix: normalize_optional(input.key_prefix), + key_hash: normalize_optional(input.key_hash), + last_used_at: input + .last_used_at_micros + .map(Timestamp::from_micros_since_unix_epoch), + revoked_at: input + .revoked_at_micros + .map(Timestamp::from_micros_since_unix_epoch), }; if ctx .db @@ -183,7 +202,6 @@ fn llm_router_account_snapshot_from_row(row: LlmRouterAccount) -> LlmRouterAccou route_origin: row.route_origin, idempotency_key: row.idempotency_key, router_account_id: row.router_account_id, - external_api_key_id: row.external_api_key_id, credential_ciphertext: row.credential_ciphertext, account_json: row.account_json, status: row.status, @@ -194,6 +212,16 @@ fn llm_router_account_snapshot_from_row(row: LlmRouterAccount) -> LlmRouterAccou credential_version: row.credential_version, created_at_micros: row.created_at.to_micros_since_unix_epoch(), updated_at_micros: row.updated_at.to_micros_since_unix_epoch(), + key_id: row.key_id, + key_name: row.key_name, + key_prefix: row.key_prefix, + key_hash: row.key_hash, + last_used_at_micros: row + .last_used_at + .map(|value| value.to_micros_since_unix_epoch()), + revoked_at_micros: row + .revoked_at + .map(|value| value.to_micros_since_unix_epoch()), } } From cbc73f111c8114b26c0816e959d77b5284d76a49 Mon Sep 17 00:00:00 2001 From: suzmii Date: Thu, 3 Sep 2026 19:42:21 +0800 Subject: [PATCH 32/43] =?UTF-8?q?=E6=81=A2=E5=A4=8D=E5=A4=96=E9=83=A8=20AP?= =?UTF-8?q?I=20Key=20=E8=A1=A8=E5=B9=B6=E7=BB=9F=E4=B8=80=20Router=20?= =?UTF-8?q?=E8=B4=A6=E5=8F=B7=E5=AD=98=E5=82=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 恢复 external_api_key 表及客户端绑定为 master 原始结构 将 LLM Router 凭据、状态、撤销和缓存统一收敛到 llm_router_account 移除后台与契约中的 purpose 字段及 Router 专用撤销 procedure 同步更新架构文档并补齐相关测试契约 --- apps/admin-web/src/api/adminApiClient.ts | 1 - apps/admin-web/src/api/adminApiTypes.ts | 3 - .../pages/AdminDatabaseTablesPage.test.tsx | 3 +- .../src/pages/AdminDatabaseTablesPage.tsx | 26 +----- ...】server-rs与SpacetimeDB数据契约-2026-05-15.md | 4 +- server-rs/crates/api-server/src/admin.rs | 48 +--------- .../api-server/src/external_api_keys.rs | 16 ---- .../crates/shared-contracts/src/admin.rs | 2 - .../src/active/mapper/external_api_key.rs | 57 +----------- .../spacetime-client/src/external_api_key.rs | 25 ----- .../spacetime-client/src/module_bindings.rs | 2 - .../external_api_key_create_input_type.rs | 7 -- .../external_api_key_snapshot_type.rs | 7 -- .../module_bindings/external_api_key_type.rs | 27 ------ ...llm_router_api_key_and_return_procedure.rs | 59 ------------ .../src/external_api_key_storage.rs | 93 +------------------ .../crates/spacetime-module/src/migration.rs | 26 ------ 17 files changed, 8 insertions(+), 398 deletions(-) delete mode 100644 server-rs/crates/spacetime-client/src/module_bindings/revoke_llm_router_api_key_and_return_procedure.rs diff --git a/apps/admin-web/src/api/adminApiClient.ts b/apps/admin-web/src/api/adminApiClient.ts index a248e2ecb..cd73d02d8 100644 --- a/apps/admin-web/src/api/adminApiClient.ts +++ b/apps/admin-web/src/api/adminApiClient.ts @@ -950,7 +950,6 @@ function buildExternalApiKeyQuery(query: AdminExternalApiKeyListQuery) { appendQueryParam(params, 'createdAfter', query.createdAfter); appendQueryParam(params, 'createdBefore', query.createdBefore); appendQueryParam(params, 'status', query.status); - appendQueryParam(params, 'purpose', query.purpose); if (typeof query.limit === 'number' && Number.isFinite(query.limit)) { params.set('limit', String(Math.floor(query.limit))); } diff --git a/apps/admin-web/src/api/adminApiTypes.ts b/apps/admin-web/src/api/adminApiTypes.ts index 062ec091e..d8cda23c0 100644 --- a/apps/admin-web/src/api/adminApiTypes.ts +++ b/apps/admin-web/src/api/adminApiTypes.ts @@ -284,7 +284,6 @@ export interface AdminExternalApiKeyListQuery { createdAfter?: string; createdBefore?: string; status?: 'active' | 'revoked'; - purpose?: 'external-editor' | 'llm-router'; limit?: number; offset?: number; sortColumn?: @@ -292,7 +291,6 @@ export interface AdminExternalApiKeyListQuery { | 'ownerUserId' | 'name' | 'keyPrefix' - | 'purpose' | 'createdAt' | 'lastUsedAt' | 'updatedAt'; @@ -304,7 +302,6 @@ export interface AdminExternalApiKeyPayload { ownerUserId: string; name: string; keyPrefix: string; - purpose: string; scopes: string[]; createdAt: string; lastUsedAt: string | null; diff --git a/apps/admin-web/src/pages/AdminDatabaseTablesPage.test.tsx b/apps/admin-web/src/pages/AdminDatabaseTablesPage.test.tsx index 167d00d82..88828fd4e 100644 --- a/apps/admin-web/src/pages/AdminDatabaseTablesPage.test.tsx +++ b/apps/admin-web/src/pages/AdminDatabaseTablesPage.test.tsx @@ -109,7 +109,6 @@ test('external_api_key 使用专用安全查询且详情不展示原始 JSON', a ownerUserId: 'user-1', name: 'agc_auto_generate', keyPrefix: 'tnr_sk_fixture', - purpose: 'llm-router', scopes: ['llm:responses'], createdAt: '2026-08-29T00:00:00Z', lastUsedAt: null, @@ -135,7 +134,7 @@ test('external_api_key 使用专用安全查询且详情不展示原始 JSON', a await waitFor(() => { expect(getAdminExternalApiKeys).toHaveBeenLastCalledWith( 'admin-token', - expect.objectContaining({ ownerUserId: 'user-1', purpose: undefined }), + expect.objectContaining({ ownerUserId: 'user-1' }), ); }); expect(await screen.findByText('tnr_sk_fixture')).toBeTruthy(); diff --git a/apps/admin-web/src/pages/AdminDatabaseTablesPage.tsx b/apps/admin-web/src/pages/AdminDatabaseTablesPage.tsx index 1f141e261..47659fd47 100644 --- a/apps/admin-web/src/pages/AdminDatabaseTablesPage.tsx +++ b/apps/admin-web/src/pages/AdminDatabaseTablesPage.tsx @@ -47,7 +47,6 @@ type ExternalApiKeySortColumn = | 'ownerUserId' | 'name' | 'keyPrefix' - | 'purpose' | 'createdAt' | 'lastUsedAt' | 'updatedAt'; @@ -1219,9 +1218,6 @@ function AdminExternalApiKeysPanel({ const [createdAfter, setCreatedAfter] = useState(''); const [createdBefore, setCreatedBefore] = useState(''); const [status, setStatus] = useState<'' | 'active' | 'revoked'>(''); - const [purpose, setPurpose] = useState<'' | 'external-editor' | 'llm-router'>( - '', - ); const [limit, setLimit] = useState('100'); const [sortColumn, setSortColumn] = useState(''); const [sortDirection, setSortDirection] = useState<'asc' | 'desc'>('desc'); @@ -1269,7 +1265,6 @@ function AdminExternalApiKeysPanel({ createdAfter: createdAfter.trim() || undefined, createdBefore: createdBefore.trim() || undefined, status: status || undefined, - purpose: purpose || undefined, limit: parseLimit(limit), offset: nextOffset, sortColumn: sortColumn || undefined, @@ -1294,7 +1289,6 @@ function AdminExternalApiKeysPanel({ setCreatedAfter(''); setCreatedBefore(''); setStatus(''); - setPurpose(''); setLimit('100'); setSortColumn(''); setSortDirection('desc'); @@ -1359,19 +1353,6 @@ function AdminExternalApiKeysPanel({ onChange={(event) => setName(event.target.value)} /> -
); } diff --git a/apps/ai-game-creator-shell/src/services/clientApi.ts b/apps/ai-game-creator-shell/src/services/clientApi.ts index 90e25572d..c47fba75a 100644 --- a/apps/ai-game-creator-shell/src/services/clientApi.ts +++ b/apps/ai-game-creator-shell/src/services/clientApi.ts @@ -179,6 +179,19 @@ export type ClientEditorAssetLibrary = { }>; }; +export type ClientLlmModel = { + id: string; + capabilities?: string[]; +}; + +export function loadClientLlmModels() { + return requestClientApi<{ models: ClientLlmModel[] }>( + '/api/llm/models', + { method: 'GET' }, + '读取可用模型失败', + ).then((response) => response.models ?? []); +} + export function loadEditorAssetLibrary() { return requestClientApi<{ library: ClientEditorAssetLibrary }>( '/api/editor/assets/library', diff --git a/apps/ai-game-creator-shell/src/styles.css b/apps/ai-game-creator-shell/src/styles.css index 431eda83b..db1f686c4 100644 --- a/apps/ai-game-creator-shell/src/styles.css +++ b/apps/ai-game-creator-shell/src/styles.css @@ -2594,7 +2594,12 @@ textarea { z-index: -1; inset: 0 auto 0 -35%; width: 28%; - background: linear-gradient(90deg, transparent, rgb(255 255 255 / 28%), transparent); + background: linear-gradient( + 90deg, + transparent, + rgb(255 255 255 / 28%), + transparent + ); pointer-events: none; opacity: 0; } @@ -2605,8 +2610,12 @@ textarea { } @keyframes project-supervisor-process-sweep { - from { transform: translateX(0); } - to { transform: translateX(520%); } + from { + transform: translateX(0); + } + to { + transform: translateX(520%); + } } .project-supervisor-process-card > header { @@ -4045,10 +4054,449 @@ h2 { color: var(--platform-text-base); } +.runtime-settings-model-card { + display: grid; + gap: 10px; + padding: 14px; + border: 1px solid var(--platform-subpanel-border); + border-radius: 12px; + background: linear-gradient( + 145deg, + var(--runtime-settings-subpanel-fill), + color-mix(in srgb, var(--runtime-settings-subpanel-fill) 82%, #f5ece2) + ); + box-shadow: 0 5px 18px rgb(84 60 42 / 6%); +} + +.runtime-settings-model-card-title { + display: grid; + gap: 3px; + min-width: 0; +} + +.runtime-settings-model-card-title > strong { + overflow: hidden; + color: var(--platform-text-strong); + font-size: 14px; + font-weight: 800; + text-overflow: ellipsis; + white-space: nowrap; +} + +.runtime-settings-model-card-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; + min-height: 32px; +} + +.runtime-settings-field-label, +.runtime-settings-model-profile-fields > label > span { + color: var(--platform-text-soft); + font-size: 11px; + font-weight: 700; +} + +.runtime-settings-model-status { + display: inline-flex; + align-items: center; + gap: 6px; + width: fit-content; + max-width: 100%; + padding: 4px 8px; + border-radius: 999px; + background: color-mix( + in srgb, + var(--platform-button-ghost-fill) 82%, + transparent + ); + color: var(--platform-text-soft); + font-size: 11px; + font-weight: 700; + line-height: 1.5; +} + +.runtime-settings-model-status[data-tone='success'] { + background: #edf8ef; + color: var(--platform-success-text); +} + +.runtime-settings-model-status[data-tone='error'] { + background: #fff0ec; + color: var(--platform-warm-text); +} + +.runtime-settings-manage-models { + display: inline-grid; + place-items: center; + width: 32px; + height: 32px; + padding: 0; + border: 1px solid var(--platform-subpanel-border); + border-radius: 8px; + background: var(--platform-button-ghost-fill); + color: var(--platform-text-base); + font-size: 12px; + font-weight: 700; + cursor: pointer; + transition: + border-color 140ms ease, + background-color 140ms ease, + color 140ms ease, + transform 140ms ease; +} + +.runtime-settings-manage-models:hover, +.runtime-settings-manage-models:focus-visible { + border-color: var(--platform-surface-hover-border); + background: var(--platform-nav-active-fill); + color: var(--platform-accent); +} + +.runtime-settings-manage-models:active { + transform: translateY(1px); +} + +.runtime-settings-modal-backdrop { + /* Portal 到 body 后不再继承设置面板里的 platform-theme 变量。 */ + --platform-text-strong: #3d1f10; + --platform-text-soft: #988476; + --platform-line-soft: rgb(226 203 184 / 72%); + --platform-surface-border: rgb(226 203 184 / 88%); + --platform-surface-hover-border: rgb(204 117 76 / 42%); + --platform-subpanel-border: rgb(225 204 187 / 82%); + --platform-nav-active-border: rgb(204 117 76 / 34%); + --platform-button-ghost-fill: rgb(255 253 250 / 56%); + --platform-input-focus-ring: rgb(190 145 108 / 18%); + --platform-success-text: #2f7b46; + --platform-warm-text: #b6623f; + --platform-danger: #b45309; + position: fixed; + inset: 0; + top: var(--window-chrome-height); + right: 0; + bottom: 0; + left: 0; + z-index: 7000; + display: grid; + place-items: center; + padding: 28px; + background: rgb(46 35 28 / 54%); + backdrop-filter: blur(7px); + animation: runtime-settings-modal-fade 140ms ease-out; + overflow: auto; +} + +.runtime-settings-modal { + position: relative; + z-index: 1; + display: grid; + grid-template-rows: auto minmax(0, 1fr) auto; + width: min(760px, 100%); + height: min(640px, 100%); + max-height: 100%; + min-height: 0; + overflow: hidden; + border: 1px solid rgb(234 223 213 / 92%); + border-radius: 16px; + background: #fffaf6; + box-shadow: + 0 28px 72px rgb(60 42 29 / 30%), + 0 2px 10px rgb(60 42 29 / 10%); +} + +@keyframes runtime-settings-modal-fade { + from { + opacity: 0; + } + to { + opacity: 1; + } +} + +.runtime-settings-modal-header, +.runtime-settings-modal-footer { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + padding: 18px 20px; + background: #fffdf9; +} + +.runtime-settings-modal-header { + border-bottom: 1px solid var(--platform-line-soft); +} + +.runtime-settings-modal-header h3, +.runtime-settings-modal-header p { + margin: 0; +} + +.runtime-settings-modal-header h3 { + color: var(--platform-text-strong); + font-size: 17px; + letter-spacing: -0.02em; +} + +.runtime-settings-modal-header p { + margin-top: 4px; + color: var(--platform-text-soft); + font-size: 12px; +} + +.runtime-settings-modal-header > button { + display: inline-grid; + place-items: center; + width: 30px; + height: 30px; + border: 0; + border-radius: 8px; + background: transparent; + color: var(--platform-text-soft); + cursor: pointer; + transition: + background-color 140ms ease, + color 140ms ease; +} + +.runtime-settings-modal-header > button:hover, +.runtime-settings-modal-header > button:focus-visible { + background: var(--platform-button-ghost-fill); + color: var(--platform-text-strong); +} + +.runtime-settings-model-profile-list { + min-height: 0; + overflow-y: auto; + display: grid; + align-content: start; + gap: 12px; + padding: 20px 22px 24px; + background: radial-gradient( + circle at 12% 0, + rgb(199 101 61 / 7%), + transparent 34% + ), + #fffaf6; +} + +.runtime-settings-model-profile-toolbar { + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: space-between; + gap: 12px; +} + +.runtime-settings-model-profile-toolbar-actions { + display: flex; + align-items: center; + justify-content: flex-end; + gap: 8px; + flex-shrink: 0; +} + +.runtime-settings-model-profile-toolbar > span { + color: var(--platform-text-soft); + font-size: 12px; +} + +.runtime-settings-model-profile-toolbar-actions > button, +.runtime-settings-modal-footer > button { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 5px; + min-height: 34px; + padding: 0 14px; + border: 1px solid var(--platform-surface-border); + border-radius: 10px; + background: #fffdfa; + color: #3d1f10; + font-size: 12px; + font-weight: 800; + cursor: pointer; + transition: + border-color 140ms ease, + background-color 140ms ease, + box-shadow 140ms ease, + transform 140ms ease; +} + +.runtime-settings-model-profile-toolbar-actions > button:hover, +.runtime-settings-model-profile-toolbar-actions > button:focus-visible, +.runtime-settings-modal-footer > button:hover, +.runtime-settings-modal-footer > button:focus-visible { + border-color: #c9b3a1; + background: #fff; + box-shadow: 0 4px 14px rgb(74 53 37 / 9%); +} + +.runtime-settings-model-profile-toolbar-actions > button:active, +.runtime-settings-modal-footer > button:active { + transform: translateY(1px); +} + +.runtime-settings-model-profile-card { + display: grid; + gap: 14px; + padding: 16px 16px 15px; + border: 1px solid var(--platform-subpanel-border); + border-radius: 15px; + background: #fffdfa; + box-shadow: 0 6px 18px rgb(74 53 37 / 7%); + transition: + border-color 140ms ease, + box-shadow 140ms ease, + transform 140ms ease; +} + +.runtime-settings-model-profile-card:hover, +.runtime-settings-model-profile-card:focus-within { + border-color: var(--platform-surface-hover-border); + box-shadow: 0 9px 24px rgb(74 53 37 / 11%); + transform: translateY(-1px); +} + +.runtime-settings-model-profile-card-header { + display: flex; + align-items: center; + gap: 9px; +} + +.runtime-settings-model-profile-index { + display: grid; + width: 34px; + height: 34px; + flex: 0 0 auto; + border: 1px solid var(--platform-nav-active-border); + border-radius: 11px; + background: linear-gradient( + 180deg, + rgb(238 208 183 / 48%), + rgb(204 117 76 / 16%) + ); + color: #c7653d; + font-size: 13px; + font-weight: 900; + place-items: center; +} + +.runtime-settings-model-profile-card-header > input { + flex: 1; + min-width: 0; + height: 36px; + padding: 0 11px; + border: 1px solid var(--platform-surface-border); + border-radius: 10px; + background: #fff; + color: #3d1f10; + font-size: 14px; + font-weight: 800; +} + +.runtime-settings-model-profile-card-header > input:focus-visible { + border-color: var(--platform-surface-hover-border); + outline: none; + box-shadow: 0 0 0 3px var(--platform-input-focus-ring); +} + +.runtime-settings-model-profile-card-header > button { + display: inline-grid; + place-items: center; + width: 34px; + height: 34px; + border: 1px solid + color-mix(in srgb, var(--platform-danger, #b45309) 24%, transparent); + border-radius: 10px; + background: color-mix(in srgb, var(--platform-danger, #b45309) 5%, #fff); + color: var(--platform-danger, #b45309); + cursor: pointer; + transition: + border-color 140ms ease, + background-color 140ms ease; +} + +.runtime-settings-model-profile-card-header > button:hover, +.runtime-settings-model-profile-card-header > button:focus-visible { + border-color: color-mix( + in srgb, + var(--platform-danger, #b45309) 36%, + transparent + ); + background: color-mix(in srgb, var(--platform-danger, #b45309) 9%, #fff); +} + +.runtime-settings-model-profile-fields { + display: grid; + grid-template-columns: minmax(0, 1.6fr) minmax(150px, 0.8fr); + gap: 12px; +} + +.runtime-settings-model-profile-fields > label { + display: grid; + gap: 7px; + color: #988476; + font-size: 11px; + font-weight: 700; +} + +.runtime-settings-model-profile-fields select { + min-width: 0; + background-color: #fff; + color: #3d1f10; +} + +.runtime-settings-modal-footer { + justify-content: flex-end; + min-height: 68px; + box-sizing: border-box; + border-top: 1px solid var(--platform-line-soft); +} + +@media (max-width: 680px) { + .runtime-settings-modal-backdrop { + padding: 12px; + } + + .runtime-settings-modal { + height: min(620px, 100%); + border-radius: 15px; + } + + .runtime-settings-modal-header, + .runtime-settings-modal-footer { + padding: 15px 16px; + } + + .runtime-settings-model-profile-list { + padding: 16px; + } + + .runtime-settings-model-profile-toolbar { + align-items: stretch; + flex-direction: column; + } + + .runtime-settings-model-profile-toolbar-actions > button { + width: 100%; + } + + .runtime-settings-model-profile-card { + transform: none; + } + + .runtime-settings-model-profile-fields { + grid-template-columns: 1fr; + } +} + .runtime-settings-fields input, .runtime-settings-fields select { border-color: var(--platform-surface-border); - background: var(--platform-input-fill); + background-color: var(--platform-input-fill); color: var(--platform-text-strong); } @@ -4059,6 +4507,118 @@ h2 { box-shadow: 0 0 0 3px var(--platform-input-focus-ring); } +/* 设置页统一下拉控件样式,菜单交给系统渲染以保持稳定的键盘与窗口行为。 */ +.runtime-settings-select { + width: 100%; + min-width: 0; + height: 38px; + padding: 0 11px; + border: 1px solid var(--platform-surface-border); + border-radius: 11px; + background: var(--platform-input-fill); + color: var(--platform-text-strong); + cursor: pointer; + transition: + border-color 140ms ease, + box-shadow 140ms ease, + background-color 140ms ease; +} + +.runtime-settings-select:hover { + border-color: var(--platform-surface-hover-border); + background-color: var(--platform-button-ghost-fill); +} + +.runtime-settings-select:focus-visible { + border-color: var(--platform-surface-hover-border); + outline: none; + box-shadow: 0 0 0 3px var(--platform-input-focus-ring); +} + +.runtime-settings-select option { + border-radius: 8px; + color: #3d1f10; + background: #fffdfa; +} + +.runtime-settings-select-control { + display: inline-flex; + align-items: center; + justify-content: space-between; + gap: 8px; + text-align: left; +} + +.runtime-settings-select-chevron { + flex: 0 0 auto; + color: #988476; + transition: transform 140ms ease; +} + +.runtime-settings-select-control[aria-expanded='true'] + .runtime-settings-select-chevron { + transform: rotate(180deg); +} + +.runtime-settings-select-menu { + position: fixed; + z-index: 7600; + display: grid; + gap: 4px; + max-height: min(300px, calc(100dvh - 24px)); + padding: 5px; + overflow-y: auto; + border: 1px solid rgb(226 203 184 / 88%); + border-radius: 13px; + background: #fffdfa; + box-shadow: 0 18px 46px rgb(74 53 37 / 20%); + scrollbar-width: none; + -ms-overflow-style: none; +} + +.runtime-settings-select-menu::-webkit-scrollbar { + display: none; +} + +.runtime-settings-select-option { + display: flex; + align-items: center; + min-height: 36px; + padding: 0 10px; + border: 0; + border-radius: 9px; + background: transparent; + color: #3d1f10; + font-size: 12px; + text-align: left; + cursor: pointer; +} + +.runtime-settings-select-option:hover, +.runtime-settings-select-option:focus-visible { + background: rgb(238 208 183 / 34%); + outline: none; +} + +.runtime-settings-select-option[data-selected='true'] { + background: rgb(204 117 76 / 15%); + color: #b45309; + font-weight: 800; +} + +.runtime-settings-content, +.runtime-settings-modal, +.runtime-settings-model-profile-list { + scrollbar-width: none; + -ms-overflow-style: none; +} + +.runtime-settings-content::-webkit-scrollbar, +.runtime-settings-modal::-webkit-scrollbar, +.runtime-settings-model-profile-list::-webkit-scrollbar { + display: none; +} + .runtime-settings-about { grid-column: 1 / -1; display: grid; diff --git a/server-rs/crates/api-server/src/llm/mod.rs b/server-rs/crates/api-server/src/llm/mod.rs index d9b5fcf6f..24922721c 100644 --- a/server-rs/crates/api-server/src/llm/mod.rs +++ b/server-rs/crates/api-server/src/llm/mod.rs @@ -18,6 +18,19 @@ use shared_contracts::llm::{ use spacetime_client::SpacetimeClientError; use std::convert::Infallible; +#[derive(Debug, serde::Serialize)] +#[serde(rename_all = "camelCase")] +pub struct LlmModelSummary { + pub id: String, + pub capabilities: Vec, +} + +#[derive(Debug, serde::Serialize)] +#[serde(rename_all = "camelCase")] +struct LlmModelsResponse { + models: Vec, +} + #[cfg(test)] use std::collections::HashMap; #[cfg(test)] @@ -171,6 +184,129 @@ pub async fn proxy_llm_chat_completions( .into_response()) } +/// Returns the Router model directory through the authenticated platform API. +/// Router credentials and the upstream response body never leave api-server. +pub async fn list_llm_models( + State(state): State, + Extension(request_context): Extension, + Extension(authenticated): Extension, +) -> Result { + let (base_url, api_key, _) = + resolve_llm_router_credentials(&state, authenticated.claims().user_id()) + .await + .map_err(|error| { + llm_error_response( + &request_context, + AppError::from_status(StatusCode::SERVICE_UNAVAILABLE).with_message(error), + ) + })?; + let client = reqwest::Client::builder() + .connect_timeout(std::time::Duration::from_secs(15)) + .timeout(std::time::Duration::from_secs(30)) + .redirect(reqwest::redirect::Policy::none()) + .build() + .map_err(|error| { + llm_error_response( + &request_context, + AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR) + .with_message(format!("创建 LLM Router 模型目录客户端失败:{error}")), + ) + })?; + let upstream = client + .get(format!("{}/models", base_url.trim_end_matches('/'))) + .bearer_auth(api_key) + .send() + .await + .map_err(|error| { + llm_error_response( + &request_context, + AppError::from_status(StatusCode::BAD_GATEWAY) + .with_message(format!("读取 LLM Router 模型目录失败:{error}")), + ) + })?; + let status = upstream.status(); + if !status.is_success() { + return Err(llm_error_response( + &request_context, + AppError::from_status(StatusCode::BAD_GATEWAY) + .with_message(format!("LLM Router 模型目录返回 HTTP {status}")), + )); + } + let payload = upstream.json::().await.map_err(|error| { + llm_error_response( + &request_context, + AppError::from_status(StatusCode::BAD_GATEWAY) + .with_message(format!("解析 LLM Router 模型目录失败:{error}")), + ) + })?; + let models = payload + .get("data") + .and_then(Value::as_array) + .into_iter() + .flatten() + .filter_map(|item| { + let id = item.get("id").and_then(Value::as_str)?.trim(); + let capabilities = item + .get("capabilities") + .and_then(Value::as_array) + .map(|values| { + values + .iter() + .filter_map(Value::as_str) + .map(str::to_string) + .collect() + }) + .unwrap_or_default(); + Some(LlmModelSummary { + id: id.to_string(), + capabilities, + }) + }) + .filter(|model| !model.id.is_empty()) + .collect(); + Ok(json_success_body(Some(&request_context), LlmModelsResponse { models }).into_response()) +} + +async fn router_model_exists( + state: &AppState, + owner_user_id: &str, + model_id: &str, +) -> Result { + let (base_url, api_key, _) = resolve_llm_router_credentials(state, owner_user_id).await?; + let client = reqwest::Client::builder() + .connect_timeout(std::time::Duration::from_secs(15)) + .timeout(std::time::Duration::from_secs(30)) + .redirect(reqwest::redirect::Policy::none()) + .build() + .map_err(|error| format!("创建 LLM Router 模型校验客户端失败:{error}"))?; + let response = client + .get(format!("{}/models", base_url.trim_end_matches('/'))) + .bearer_auth(api_key) + .send() + .await + .map_err(|error| format!("读取 LLM Router 模型目录失败:{error}"))?; + if !response.status().is_success() { + return Err(format!( + "LLM Router 模型目录返回 HTTP {}", + response.status() + )); + } + let payload = response + .json::() + .await + .map_err(|error| format!("解析 LLM Router 模型目录失败:{error}"))?; + Ok(payload + .get("data") + .and_then(Value::as_array) + .is_some_and(|models| { + models.iter().any(|item| { + item.get("id") + .and_then(Value::as_str) + .is_some_and(|id| id.trim() == model_id) + }) + })) +} + /// Proxies the OpenAI-compatible Responses protocol for the LLM Router. /// /// The caller only presents the platform access token. The Router credential @@ -213,6 +349,12 @@ pub async fn proxy_llm_responses( .with_message("LLM Responses 请求体必须是 JSON 对象"), ) })?; + let requested_model = object + .get("model") + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(str::to_string); // The LLM Router is an account-owned route. Ignore legacy client/provider controls; // they must not reach Router even when an older desktop build still sends // them. Runtime controls such as `stream`, `input`, `tools` and `metadata` @@ -232,11 +374,39 @@ pub async fn proxy_llm_responses( ] { object.remove(field); } - // Ignore any client model override. - object.insert( - "model".to_string(), - Value::String(state.config.llm_router_model.clone()), - ); + // The AGC client may select a model from the server-provided Router + // directory. Older callers without the reserved marker remain pinned to + // the official default model. + let agc_client = headers + .get("x-genarrative-client") + .and_then(|value| value.to_str().ok()) + .is_some_and(|value| value == "agc"); + let selected_model = if agc_client { + if let Some(model) = requested_model { + if router_model_exists(&state, authenticated.claims().user_id(), &model) + .await + .map_err(|error| { + llm_error_response( + &request_context, + AppError::from_status(StatusCode::BAD_GATEWAY).with_message(error), + ) + })? + { + model + } else { + return Err(llm_error_response( + &request_context, + AppError::from_status(StatusCode::UNPROCESSABLE_ENTITY) + .with_message("所选模型已不存在,请刷新模型列表后重试"), + )); + } + } else { + state.config.llm_router_model.clone() + } + } else { + state.config.llm_router_model.clone() + }; + object.insert("model".to_string(), Value::String(selected_model)); let (base_url, api_key, key_id) = resolve_llm_router_credentials(&state, authenticated.claims().user_id()) diff --git a/server-rs/crates/api-server/src/modules/platform.rs b/server-rs/crates/api-server/src/modules/platform.rs index 2c45f526e..43492f64c 100644 --- a/server-rs/crates/api-server/src/modules/platform.rs +++ b/server-rs/crates/api-server/src/modules/platform.rs @@ -5,7 +5,7 @@ use axum::{ use crate::{ auth::require_bearer_auth, - llm::{proxy_llm_chat_completions, proxy_llm_responses}, + llm::{list_llm_models, proxy_llm_chat_completions, proxy_llm_responses}, state::AppState, volcengine_speech::{ get_volcengine_speech_config, stream_volcengine_asr, stream_volcengine_tts_bidirection, @@ -15,6 +15,13 @@ use crate::{ pub fn router(state: AppState) -> Router { Router::new() + .route( + "/api/llm/models", + get(list_llm_models).route_layer(middleware::from_fn_with_state( + state.clone(), + require_bearer_auth, + )), + ) .route( "/api/llm/chat/completions", post(proxy_llm_chat_completions).route_layer(middleware::from_fn_with_state( From 020f7d7a7aa063a8e1b492baa75c21540d60bb3b Mon Sep 17 00:00:00 2001 From: suzmii Date: Fri, 4 Sep 2026 14:08:16 +0800 Subject: [PATCH 36/43] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=20Router=20=E5=87=AD?= =?UTF-8?q?=E6=8D=AE=E5=B9=B6=E5=8F=91=E4=B8=80=E8=87=B4=E6=80=A7=E9=97=AE?= =?UTF-8?q?=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 移除 Router Key 进程内 TTL 缓存,改为每次读取权威账号状态并即时解密当前密文 Router 账号写入改为同主键原地更新,避免 delete+insert 读取缺口 新增 owner/route 映射冲突校验,防止并发写入插入重复账号行 同步更新后端数据契约文档中的凭据读取语义 --- ...】server-rs与SpacetimeDB数据契约-2026-05-15.md | 2 +- .../api-server/src/external_api_keys.rs | 61 +++---------------- .../src/llm_router_account.rs | 52 ++++++++++++---- 3 files changed, 50 insertions(+), 65 deletions(-) diff --git a/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md b/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md index 204e7ffee..9824cf0b4 100644 --- a/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md +++ b/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md @@ -642,7 +642,7 @@ Responses 的终态载荷既是工具调用的恢复源,也是正文的恢复 ### `llm_router_account` -- 当前 AGC Router 需求由 `llm_router_account` 表单独承载:API Key 核心字段、加密凭据、Router 账号元数据、生命周期与 provisioning 状态均在该表;不依赖 `external_api_key`。api-server 首次需要上游调用时解密凭据,并按 owner + route 使用 10 分钟进程内缓存;轮换或撤销时清理缓存。 +- 当前 AGC Router 需求由 `llm_router_account` 表单独承载:API Key 核心字段、加密凭据、Router 账号元数据、生命周期与 provisioning 状态均在该表;不依赖 `external_api_key`。api-server 每次上游调用都读取权威 `llm_router_account` active/revoked 状态并即时解密当前密文,不做 TTL 凭据缓存,避免任意实例轮换或撤销后继续使用旧 Key。 - Rust 结构体:`LlmRouterAccount` - 源码:`server-rs/crates/spacetime-module/src/llm_router_account.rs` diff --git a/server-rs/crates/api-server/src/external_api_keys.rs b/server-rs/crates/api-server/src/external_api_keys.rs index 9366d73e1..1396c5560 100644 --- a/server-rs/crates/api-server/src/external_api_keys.rs +++ b/server-rs/crates/api-server/src/external_api_keys.rs @@ -23,7 +23,6 @@ use spacetime_client::{ }; use std::collections::HashMap; use std::sync::{Arc, OnceLock}; -use std::time::{Duration, Instant}; use tokio::sync::Mutex; use crate::{ @@ -55,21 +54,10 @@ const LLM_ROUTER_SUBSCRIPTION_PLAN_ID: i64 = 1; const LLM_ROUTER_SUBSCRIPTION_RENEWAL_THRESHOLD_SECONDS: i64 = 24 * 60 * 60; const LLM_ROUTER_RECONCILIATION_ERROR_PREFIX: &str = "llm-router-reconciliation-required:"; const LLM_ROUTER_PROVISIONING_CREDENTIAL_VERSION: u32 = 1; -const LLM_ROUTER_CREDENTIAL_CACHE_TTL: Duration = Duration::from_secs(600); - type HmacSha256 = Hmac; static LLM_ROUTER_PROVISION_LOCKS: OnceLock>>>> = OnceLock::new(); -static LLM_ROUTER_CREDENTIAL_CACHE: OnceLock>> = - OnceLock::new(); - -struct CachedRouterCredential { - expires_at: Instant, - api_key: String, - key_id: String, -} - struct ProvisionedRouterCredential { provider_account_id: String, provider_account_json: Option, @@ -107,10 +95,6 @@ fn llm_router_provision_locks() -> &'static Mutex> LLM_ROUTER_PROVISION_LOCKS.get_or_init(|| Mutex::new(HashMap::new())) } -fn llm_router_credential_cache() -> &'static Mutex> { - LLM_ROUTER_CREDENTIAL_CACHE.get_or_init(|| Mutex::new(HashMap::new())) -} - #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] pub struct ExternalApiKeyCreateRequest { @@ -607,7 +591,7 @@ async fn upsert_llm_router_account_state( ) }) }); - state + let account = state .spacetime_client() .upsert_llm_router_account(LlmRouterAccountUpsertRecordInput { account_key: account_key.to_string(), @@ -637,7 +621,8 @@ async fn upsert_llm_router_account_state( .map(|_| current_utc_micros()), }) .await - .map_err(|error| format!("保存 LLM Router 账号状态失败:{error}")) + .map_err(|error| format!("保存 LLM Router 账号状态失败:{error}"))?; + Ok(account) } /// Ensures the Router user has the fixed AGC subscription before the server @@ -992,29 +977,6 @@ pub(crate) async fn read_active_llm_router_credentials( return Err("LLM Router 账号缺少 owner_user_id".to_string()); } ensure_llm_router_target_allowed(state)?; - let cache_key = format!( - "{}|{}", - owner_user_id, - state.config.llm_router_base_url.trim_end_matches('/') - ); - let now = Instant::now(); - let mut cache = llm_router_credential_cache().lock().await; - if let Some(cached) = cache - .get(cache_key.as_str()) - .filter(|entry| entry.expires_at > now) - { - return Ok(Some(( - state - .config - .llm_router_base_url - .trim_end_matches('/') - .to_string(), - cached.api_key.clone(), - cached.key_id.clone(), - ))); - } - cache.remove(cache_key.as_str()); - drop(cache); let encryption_secret = state .config .effective_llm_router_api_key_encryption_secret() @@ -1038,15 +1000,12 @@ pub(crate) async fn read_active_llm_router_credentials( let ciphertext = account.credential_ciphertext.as_deref().ok_or_else(|| { format!("{LLM_ROUTER_RECONCILIATION_ERROR_PREFIX}active Router Key 密文缺失") })?; + // Router account state is read from SpacetimeDB immediately before every + // upstream request and the current ciphertext is decrypted on demand. This + // deliberately avoids a TTL cache: another api-server instance can rotate + // or revoke the durable row at any time, and no process-local entry can + // outlive that authoritative state. let api_key = decrypt_router_api_key(ciphertext, encryption_secret.as_str())?; - llm_router_credential_cache().lock().await.insert( - cache_key, - CachedRouterCredential { - expires_at: Instant::now() + LLM_ROUTER_CREDENTIAL_CACHE_TTL, - api_key: api_key.clone(), - key_id: account.key_id.clone(), - }, - ); Ok(Some(( state .config @@ -1064,10 +1023,6 @@ pub(crate) async fn revoke_llm_router_account( key_id: &str, ) -> Result<(), String> { let route_origin = state.config.llm_router_base_url.trim_end_matches('/'); - llm_router_credential_cache() - .lock() - .await - .remove(format!("{}|{}", owner_user_id, route_origin).as_str()); if let Some(account) = state .spacetime_client() .get_llm_router_account(owner_user_id.to_string(), route_origin.to_string()) diff --git a/server-rs/crates/spacetime-module/src/llm_router_account.rs b/server-rs/crates/spacetime-module/src/llm_router_account.rs index d59cc6016..2462ff063 100644 --- a/server-rs/crates/spacetime-module/src/llm_router_account.rs +++ b/server-rs/crates/spacetime-module/src/llm_router_account.rs @@ -174,19 +174,34 @@ fn upsert_llm_router_account( .revoked_at_micros .map(Timestamp::from_micros_since_unix_epoch), }; - if ctx - .db - .llm_router_account() - .account_key() - .find(&account_key) - .is_some() - { - ctx.db + // SpacetimeDB procedures serialize writes in the same transaction. Update + // the durable primary-key row in place so readers can never observe the + // delete+insert gap and a concurrent owner/route write cannot resurrect an + // old row through a separate primary key. + if let Some(existing) = ctx.db.llm_router_account().account_key().find(&account_key) { + ensure_llm_router_account_identity( + Some((existing.owner_user_id, existing.route_origin)), + row.owner_user_id.as_str(), + row.route_origin.as_str(), + )?; + let mut updated = row; + updated.created_at = existing.created_at; + ctx.db.llm_router_account().account_key().update(updated); + } else { + let conflicting_owner_route = ctx + .db .llm_router_account() - .account_key() - .delete(&account_key); + .by_llm_router_account_owner_route() + .filter((row.owner_user_id.as_str(), row.route_origin.as_str())) + .next() + .map(|row| (row.owner_user_id, row.route_origin)); + ensure_llm_router_account_identity( + conflicting_owner_route, + row.owner_user_id.as_str(), + row.route_origin.as_str(), + )?; + ctx.db.llm_router_account().try_insert(row)?; } - ctx.db.llm_router_account().insert(row); ctx.db .llm_router_account() .account_key() @@ -195,6 +210,21 @@ fn upsert_llm_router_account( .ok_or_else(|| "Router 账号状态保存失败".to_string()) } +fn ensure_llm_router_account_identity( + existing: Option<(String, String)>, + owner_user_id: &str, + route_origin: &str, +) -> Result<(), String> { + if let Some((existing_owner, existing_route)) = existing { + if existing_owner != owner_user_id || existing_route != route_origin { + return Err(format!( + "Router 账号 owner/route 映射冲突:{existing_owner}|{existing_route} != {owner_user_id}|{route_origin}" + )); + } + } + Ok(()) +} + fn llm_router_account_snapshot_from_row(row: LlmRouterAccount) -> LlmRouterAccountSnapshot { LlmRouterAccountSnapshot { account_key: row.account_key, From 13639d9f8cdd0508dfe178c5dd81e40c6c76b08c Mon Sep 17 00:00:00 2001 From: suzmii Date: Fri, 4 Sep 2026 15:19:01 +0800 Subject: [PATCH 37/43] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=20AGC=20=E8=BF=90?= =?UTF-8?q?=E8=A1=8C=E6=97=B6=E9=85=8D=E7=BD=AE=E6=A8=A1=E5=9E=8B=E6=96=B9?= =?UTF-8?q?=E6=A1=88=E6=B5=8B=E8=AF=95=E6=96=AD=E8=A8=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 将推理档断言迁移到模型方案管理面板的推理档位选择器 补充持久化测试 fixture 的 modelProfiles 与选中方案字段 同步保存回写断言中的模型方案结构 --- .../appSurface/runtime-settings.suite.ts | 56 +++++++++++++++++-- 1 file changed, 52 insertions(+), 4 deletions(-) diff --git a/apps/ai-game-creator-shell/tests/appSurface/runtime-settings.suite.ts b/apps/ai-game-creator-shell/tests/appSurface/runtime-settings.suite.ts index a35eb7162..be29b7549 100644 --- a/apps/ai-game-creator-shell/tests/appSurface/runtime-settings.suite.ts +++ b/apps/ai-game-creator-shell/tests/appSurface/runtime-settings.suite.ts @@ -725,9 +725,17 @@ export function registerRuntimeSettingsTests() { expect( await screen.findByRole('dialog', { name: '运行时配置' }), ).not.toBeNull(); - fireEvent.keyDown(screen.getByLabelText('推理档'), { + fireEvent.click(screen.getByRole('button', { name: '配置模型方案' })); + expect( + await screen.findByRole('dialog', { name: '模型方案管理' }), + ).not.toBeNull(); + fireEvent.keyDown(screen.getByLabelText('陶泥儿智能创作 显示名称'), { key: 'Escape', }); + expect(screen.getByRole('dialog', { name: '模型方案管理' })).not.toBeNull(); + + fireEvent.keyDown(window, { key: 'Escape' }); + expect(screen.queryByRole('dialog', { name: '模型方案管理' })).toBeNull(); expect(screen.getByRole('dialog', { name: '运行时配置' })).not.toBeNull(); fireEvent.keyDown(window, { key: 'Escape' }); @@ -869,6 +877,16 @@ export function registerPublishedRuntimeSettingsTests() { baseUrl: 'http://127.0.0.1:8082', apiKey: 'editor-loaded-secret', }, + modelProfiles: [ + { + id: 'default', + name: '陶泥儿智能创作', + modelId: 'gpt-test', + enabled: true, + reasoningEffort: 'medium', + }, + ], + selectedModelProfileId: 'default', }, }; } @@ -933,12 +951,24 @@ export function registerPublishedRuntimeSettingsTests() { expect(screen.getByText('已启用')).not.toBeNull(); fireEvent.click(screen.getByRole('button', { name: /常用设置/ })); - expect(screen.getByLabelText('推理档')).toHaveProperty('value', 'medium'); + fireEvent.click(screen.getByRole('button', { name: '配置模型方案' })); + await screen.findByRole('dialog', { + name: '模型方案管理', + }); + const reasoningSelect = screen.getByLabelText('陶泥儿智能创作 推理档位'); + expect(reasoningSelect.textContent).toContain('medium'); + fireEvent.click(reasoningSelect); expect( - within(screen.getByLabelText('推理档')).getByRole('option', { + within( + screen.getByRole('listbox', { + name: '陶泥儿智能创作 推理档位', + }), + ).getByRole('option', { name: 'max', }), ).not.toBeNull(); + fireEvent.click(screen.getByRole('button', { name: '完成' })); + expect(screen.queryByRole('dialog', { name: '模型方案管理' })).toBeNull(); fireEvent.click(screen.getByLabelText('流式输出')); fireEvent.click(screen.getByLabelText('联网检索')); fireEvent.click(screen.getByRole('button', { name: /高级参数/ })); @@ -997,6 +1027,16 @@ export function registerPublishedRuntimeSettingsTests() { baseUrl: 'https://dev.genarrative.world', apiKey: '', }, + modelProfiles: [ + { + id: 'default', + name: '陶泥儿智能创作', + modelId: 'gpt-test', + enabled: true, + reasoningEffort: 'medium', + }, + ], + selectedModelProfileId: 'default', }, }); expect(JSON.stringify(persistedConfig)).not.toContain( @@ -1058,7 +1098,15 @@ export function registerPublishedRuntimeSettingsTests() { expect(screen.queryByLabelText('LLM API Key')).toBeNull(); expect(screen.queryByLabelText('LLM Base URL')).toBeNull(); expect(screen.queryByLabelText('LLM 模型')).toBeNull(); - expect(screen.getByLabelText('推理档')).toHaveProperty('value', 'max'); + fireEvent.click(screen.getByRole('button', { name: '配置模型方案' })); + await screen.findByRole('dialog', { + name: '模型方案管理', + }); + expect( + screen.getByLabelText('陶泥儿智能创作 推理档位').textContent, + ).toContain('max'); + fireEvent.click(screen.getByRole('button', { name: '完成' })); + expect(screen.queryByRole('dialog', { name: '模型方案管理' })).toBeNull(); expect(screen.getByLabelText('联网检索')).toHaveProperty('checked', true); fireEvent.click(screen.getByRole('button', { name: /Agent 分工/ })); expect(screen.getByText('已启用')).not.toBeNull(); From 87c0cbd06d03934e0235cd9824e9e39ce206c10c Mon Sep 17 00:00:00 2001 From: suzmii Date: Fri, 4 Sep 2026 15:52:47 +0800 Subject: [PATCH 38/43] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=20Provider=20=E4=BB=A3?= =?UTF-8?q?=E7=90=86=E9=94=99=E8=AF=AF=E5=A4=96=E5=8F=91=20AGC=20=E4=B8=BB?= =?UTF-8?q?=E7=AB=99=E5=AE=A2=E6=88=B7=E7=AB=AF=E6=A0=87=E8=AE=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 仅 PlatformSession 的 /api/llm 主站路由携带 x-genarrative-client 标记 通用 Provider 与第三方凭据桥接不再自动附加该标记 补充主站路由代理的标记回归测试 --- .../src-tauri/src/agent/codex_app_server.rs | 28 +++++--- .../src/agent/codex_provider_proxy.rs | 70 +++++++++++++++++-- 2 files changed, 83 insertions(+), 15 deletions(-) diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server.rs index d58d1f878..938e36a8e 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server.rs @@ -1922,19 +1922,25 @@ impl CodexAppServerConnection { "创建 Codex app-server 临时目录失败:{error}" )) })?; - let direct_provider_route = match credential { + let (direct_provider_route, main_site_upstream) = match credential { CodexAppServerCredential::PlatformSession { api_base_url, access_token, .. - } => Some(( - format!("{}/api/llm", api_base_url.trim_end_matches('/')), - access_token.clone(), - )), - _ => (workspace_mode == CodexAppServerWorkspaceMode::DirectProject) - .then(|| credential.direct_provider_route(llm)) - .flatten() - .map(|(base_url, api_key)| (base_url.to_string(), api_key.to_string())), + } => ( + Some(( + format!("{}/api/llm", api_base_url.trim_end_matches('/')), + access_token.clone(), + )), + true, + ), + _ => ( + (workspace_mode == CodexAppServerWorkspaceMode::DirectProject) + .then(|| credential.direct_provider_route(llm)) + .flatten() + .map(|(base_url, api_key)| (base_url.to_string(), api_key.to_string())), + false, + ), }; let remote_control_disable_reason = credential.remote_control_disable_reason(direct_provider_route.is_some()); @@ -2024,7 +2030,7 @@ impl CodexAppServerConnection { }; let provider_proxy = if let Some((base_url, api_key)) = direct_provider_route.as_ref() { Some( - start_codex_provider_proxy(base_url, api_key) + start_codex_provider_proxy(base_url, api_key, main_site_upstream) .await .map_err(platform_llm::LlmError::InvalidConfig)?, ) @@ -4966,7 +4972,7 @@ mod tests { #[tokio::test] async fn direct_project_command_receives_only_provider_proxy_session_token() { let provider_key = "fixture-upstream-provider-secret"; - let proxy = start_codex_provider_proxy("http://127.0.0.1:9", provider_key) + let proxy = start_codex_provider_proxy("http://127.0.0.1:9", provider_key, false) .await .expect("start credential broker"); let mut command = tokio::process::Command::new("fixture"); diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/codex_provider_proxy.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/codex_provider_proxy.rs index b23b50749..a38811148 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/codex_provider_proxy.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/codex_provider_proxy.rs @@ -9,12 +9,15 @@ use std::sync::Arc; pub(crate) const CODEX_PROVIDER_PROXY_PROTOCOL: &str = "genarrative-codex-provider-proxy.v1"; const CODEX_PROVIDER_PROXY_MAX_REQUEST_BYTES: usize = 32 * 1024 * 1024; +const AGC_CLIENT_MARKER_HEADER: &str = "x-genarrative-client"; +const AGC_CLIENT_MARKER_VALUE: &str = "agc"; #[derive(Clone)] struct CodexProviderProxyState { upstream_base_url: String, upstream_bearer_token: String, downstream_bearer_token: String, + main_site_upstream: bool, client: reqwest::Client, } @@ -137,10 +140,14 @@ async fn proxy_codex_provider_request( headers.append(name.clone(), value.clone()); } } - headers.insert( - axum::http::HeaderName::from_static("x-genarrative-client"), - axum::http::HeaderValue::from_static("agc"), - ); + // 仅 AGC 主站 `/api/llm` 路由需要携带保留的客户端标记,供服务端校验模型 + // 方案;通用 Provider/凭据桥接不得把该标记外发给第三方上游。 + if state.main_site_upstream { + headers.insert( + axum::http::HeaderName::from_static(AGC_CLIENT_MARKER_HEADER), + axum::http::HeaderValue::from_static(AGC_CLIENT_MARKER_VALUE), + ); + } let upstream_authorization = match format!("Bearer {}", state.upstream_bearer_token).parse() { Ok(value) => value, Err(_) => { @@ -193,6 +200,7 @@ async fn proxy_codex_provider_request( pub(crate) async fn start_codex_provider_proxy( upstream_base_url: &str, upstream_bearer_token: &str, + main_site_upstream: bool, ) -> Result { let upstream_base_url = normalize_codex_provider_upstream(upstream_base_url)?; let upstream_bearer_token = upstream_bearer_token.trim(); @@ -223,6 +231,7 @@ pub(crate) async fn start_codex_provider_proxy( upstream_base_url, upstream_bearer_token: upstream_bearer_token.to_string(), downstream_bearer_token: downstream_bearer_token.clone(), + main_site_upstream, client, }); let app = Router::new() @@ -269,6 +278,25 @@ mod tests { .expect("fake response") } + async fn fake_main_site_upstream( + State(calls): State>, + headers: HeaderMap, + body: axum::body::Bytes, + ) -> Response { + calls.fetch_add(1, Ordering::SeqCst); + assert_eq!( + headers + .get(AGC_CLIENT_MARKER_HEADER) + .and_then(|value| value.to_str().ok()), + Some(AGC_CLIENT_MARKER_VALUE) + ); + Response::builder() + .status(StatusCode::OK) + .header("content-type", "application/json") + .body(Body::from(body)) + .expect("fake main-site response") + } + #[tokio::test] async fn loopback_proxy_strips_false_codex_limit_headers_and_requires_bearer() { let calls = Arc::new(AtomicUsize::new(0)); @@ -285,6 +313,7 @@ mod tests { let proxy = start_codex_provider_proxy( &format!("http://127.0.0.1:{}", address.port()), "fixture-provider-key", + false, ) .await .expect("start provider proxy"); @@ -328,4 +357,37 @@ mod tests { assert_eq!(calls.load(Ordering::SeqCst), 1); upstream_task.abort(); } + + #[tokio::test] + async fn loopback_proxy_adds_main_site_marker_only_when_bridging_the_agc_route() { + let calls = Arc::new(AtomicUsize::new(0)); + let listener = tokio::net::TcpListener::bind((std::net::Ipv4Addr::LOCALHOST, 0)) + .await + .expect("bind upstream"); + let address = listener.local_addr().expect("upstream address"); + let app = Router::new() + .route("/responses", post(fake_main_site_upstream)) + .with_state(Arc::clone(&calls)); + let upstream_task = tokio::spawn(async move { + let _ = axum::serve(listener, app).await; + }); + let proxy = start_codex_provider_proxy( + &format!("http://127.0.0.1:{}", address.port()), + "fixture-provider-key", + true, + ) + .await + .expect("start main-site provider proxy"); + + let accepted = reqwest::Client::new() + .post(format!("{}/responses", proxy.base_url())) + .bearer_auth(proxy.downstream_bearer_token()) + .body("{\"input\":\"ok\"}") + .send() + .await + .expect("accepted main-site response"); + assert_eq!(accepted.status(), StatusCode::OK); + assert_eq!(calls.load(Ordering::SeqCst), 1); + upstream_task.abort(); + } } From 4f3f0f24ffba2f18f686541ebf2609becc1d0574 Mon Sep 17 00:00:00 2001 From: suzmii Date: Sat, 5 Sep 2026 19:10:49 +0800 Subject: [PATCH 39/43] =?UTF-8?q?=E6=8E=A5=E5=85=A5AGC=E5=90=8E=E5=8F=B0?= =?UTF-8?q?=E6=A8=A1=E5=9E=8B=E7=9B=AE=E5=BD=95=E4=B8=8E=E5=AF=B9=E8=AF=9D?= =?UTF-8?q?=E6=A8=A1=E5=9E=8B=E9=80=89=E6=8B=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增后台 AGC 模型目录、别名、启停和默认项管理 客户端设置页恢复原状,对话框右下角按别名选择模型 服务端按稳定模型标识映射并校验实际模型白名单 修复 AGC 配套后端端口漂移、启动等待和 SpacetimeDB 版本检查 补充迁移、文档、启动与模型选择测试 --- apps/admin-web/src/api/adminApiClient.ts | 16 + apps/admin-web/src/api/adminApiTypes.ts | 12 + apps/admin-web/src/app/AdminApp.tsx | 4 + apps/admin-web/src/app/AdminShell.tsx | 1 + apps/admin-web/src/app/adminRoutes.ts | 7 +- .../src/pages/AdminAgcModelsPage.test.tsx | 54 ++ .../src/pages/AdminAgcModelsPage.tsx | 218 ++++++ apps/admin-web/src/styles/admin.css | 53 +- .../game-creator.config.json | 2 +- .../scripts/start-dev-stack.mjs | 3 + .../scripts/start-tauri-dev.mjs | 77 +- .../src-tauri/src/agent/codex_app_server.rs | 4 +- .../src-tauri/src/commands.rs | 32 + .../src-tauri/src/config.rs | 38 +- .../src-tauri/src/main.rs | 40 +- apps/ai-game-creator-shell/src/app/types.ts | 11 +- .../ConversationModelSelect.tsx | 110 +++ .../ProjectSupervisorView.tsx | 25 +- .../runtime-config/RuntimeConfigDialog.tsx | 618 ++-------------- .../src/services/clientApi.ts | 6 +- apps/ai-game-creator-shell/src/styles.css | 665 ++---------------- .../tests/appSurface/harness.ts | 41 ++ .../appSurface/runtime-settings.suite.ts | 148 +--- .../tests/conversationModelSelect.test.tsx | 89 +++ .../tests/start-tauri-dev.test.ts | 43 +- .../shared-memory/decision-log.md | 2 +- docs/project-memory/shared-memory/pitfalls.md | 6 + ...方案】AGC后台模型别名与对话选择-2026-09-05.md | 17 + ...】server-rs与SpacetimeDB数据契约-2026-05-15.md | 7 + ...发运维】本地开发验证与生产运维-2026-05-15.md | 4 + scripts/dev.mjs | 23 + scripts/dev.test.ts | 38 + server-rs/crates/api-server/src/admin.rs | 15 + server-rs/crates/api-server/src/agc_models.rs | 97 +++ server-rs/crates/api-server/src/config.rs | 2 +- server-rs/crates/api-server/src/llm/mod.rs | 259 +++---- server-rs/crates/api-server/src/main.rs | 1 + .../crates/api-server/src/modules/admin.rs | 9 + .../crates/module-runtime/src/agc_models.rs | 118 ++++ server-rs/crates/module-runtime/src/lib.rs | 2 + .../crates/shared-contracts/src/admin.rs | 17 + server-rs/crates/shared-contracts/src/llm.rs | 13 + .../crates/spacetime-client/src/active.rs | 1 + .../crates/spacetime-client/src/agc_models.rs | 42 ++ .../spacetime-client/src/module_bindings.rs | 29 + .../agc_model_catalog_row_type.rs | 58 ++ .../agc_model_catalog_table.rs | 230 ++++++ .../read_agc_model_catalog_procedure.rs | 52 ++ .../save_agc_model_catalog_procedure.rs | 56 ++ .../crates/spacetime-module/src/active.rs | 3 + .../crates/spacetime-module/src/agc_models.rs | 66 ++ .../crates/spacetime-module/src/migration.rs | 2 + 52 files changed, 1942 insertions(+), 1544 deletions(-) create mode 100644 apps/admin-web/src/pages/AdminAgcModelsPage.test.tsx create mode 100644 apps/admin-web/src/pages/AdminAgcModelsPage.tsx create mode 100644 apps/ai-game-creator-shell/src/features/project-workspace/ConversationModelSelect.tsx create mode 100644 apps/ai-game-creator-shell/tests/conversationModelSelect.test.tsx create mode 100644 docs/technical/【技术方案】AGC后台模型别名与对话选择-2026-09-05.md create mode 100644 server-rs/crates/api-server/src/agc_models.rs create mode 100644 server-rs/crates/module-runtime/src/agc_models.rs create mode 100644 server-rs/crates/spacetime-client/src/agc_models.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/agc_model_catalog_row_type.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/agc_model_catalog_table.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/read_agc_model_catalog_procedure.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/save_agc_model_catalog_procedure.rs create mode 100644 server-rs/crates/spacetime-module/src/agc_models.rs diff --git a/apps/admin-web/src/api/adminApiClient.ts b/apps/admin-web/src/api/adminApiClient.ts index cd73d02d8..1f53fd340 100644 --- a/apps/admin-web/src/api/adminApiClient.ts +++ b/apps/admin-web/src/api/adminApiClient.ts @@ -1072,3 +1072,19 @@ function buildAdminApiError( function isRecord(value: unknown): value is Record { return typeof value === 'object' && value !== null && !Array.isArray(value); } +export function getAgcModelCatalog(token: string) { + return request( + '/admin/api/agc-models', + { token }, + ); +} + +export function saveAgcModelCatalog( + token: string, + body: import('./adminApiTypes').AdminAgcModelCatalog, +) { + return request( + '/admin/api/agc-models', + { token, method: 'PUT', body }, + ); +} diff --git a/apps/admin-web/src/api/adminApiTypes.ts b/apps/admin-web/src/api/adminApiTypes.ts index d8cda23c0..1973d77a5 100644 --- a/apps/admin-web/src/api/adminApiTypes.ts +++ b/apps/admin-web/src/api/adminApiTypes.ts @@ -998,3 +998,15 @@ export interface AdminRechargeRefundActionResponse { export interface AdminWalletRestrictionResponse { wallet: AdminProfileWalletPayload; } +export interface AdminAgcModel { + id: string; + alias: string; + modelId: string; + enabled: boolean; +} + +export interface AdminAgcModelCatalog { + revision: number; + defaultModelId: string; + models: AdminAgcModel[]; +} diff --git a/apps/admin-web/src/app/AdminApp.tsx b/apps/admin-web/src/app/AdminApp.tsx index a717f146b..afb62b22f 100644 --- a/apps/admin-web/src/app/AdminApp.tsx +++ b/apps/admin-web/src/app/AdminApp.tsx @@ -18,6 +18,7 @@ import { setStoredAdminToken, } from '../auth/adminAuthStore'; import { AdminAccountsPage } from '../pages/AdminAccountsPage'; +import { AdminAgcModelsPage } from '../pages/AdminAgcModelsPage'; import { AdminDashboardPage } from '../pages/AdminDashboardPage'; import { AdminDatabaseTablesPage } from '../pages/AdminDatabaseTablesPage'; import { AdminDebugHttpPage } from '../pages/AdminDebugHttpPage'; @@ -289,6 +290,9 @@ export function AdminApp() { onUnauthorized={handleUnauthorized} /> ) : null} + {activeRouteId === 'agc-models' ? ( + + ) : null} {activeRouteId === 'editor-showcase' ? ( ; export function AdminShell({ diff --git a/apps/admin-web/src/app/adminRoutes.ts b/apps/admin-web/src/app/adminRoutes.ts index 6afaf6b7e..1110bb05d 100644 --- a/apps/admin-web/src/app/adminRoutes.ts +++ b/apps/admin-web/src/app/adminRoutes.ts @@ -16,9 +16,13 @@ export type AdminRouteId = | 'editor-generation-pricing' | 'editor-showcase' | 'editor-assets' + | 'agc-models' | 'accounts'; -export type AdminTabPermission = Exclude; +export type AdminTabPermission = Exclude< + AdminRouteId, + 'accounts' | 'agc-models' +>; /** 后台导航项定义,hash 是浏览器地址栏和移动底栏共用入口。 */ export interface AdminRouteDefinition { @@ -47,6 +51,7 @@ export const adminRoutes: AdminRouteDefinition[] = [ label: '模型定价', hash: '#editor-generation-pricing', }, + { id: 'agc-models', label: 'AGC 模型', hash: '#agc-models', ownerOnly: true }, { id: 'editor-showcase', label: '精选审核', hash: '#editor-showcase' }, { id: 'editor-assets', label: '素材查询', hash: '#editor-assets' }, { id: 'accounts', label: '账号管理', hash: '#accounts', ownerOnly: true }, diff --git a/apps/admin-web/src/pages/AdminAgcModelsPage.test.tsx b/apps/admin-web/src/pages/AdminAgcModelsPage.test.tsx new file mode 100644 index 000000000..41bf69552 --- /dev/null +++ b/apps/admin-web/src/pages/AdminAgcModelsPage.test.tsx @@ -0,0 +1,54 @@ +// @vitest-environment jsdom +import { + cleanup, + fireEvent, + render, + screen, + waitFor, +} from '@testing-library/react'; +import { afterEach, expect, test, vi } from 'vitest'; + +import { getAgcModelCatalog, saveAgcModelCatalog } from '../api/adminApiClient'; +import { AdminAgcModelsPage } from './AdminAgcModelsPage'; + +vi.mock('../api/adminApiClient', () => ({ + getAgcModelCatalog: vi.fn(), + saveAgcModelCatalog: vi.fn(), + isAdminApiError: vi.fn(() => false), + formatAdminApiError: vi.fn(() => '保存失败'), +})); +vi.mock('../components/useAdminWriteConfirm', () => ({ + useAdminWriteConfirm: () => ({ + confirmWrite: async () => true, + confirmDialog: null, + }), +})); +afterEach(cleanup); + +test('edits alias and upstream model without changing the stable identifier or revision', async () => { + const catalog = { + revision: 3, + defaultModelId: 'quality', + models: [ + { id: 'quality', alias: '高质量', modelId: 'gpt-6-astra', enabled: true }, + ], + }; + vi.mocked(getAgcModelCatalog).mockResolvedValue(catalog); + vi.mocked(saveAgcModelCatalog).mockImplementation(async (_, input) => ({ + ...input, + revision: 4, + })); + render(); + await screen.findByDisplayValue('gpt-6-astra'); + fireEvent.change(screen.getByLabelText('模型 1 别名'), { + target: { value: '精细创作' }, + }); + fireEvent.click(screen.getByRole('button', { name: '保存' })); + await waitFor(() => + expect(saveAgcModelCatalog).toHaveBeenCalledWith('test', { + ...catalog, + models: [{ ...catalog.models[0], alias: '精细创作' }], + }), + ); + await screen.findByText('已保存'); +}); diff --git a/apps/admin-web/src/pages/AdminAgcModelsPage.tsx b/apps/admin-web/src/pages/AdminAgcModelsPage.tsx new file mode 100644 index 000000000..a9affc116 --- /dev/null +++ b/apps/admin-web/src/pages/AdminAgcModelsPage.tsx @@ -0,0 +1,218 @@ +import { Plus, RefreshCcw, Save, Trash2 } from 'lucide-react'; +import { useEffect, useState } from 'react'; + +import { getAgcModelCatalog, saveAgcModelCatalog } from '../api/adminApiClient'; +import type { AdminAgcModel, AdminAgcModelCatalog } from '../api/adminApiTypes'; +import { useAdminWriteConfirm } from '../components/useAdminWriteConfirm'; +import { handlePageError } from './pageUtils'; + +export function AdminAgcModelsPage({ + token, + onUnauthorized, +}: { + token: string; + onUnauthorized: (message?: string) => void; +}) { + const [catalog, setCatalog] = useState(null); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(''); + const [saved, setSaved] = useState(false); + const { confirmWrite, confirmDialog } = useAdminWriteConfirm(); + + async function refresh() { + if (!token) return; + setBusy(true); + setError(''); + setSaved(false); + try { + setCatalog(await getAgcModelCatalog(token)); + } catch (error) { + handlePageError(error, onUnauthorized, setError); + } finally { + setBusy(false); + } + } + + useEffect(() => { + void refresh(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [token]); + + function update(id: string, patch: Partial) { + setSaved(false); + setCatalog( + (current) => + current && { + ...current, + models: current.models.map((model) => + model.id === id ? { ...model, ...patch } : model, + ), + }, + ); + } + + async function save() { + if (!catalog || busy) return; + if ( + !(await confirmWrite({ + action: '保存 AGC 模型目录', + target: `${catalog.models.length} 个模型`, + })) + ) + return; + setBusy(true); + setError(''); + setSaved(false); + try { + setCatalog(await saveAgcModelCatalog(token, catalog)); + setSaved(true); + } catch (error) { + handlePageError(error, onUnauthorized, setError); + } finally { + setBusy(false); + } + } + + return ( +
+
+

AGC 模型

+
+ + + +
+
+ {error ?

{error}

: null} + {saved ?

已保存

: null} + {busy ?

正在处理

: null} +
+ + + + + + + + + + + + {catalog?.models.map((model, index) => ( + + + + + + + + ))} + +
别名实际模型名启用默认操作
+ + update(model.id, { alias: e.target.value }) + } + /> + + + update(model.id, { modelId: e.target.value }) + } + /> + + + update(model.id, { enabled: e.target.checked }) + } + /> + + { + setSaved(false); + setCatalog({ ...catalog, defaultModelId: model.id }); + }} + /> + + +
+
+ {confirmDialog} +
+ ); +} diff --git a/apps/admin-web/src/styles/admin.css b/apps/admin-web/src/styles/admin.css index adfb46737..e500b7abd 100644 --- a/apps/admin-web/src/styles/admin.css +++ b/apps/admin-web/src/styles/admin.css @@ -3093,5 +3093,54 @@ button:disabled { background: var(--admin-surface, #fff); } .admin-detail-modal__panel header, -.admin-detail-modal__actions { display: flex; align-items: center; justify-content: space-between; gap: 12px; } -.admin-detail-modal__panel pre { max-height: 360px; overflow: auto; white-space: pre-wrap; background: #f8fafc; padding: 12px; border-radius: 8px; } +.admin-detail-modal__actions { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; +} +.admin-detail-modal__panel pre { + max-height: 360px; + overflow: auto; + white-space: pre-wrap; + background: #f8fafc; + padding: 12px; + border-radius: 8px; +} +.admin-agc-models { + min-width: 0; +} +.admin-agc-models > header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; +} +.admin-agc-models > header > div { + display: flex; + gap: 8px; +} +.admin-agc-models button { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 6px; + min-height: 32px; +} +.admin-agc-models-table { + overflow-x: auto; +} +.admin-agc-models table { + width: 100%; + border-collapse: collapse; +} +.admin-agc-models th, +.admin-agc-models td { + padding: 8px; + text-align: left; +} +.admin-agc-models td input:not([type]) { + min-width: 150px; + width: 100%; + box-sizing: border-box; +} diff --git a/apps/ai-game-creator-shell/game-creator.config.json b/apps/ai-game-creator-shell/game-creator.config.json index 6b3214da9..bb00a1ed7 100644 --- a/apps/ai-game-creator-shell/game-creator.config.json +++ b/apps/ai-game-creator-shell/game-creator.config.json @@ -4,7 +4,7 @@ "llm": { "apiKey": "", "baseUrl": "https://dev.genarrative.world/gpt/v1", - "model": "gpt-5.6-sol", + "model": "gpt-6-astra", "apiKind": "openai_responses", "reasoningEffort": "max", "stream": true, diff --git a/apps/ai-game-creator-shell/scripts/start-dev-stack.mjs b/apps/ai-game-creator-shell/scripts/start-dev-stack.mjs index 968c8927e..397d88dcf 100644 --- a/apps/ai-game-creator-shell/scripts/start-dev-stack.mjs +++ b/apps/ai-game-creator-shell/scripts/start-dev-stack.mjs @@ -540,6 +540,7 @@ async function ensureBackend({ backendDatabase, '--spacetime-data-dir', backendSpacetimeDataDir, + '--preserve-database', '--no-interactive', ], { cwd: appRoot }, @@ -680,11 +681,13 @@ function isDirectModuleExecution() { export { ensureBackend, formatChildFailure, + isAiGameCreatorServer, isBackendReady, isDirectModuleExecution, isProcessGroupAlive, preflightExistingVite, readChildFailure, + readExistingViteServer, readLinuxProcessGroupAlive, resolveBackendTargetsFromState, runWindowsTaskkill, diff --git a/apps/ai-game-creator-shell/scripts/start-tauri-dev.mjs b/apps/ai-game-creator-shell/scripts/start-tauri-dev.mjs index 7667c1389..95a866f95 100644 --- a/apps/ai-game-creator-shell/scripts/start-tauri-dev.mjs +++ b/apps/ai-game-creator-shell/scripts/start-tauri-dev.mjs @@ -7,7 +7,10 @@ import { withAgcDevEndpointEnv, } from './dev-port.mjs'; import { + isAiGameCreatorServer, preflightExistingVite, + readChildFailure, + readExistingViteServer, spawnChild, stopChild, terminateChildTree, @@ -20,7 +23,9 @@ const tauriCliPath = resolve(repoRoot, 'node_modules/@tauri-apps/cli/tauri.js'); function buildTauriArguments(argv, devUrl = readAgcDevEndpoint().url) { const args = [...argv]; - const configOverride = JSON.stringify({ build: { devUrl } }); + const configOverride = JSON.stringify({ + build: { devUrl, beforeDevCommand: '' }, + }); const separatorIndex = args.indexOf('--'); if (separatorIndex < 0) { return ['dev', ...args, '--config', configOverride]; @@ -51,6 +56,7 @@ async function runTauriDev( { resolveDevEndpoint = resolveAgcDevEndpoint, preflight = preflightExistingVite, + prepareFrontend = prepareFrontendDev, spawnCli = spawnTauriCli, waitForCli = waitForChildTermination, terminateTree = terminateChildTree, @@ -59,10 +65,9 @@ async function runTauriDev( const endpoint = await resolveDevEndpoint(); await preflight({ endpoint }); - const tauriArguments = buildTauriArguments(argv, endpoint.url); - const child = spawnCli(tauriArguments, { - env: withAgcDevEndpointEnv(endpoint), - }); + let child = null; + let frontendChild = null; + const preparationAbort = new AbortController(); let resolveShutdown; let shutdownSignal = ''; let repeatedSignal = false; @@ -76,21 +81,47 @@ async function runTauriDev( if (!shutdownSignal) { shutdownSignal = signal; stopChild(child, 'SIGTERM'); + stopChild(frontendChild, 'SIGTERM'); + preparationAbort.abort(); resolveShutdown(signal); return; } repeatedSignal = true; stopChild(child, 'SIGKILL'); + stopChild(frontendChild, 'SIGKILL'); }; signalHandlers.set(signal, handler); process.on(signal, handler); } try { + const preparation = prepareFrontend(endpoint, { + signal: preparationAbort.signal, + onChild(frontend) { + frontendChild = frontend; + }, + }); + const prepared = await Promise.race([ + preparation.then(() => true), + shutdownRequested.then(() => false), + ]); + if (!prepared || shutdownSignal) return 1; + const tauriArguments = buildTauriArguments(argv, endpoint.url); + child = spawnCli(tauriArguments, { + env: withAgcDevEndpointEnv(endpoint), + }); const childResult = waitForCli(child); const outcome = await Promise.race([ childResult.then((failure) => ({ type: 'exit', failure })), shutdownRequested.then((signal) => ({ type: 'signal', signal })), + ...(frontendChild + ? [ + waitForChildTermination(frontendChild).then((failure) => ({ + type: 'frontend-exit', + failure, + })), + ] + : []), ]); const cleanup = await terminateTree(child, { gracefulTimeoutMs: repeatedSignal ? 0 : 2500, @@ -105,15 +136,51 @@ async function runTauriDev( if (outcome.type === 'signal') { return 1; } + if (outcome.type === 'frontend-exit') return 1; const { failure } = outcome; return failure.type === 'error' || failure.signal ? 1 : (failure.code ?? 0); } finally { for (const [signal, handler] of signalHandlers) { process.off(signal, handler); } + preparationAbort.abort(); + if (frontendChild) { + const cleanup = await terminateTree(frontendChild); + if (!cleanup.stopped) { + console.error('[ai-game-creator-shell] 配套开发服务未能完全停止。'); + } + } } } +async function prepareFrontendDev(endpoint, { onChild, signal }) { + const frontend = spawnChild( + process.platform === 'win32' ? 'npm.cmd' : 'npm', + ['run', 'agc:serve'], + { cwd: repoRoot, env: withAgcDevEndpointEnv(endpoint) }, + ); + onChild(frontend); + console.log( + '[ai-game-creator-shell] 正在准备前端与配套后端,完成后启动 Tauri', + ); + const deadline = Date.now() + 660_000; + while (Date.now() < deadline) { + signal.throwIfAborted(); + const failure = readChildFailure(frontend); + if (failure) { + throw new Error( + `配套开发服务退出,前端未就绪:${failure.error?.message ?? failure.signal ?? failure.code}`, + ); + } + if (isAiGameCreatorServer(await readExistingViteServer(endpoint))) return; + await Promise.race([ + new Promise((resolveWait) => setTimeout(resolveWait, 1000)), + waitForChildTermination(frontend), + ]); + } + throw new Error(`等待前端与配套后端就绪超时:${endpoint.url}`); +} + function isDirectModuleExecution() { return Boolean( process.argv[1] && diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server.rs index 938e36a8e..a20098cc1 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server.rs @@ -1776,8 +1776,8 @@ impl CodexAppServerConnection { effective_llm.base_url = format!("{}/api/llm", session.api_base_url.trim_end_matches('/')); effective_llm.api_key.clear(); - // The selected model profile is resolved and validated at config - // load time; the official route still owns the provider/base URL. + // Only the platform catalog identifier reaches Codex. api-server + // validates it and resolves the actual upstream model. effective_llm.model = llm.model.clone(); CodexAppServerCredential::PlatformSession { fingerprint: format!( diff --git a/apps/ai-game-creator-shell/src-tauri/src/commands.rs b/apps/ai-game-creator-shell/src-tauri/src/commands.rs index 03597027a..2f564e863 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/commands.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/commands.rs @@ -1997,8 +1997,40 @@ pub(crate) fn read_game_creator_app_config() -> Result = std::sync::Mutex::new(()); + #[tauri::command] pub(crate) fn write_game_creator_app_config( + mut config: GameCreatorAppConfig, +) -> Result { + let _guard = GAME_CREATOR_CONFIG_WRITE_LOCK + .lock() + .map_err(|_| "配置写入锁不可用")?; + config.selected_model_id = load_game_creator_app_config()?.selected_model_id; + persist_game_creator_app_config(config) +} + +#[tauri::command] +pub(crate) fn select_game_creator_model( + model_id: String, +) -> Result { + let _guard = GAME_CREATOR_CONFIG_WRITE_LOCK + .lock() + .map_err(|_| "配置写入锁不可用")?; + if model_id.is_empty() + || model_id.len() > 64 + || !model_id + .bytes() + .all(|c| c.is_ascii_alphanumeric() || c == b'-' || c == b'_') + { + return Err("模型标识无效".into()); + } + let mut config = load_game_creator_app_config()?; + config.selected_model_id = model_id; + persist_game_creator_app_config(config) +} + +fn persist_game_creator_app_config( config: GameCreatorAppConfig, ) -> Result { let config = normalize_game_creator_app_config(config)?; diff --git a/apps/ai-game-creator-shell/src-tauri/src/config.rs b/apps/ai-game-creator-shell/src-tauri/src/config.rs index f3de56e66..8fbfff37b 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/config.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/config.rs @@ -93,7 +93,7 @@ fn user_selected_path_is_authorized(path: &Path, is_directory: bool) -> bool { } pub(crate) const OFFICIAL_LLM_ROUTER_BASE_URL: &str = "https://router.genarrative.world/v1"; -pub(crate) const OFFICIAL_LLM_ROUTER_MODEL: &str = "gpt-5.6-sol"; +pub(crate) const OFFICIAL_LLM_ROUTER_MODEL: &str = "gpt-6-astra"; pub(crate) const GAME_CREATOR_LLM_AGENT_REASONING_EFFORT_DEFAULTS: [(&str, &str); 21] = [ (GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, "high"), @@ -3482,23 +3482,11 @@ pub(crate) fn lock_game_creator_app_config_to_official_route(config: &mut GameCr config.agent_mode = GAME_CREATOR_AGENT_MODE_CODEX_APP_SERVER.to_string(); config.llm.api_key.clear(); config.llm.base_url = OFFICIAL_LLM_ROUTER_BASE_URL.to_string(); - if let Some(profile) = config - .model_profiles - .iter() - .find(|profile| profile.enabled && profile.id == config.selected_model_profile_id) - { - if !profile.model_id.trim().is_empty() { - config.llm.model = profile.model_id.trim().to_string(); - } - if let Some(reasoning_effort) = profile.reasoning_effort.as_deref() { - if !reasoning_effort.trim().is_empty() { - config.llm.reasoning_effort = reasoning_effort.trim().to_string(); - } - } - } - if config.llm.model.trim().is_empty() { - config.llm.model = OFFICIAL_LLM_ROUTER_MODEL.to_string(); - } + config.llm.model = if config.selected_model_id.is_empty() { + "platform-default".to_string() + } else { + config.selected_model_id.clone() + }; config.llm.api_kind = DEFAULT_GAME_CREATOR_LLM_API_KIND.to_string(); config.agent_llm.clear(); config.editor_api.api_key.clear(); @@ -3632,18 +3620,8 @@ pub(crate) fn merge_game_creator_config_file( config.planning.capability_enabled = capability_enabled; } } - if let Some(model_profiles) = file_config.model_profiles { - config.model_profiles = model_profiles - .into_iter() - .filter(|profile| { - !profile.id.trim().is_empty() - && !profile.name.trim().is_empty() - && !profile.model_id.trim().is_empty() - }) - .collect(); - } - if let Some(selected_model_profile_id) = file_config.selected_model_profile_id { - config.selected_model_profile_id = selected_model_profile_id; + if let Some(selected_model_id) = file_config.selected_model_id { + config.selected_model_id = selected_model_id; } Ok(()) } diff --git a/apps/ai-game-creator-shell/src-tauri/src/main.rs b/apps/ai-game-creator-shell/src-tauri/src/main.rs index dc22839aa..90ffd9333 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/main.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/main.rs @@ -1021,25 +1021,7 @@ struct GameCreatorAppConfigFile { editor_api: Option, planning: Option, #[serde(default, skip_serializing_if = "Option::is_none")] - model_profiles: Option>, - #[serde(default, skip_serializing_if = "Option::is_none")] - selected_model_profile_id: Option, -} - -#[derive(Clone, Debug, Default, Deserialize, Serialize)] -#[serde(rename_all = "camelCase")] -struct GameCreatorModelProfileFile { - id: String, - name: String, - model_id: String, - #[serde(default = "default_model_profile_enabled")] - enabled: bool, - #[serde(default)] - reasoning_effort: Option, -} - -fn default_model_profile_enabled() -> bool { - true + selected_model_id: Option, } #[derive(Clone, Debug, Default, Deserialize, Serialize)] @@ -1101,13 +1083,7 @@ struct GameCreatorAppConfig { #[serde(default)] planning: GameCreatorPlanningConfig, #[serde(default)] - model_profiles: Vec, - #[serde(default = "default_selected_model_profile_id")] - selected_model_profile_id: String, -} - -fn default_selected_model_profile_id() -> String { - "default".to_string() + selected_model_id: String, } #[derive(Clone, Debug, Deserialize, Serialize)] @@ -1530,7 +1506,7 @@ const GAME_CREATOR_AGENT_MODE_CODEX_CLI: &str = "codex_cli"; const GAME_CREATOR_AGENT_MODE_PROVIDER: &str = "provider"; const GAME_CREATOR_APP_CONFIG_SCHEMA_VERSION: &str = "game-creator-config.v2"; const DEFAULT_GAME_CREATOR_LLM_BASE_URL: &str = "https://dev.genarrative.world/gpt/v1"; -const DEFAULT_GAME_CREATOR_LLM_MODEL: &str = "gpt-5.6-sol"; +const DEFAULT_GAME_CREATOR_LLM_MODEL: &str = "gpt-6-astra"; const DEFAULT_GAME_CREATOR_LLM_API_KIND: &str = "openai_responses"; const DEFAULT_GAME_CREATOR_LLM_REASONING_EFFORT: &str = "max"; const DEFAULT_GAME_CREATOR_LLM_CONTEXT_WINDOW_TOKENS: u64 = 128_000; @@ -1634,14 +1610,7 @@ impl Default for GameCreatorAppConfig { agent_llm: BTreeMap::new(), editor_api: GameCreatorEditorApiConfig::default(), planning: GameCreatorPlanningConfig::default(), - model_profiles: vec![GameCreatorModelProfileFile { - id: "default".to_string(), - name: "陶泥儿智能创作".to_string(), - model_id: OFFICIAL_LLM_ROUTER_MODEL.to_string(), - enabled: true, - reasoning_effort: Some("max".to_string()), - }], - selected_model_profile_id: default_selected_model_profile_id(), + selected_model_id: String::new(), } } } @@ -2570,6 +2539,7 @@ fn main() { clear_platform_account_session, read_game_creator_app_config, write_game_creator_app_config, + select_game_creator_model, upload_local_asset, register_local_asset, create_ui_design_resource, diff --git a/apps/ai-game-creator-shell/src/app/types.ts b/apps/ai-game-creator-shell/src/app/types.ts index df3f223ec..493fdb748 100644 --- a/apps/ai-game-creator-shell/src/app/types.ts +++ b/apps/ai-game-creator-shell/src/app/types.ts @@ -718,21 +718,12 @@ export interface GameCreatorAppConfig { baseUrl: string; apiKey: string; }; - modelProfiles: GameCreatorModelProfile[]; - selectedModelProfileId: string; + selectedModelId?: string; planning?: { capabilityEnabled: boolean; }; } -export interface GameCreatorModelProfile { - id: string; - name: string; - modelId: string; - enabled: boolean; - reasoningEffort: GameCreatorLlmReasoningEffort; -} - export interface GameCreatorAppConfigView { path: string; config: GameCreatorAppConfig; diff --git a/apps/ai-game-creator-shell/src/features/project-workspace/ConversationModelSelect.tsx b/apps/ai-game-creator-shell/src/features/project-workspace/ConversationModelSelect.tsx new file mode 100644 index 000000000..60ed9f736 --- /dev/null +++ b/apps/ai-game-creator-shell/src/features/project-workspace/ConversationModelSelect.tsx @@ -0,0 +1,110 @@ +import { RefreshCcw } from 'lucide-react'; +import { useCallback, useEffect, useState } from 'react'; + +import { resolveTauriInvoke } from '../../app/tauri'; +import type { GameCreatorAppConfigView } from '../../app/types'; +import { + type ClientLlmModel, + loadClientLlmModels, +} from '../../services/clientApi'; + +export function ConversationModelSelect({ + disabled, + onReady, +}: { + disabled: boolean; + onReady: (ready: boolean) => void; +}) { + const [models, setModels] = useState([]); + const [selected, setSelected] = useState(''); + const [busy, setBusy] = useState(true); + const [error, setError] = useState(''); + const refresh = useCallback(async () => { + setBusy(true); + setError(''); + onReady(false); + try { + const invoke = resolveTauriInvoke(); + if (!invoke) throw new Error('Native host unavailable'); + const [catalog, config] = await Promise.all([ + loadClientLlmModels(), + invoke('read_game_creator_app_config'), + ]); + setModels(catalog.models); + const id = config.config.selectedModelId || catalog.defaultModelId; + setSelected(id); + const available = catalog.models.some((model) => model.id === id); + if (available && !config.config.selectedModelId) { + const saved = await invoke( + 'select_game_creator_model', + { modelId: id }, + ); + if (saved.config.selectedModelId !== id) + throw new Error('Default selection was not saved'); + } + onReady(available); + if (!available) setError('请选择可用模型'); + } catch { + setModels([]); + setError('模型列表加载失败'); + } finally { + setBusy(false); + } + }, [onReady]); + + useEffect(() => { + void refresh(); + }, [refresh]); + + async function select(id: string) { + onReady(false); + setBusy(true); + setError(''); + try { + const invoke = resolveTauriInvoke(); + if (!invoke) throw new Error('Native host unavailable'); + const result = await invoke( + 'select_game_creator_model', + { modelId: id }, + ); + if (result.config.selectedModelId !== id) + throw new Error('Selection was not saved'); + setSelected(id); + onReady(true); + } catch { + setError('模型选择保存失败'); + } finally { + setBusy(false); + } + } + + return ( +
+ {error ? {error} : null} + + +
+ ); +} diff --git a/apps/ai-game-creator-shell/src/features/project-workspace/ProjectSupervisorView.tsx b/apps/ai-game-creator-shell/src/features/project-workspace/ProjectSupervisorView.tsx index bd209924d..2c04e0227 100644 --- a/apps/ai-game-creator-shell/src/features/project-workspace/ProjectSupervisorView.tsx +++ b/apps/ai-game-creator-shell/src/features/project-workspace/ProjectSupervisorView.tsx @@ -27,6 +27,7 @@ import { } from '../agent-runtime'; import { formatAgentCardRuntimeStatus } from '../project-summary/agentPresentation'; import { taskStatusLabels } from '../project-summary/projectSummary'; +import { ConversationModelSelect } from './ConversationModelSelect'; import { PlanGddSurface } from './GddApprovalCard'; import { pendingCommandDetail, @@ -143,6 +144,7 @@ export function ProjectSupervisorView({ ? '思考中' : '发送'; const submitting = runtimePanelProps.controlBusy && !needsUserInput; + const [modelReady, setModelReady] = useState(false); return (
) : null} -
+ { + if (directCodex && !modelReady) { + event.preventDefault(); + return; + } + onSubmit(event); + }} + >