Merge branch 'master' into opt/design_agent
Project CI / Repository checks (pull_request) Successful in 2m51s
Project CI / Frontend tests (pull_request) Successful in 3m15s
Project CI / Backend tests (pull_request) Successful in 5m26s
Project CI / Native shell tests (pull_request) Successful in 21m10s

This commit is contained in:
2026-09-14 13:24:15 +08:00
20 changed files with 449 additions and 347 deletions
@@ -1378,15 +1378,19 @@ fn codex_app_server_thread_start_params(
base_instructions: String,
use_model_provider: bool,
) -> serde_json::Value {
// DirectProject is an autonomous Codex session. The app-server sandbox
// remains the hard write boundary; approval prompts are not a second
// harness that can stall a turn. DirectHome/ToolHost stay passive.
// DirectProject is an autonomous Codex session with explicit full OS
// access; approval prompts are not a second harness that can stall a turn.
// DirectHome/ToolHost stay passive.
let approval_policy = "never";
let mut params = serde_json::json!({
"model": model,
"cwd": workspace_path,
"approvalPolicy": approval_policy,
"sandbox": if workspace_mode.allows_workspace_writes() { "workspace-write" } else { "read-only" },
"sandbox": if workspace_mode == CodexAppServerWorkspaceMode::DirectProject {
"danger-full-access"
} else {
"read-only"
},
"ephemeral": true,
"baseInstructions": base_instructions
});
@@ -1404,7 +1408,6 @@ fn codex_app_server_turn_start_params(
thread_id: &str,
input: serde_json::Value,
model: &str,
workspace_path: &std::path::Path,
workspace_mode: CodexAppServerWorkspaceMode,
client_user_message_id: Option<&str>,
) -> serde_json::Value {
@@ -1415,14 +1418,12 @@ fn codex_app_server_turn_start_params(
"model": model,
"approvalPolicy": approval_policy,
});
if workspace_mode.allows_workspace_writes() {
// npm install/build must resolve project dependencies. Network access
// is enabled only for DirectProject; writableRoots keeps the file-write
// boundary at the real game workspace.
if workspace_mode == CodexAppServerWorkspaceMode::DirectProject {
// DirectProject is an explicitly user-selected local Codex session.
// Give the native Codex tools the full OS sandbox profile so they are
// not narrowed by a project-root writableRoots allowlist.
params["sandboxPolicy"] = serde_json::json!({
"type": "workspaceWrite",
"writableRoots": [workspace_path],
"networkAccess": true
"type": "dangerFullAccess"
});
}
if let Some(client_user_message_id) = client_user_message_id
@@ -1436,40 +1437,27 @@ fn codex_app_server_turn_start_params(
}
fn game_creator_codex_app_server_interaction_response(
workspace_path: &std::path::Path,
workspace_mode: CodexAppServerWorkspaceMode,
id: u64,
method: &str,
requested_grant_root: Option<&str>,
_method: &str,
_requested_grant_root: Option<&str>,
) -> serde_json::Value {
let direct_workspace = workspace_mode.allows_workspace_writes();
let file_change_within_workspace = game_creator_codex_file_change_request_is_allowed(
workspace_path,
method,
requested_grant_root,
);
if direct_workspace && file_change_within_workspace {
serde_json::json!({
if workspace_mode == CodexAppServerWorkspaceMode::DirectProject {
// Full-access DirectProject sessions do not use a file-root allowlist
// or a second approval gate. The declared sandbox policy is the only
// capability boundary for native Codex operations.
return serde_json::json!({
"id": id,
"result": { "decision": "accept" }
})
} else if direct_workspace && method == "item/fileChange/requestApproval" {
serde_json::json!({
"id": id,
"error": {
"code": -32602,
"message": "Genarrative AGC 只允许当前项目工作区的文件变更审批"
}
})
} else {
serde_json::json!({
"id": id,
"error": {
"code": -32601,
"message": "Genarrative AGC 拒绝 app-server 的交互、审批与工具请求"
}
})
});
}
serde_json::json!({
"id": id,
"error": {
"code": -32601,
"message": "Genarrative AGC 拒绝 app-server 的交互、审批与工具请求"
}
})
}
#[cfg(test)]
@@ -2734,7 +2722,6 @@ impl CodexAppServerConnection {
&thread_id,
input,
model,
&self.inner.workspace_path,
self.inner.workspace_mode,
direct_client_turn_id,
);
@@ -3296,7 +3283,6 @@ async fn read_game_creator_codex_app_server_stdout(
);
}
let response = game_creator_codex_app_server_interaction_response(
&inner.workspace_path,
inner.workspace_mode,
id,
method,
@@ -3638,82 +3624,6 @@ async fn read_game_creator_codex_app_server_stderr(
}
}
#[cfg(windows)]
fn game_creator_codex_workspace_path_key(path: &std::path::Path) -> String {
let value = path.to_string_lossy();
value
.strip_prefix("\\\\?\\")
.unwrap_or(value.as_ref())
.replace('/', "\\")
.trim_end_matches('\\')
.to_ascii_lowercase()
}
fn game_creator_codex_grant_root_is_within_workspace(
workspace: &std::path::Path,
grant_root: &str,
) -> bool {
fn canonicalize_with_missing_tail(path: &std::path::Path) -> Option<std::path::PathBuf> {
if !path.is_absolute()
|| path
.components()
.any(|component| matches!(component, std::path::Component::ParentDir))
{
return None;
}
let mut existing = path.to_path_buf();
let mut missing_tail = Vec::new();
while !existing.exists() {
missing_tail.push(existing.file_name()?.to_os_string());
if !existing.pop() {
return None;
}
}
let mut normalized = existing.canonicalize().ok()?;
for component in missing_tail.iter().rev() {
normalized.push(component);
}
Some(normalized)
}
let workspace = workspace
.canonicalize()
.unwrap_or_else(|_| workspace.to_path_buf());
let Some(grant_root) = canonicalize_with_missing_tail(std::path::Path::new(grant_root)) else {
return false;
};
#[cfg(windows)]
{
let workspace_key = game_creator_codex_workspace_path_key(&workspace);
let grant_key = game_creator_codex_workspace_path_key(&grant_root);
grant_key == workspace_key
|| grant_key
.strip_prefix(&workspace_key)
.is_some_and(|suffix| suffix.starts_with('\\'))
}
#[cfg(not(windows))]
{
grant_root == workspace || grant_root.starts_with(&workspace)
}
}
fn game_creator_codex_file_change_request_is_allowed(
workspace: &std::path::Path,
method: &str,
grant_root: Option<&str>,
) -> bool {
if method != "item/fileChange/requestApproval" {
return false;
}
// `grantRoot: null` means the already-declared turn sandbox root. It is
// valid only for file changes; it must never authorize another capability
// or a broader permission request.
grant_root.is_none()
|| grant_root.is_some_and(|grant_root| {
game_creator_codex_grant_root_is_within_workspace(workspace, grant_root)
})
}
async fn fail_game_creator_codex_app_server_connection(
inner: &Weak<CodexAppServerInner>,
error: String,
@@ -4484,7 +4394,6 @@ mod tests {
"home-thread",
serde_json::json!([{ "type": "text", "text": "你好" }]),
"fixture-model",
workspace,
CodexAppServerWorkspaceMode::DirectHome,
None,
);
@@ -4498,7 +4407,6 @@ mod tests {
"item/permissions/requestApproval",
] {
let response = game_creator_codex_app_server_interaction_response(
workspace,
CodexAppServerWorkspaceMode::DirectHome,
7,
method,
@@ -4516,14 +4424,10 @@ mod tests {
}
#[test]
fn direct_project_protocol_and_interactions_expose_only_the_real_game_workspace() {
fn direct_project_protocol_uses_full_access_without_a_root_allowlist() {
let temp = tempfile::tempdir().expect("temp dir");
let project_root = temp.path().join("project");
let assets = project_root.join("assets");
let agent = project_root.join(".agent");
std::fs::create_dir_all(&project_root).expect("project root");
std::fs::create_dir(&assets).expect("assets directory");
std::fs::create_dir(&agent).expect("agent directory");
let workspace =
resolve_direct_codex_game_workspace(&project_root).expect("resolve project workspace");
assert_eq!(
@@ -4539,83 +4443,40 @@ mod tests {
true,
);
assert_eq!(thread["cwd"], serde_json::json!(workspace));
assert_eq!(thread["sandbox"], "workspace-write");
assert_eq!(thread["sandbox"], "danger-full-access");
let turn = codex_app_server_turn_start_params(
"project-thread",
serde_json::json!([{ "type": "text", "text": "修复游戏" }]),
"fixture-model",
&workspace,
CodexAppServerWorkspaceMode::DirectProject,
Some("direct-turn-0001"),
);
assert_eq!(turn["clientUserMessageId"], "direct-turn-0001");
assert_eq!(
turn.pointer("/sandboxPolicy/writableRoots/0"),
Some(&serde_json::json!(workspace))
);
assert_eq!(
turn.pointer("/sandboxPolicy/networkAccess"),
Some(&serde_json::json!(true))
);
let authority_paths = [
turn.get("cwd"),
turn.pointer("/sandboxPolicy/writableRoots/0"),
];
assert!(
authority_paths
.iter()
.flatten()
.all(|value| value.as_str() == Some(workspace.to_string_lossy().as_ref())),
"writable params must be exactly the project workspace"
turn.pointer("/sandboxPolicy/type"),
Some(&serde_json::json!("dangerFullAccess"))
);
assert!(turn.pointer("/sandboxPolicy/writableRoots").is_none());
assert!(turn.pointer("/sandboxPolicy/networkAccess").is_none());
let workspace_string = workspace.to_string_lossy().into_owned();
for allowed_root in [None, Some(workspace_string.as_str())] {
for (id, method) in [
(9, "item/fileChange/requestApproval"),
(10, "item/commandExecution/requestApproval"),
(11, "item/permissions/requestApproval"),
(12, "item/tool/call"),
] {
let response = game_creator_codex_app_server_interaction_response(
&workspace,
CodexAppServerWorkspaceMode::DirectProject,
9,
"item/fileChange/requestApproval",
allowed_root,
id,
method,
Some("C:\\outside-project"),
);
assert_eq!(
response.pointer("/result/decision"),
Some(&serde_json::json!("accept"))
);
}
for forbidden_root in [assets, agent] {
let forbidden_root = forbidden_root.to_string_lossy().into_owned();
let response = game_creator_codex_app_server_interaction_response(
&workspace,
CodexAppServerWorkspaceMode::DirectProject,
10,
"item/fileChange/requestApproval",
Some(&forbidden_root),
);
assert_eq!(
response.pointer("/result/decision"),
Some(&serde_json::json!("accept")),
"project-root children must stay writable: {forbidden_root}"
);
}
for method in [
"item/commandExecution/requestApproval",
"item/permissions/requestApproval",
"item/tool/call",
] {
for requested_root in [None, Some(workspace_string.as_str())] {
let response = game_creator_codex_app_server_interaction_response(
&workspace,
CodexAppServerWorkspaceMode::DirectProject,
11,
method,
requested_root,
);
assert!(response.get("error").is_some());
assert!(response.get("result").is_none());
}
}
}
#[test]
@@ -4633,26 +4494,6 @@ mod tests {
assert!(resolve_direct_codex_game_workspace(&file_root).is_err());
}
#[cfg(all(unix, not(target_os = "macos")))]
#[test]
fn direct_project_grant_root_comparison_remains_case_sensitive() {
let temp = tempfile::tempdir().expect("temp dir");
let project_root = temp.path().join("Project");
let child = project_root.join("assets");
let different_case = temp.path().join("project").join("assets");
std::fs::create_dir_all(&child).expect("child directory");
std::fs::create_dir_all(&different_case).expect("different-case directory");
assert!(game_creator_codex_grant_root_is_within_workspace(
&project_root,
project_root.to_string_lossy().as_ref()
));
assert!(!game_creator_codex_grant_root_is_within_workspace(
&project_root,
different_case.to_string_lossy().as_ref()
));
}
#[cfg(unix)]
#[test]
fn direct_project_rejects_a_non_directory_workspace() {
@@ -5249,51 +5090,25 @@ while IFS= read -r line; do :; done
}
#[test]
fn direct_file_change_approval_is_limited_to_workspace() {
let temp = tempfile::tempdir().expect("temp dir");
let workspace = temp.path().join("demo");
let child = workspace.join("assets");
let sibling = temp.path().join("demolition");
std::fs::create_dir_all(&child).expect("workspace child");
std::fs::create_dir(&sibling).expect("sibling");
assert!(game_creator_codex_file_change_request_is_allowed(
&workspace,
fn direct_project_interactions_accept_full_access_without_a_root_allowlist() {
for method in [
"item/fileChange/requestApproval",
None
));
assert!(game_creator_codex_grant_root_is_within_workspace(
&workspace,
workspace.to_string_lossy().as_ref()
));
assert!(game_creator_codex_grant_root_is_within_workspace(
&workspace,
child.to_string_lossy().as_ref()
));
assert!(!game_creator_codex_grant_root_is_within_workspace(
&workspace,
sibling.to_string_lossy().as_ref()
));
assert!(!game_creator_codex_file_change_request_is_allowed(
&workspace,
"item/fileChange/requestApproval",
Some(sibling.to_string_lossy().as_ref())
));
assert!(!game_creator_codex_grant_root_is_within_workspace(
&workspace,
sibling.join("missing").to_string_lossy().as_ref()
));
assert!(game_creator_codex_grant_root_is_within_workspace(
&workspace,
workspace.join("missing").to_string_lossy().as_ref()
));
assert!(!game_creator_codex_grant_root_is_within_workspace(
&workspace,
workspace
.join("..")
.join("outside")
.to_string_lossy()
.as_ref()
));
"item/commandExecution/requestApproval",
"item/permissions/requestApproval",
"item/tool/call",
] {
let response = game_creator_codex_app_server_interaction_response(
CodexAppServerWorkspaceMode::DirectProject,
1,
method,
Some("C:\\outside-project"),
);
assert_eq!(
response.pointer("/result/decision"),
Some(&serde_json::json!("accept"))
);
assert!(response.get("error").is_none());
}
}
#[test]
File diff suppressed because one or more lines are too long
@@ -1945,28 +1945,36 @@ pub(crate) fn read_platform_account_session_generation() -> u64 {
}
#[tauri::command]
pub(crate) fn install_platform_account_session(
pub(crate) async fn install_platform_account_session(
user_id: String,
access_token: String,
api_base_url: String,
generation: u64,
) -> Result<(), String> {
validate_platform_session_input(&user_id, &access_token, &api_base_url, generation)?;
install_external_agent_runner_platform_session(
&user_id,
&access_token,
&api_base_url,
generation,
)?;
install_platform_session(&user_id, &access_token, &api_base_url, generation)
tokio::task::spawn_blocking(move || {
validate_platform_session_input(&user_id, &access_token, &api_base_url, generation)?;
install_external_agent_runner_platform_session(
&user_id,
&access_token,
&api_base_url,
generation,
)?;
install_platform_session(&user_id, &access_token, &api_base_url, generation)
})
.await
.map_err(|error| format!("安装本地运行时会话任务意外终止:{error}"))?
}
#[tauri::command]
pub(crate) fn clear_platform_account_session(generation: u64) -> Result<(), String> {
shutdown_game_creator_codex_app_servers()?;
clear_external_agent_runner_platform_session(generation)?;
clear_platform_session(generation);
Ok(())
pub(crate) async fn clear_platform_account_session(generation: u64) -> Result<(), String> {
tokio::task::spawn_blocking(move || {
shutdown_game_creator_codex_app_servers()?;
clear_external_agent_runner_platform_session(generation)?;
clear_platform_session(generation);
Ok(())
})
.await
.map_err(|error| format!("清除本地运行时会话任务意外终止:{error}"))?
}
#[tauri::command]
@@ -75,6 +75,7 @@ function withAuthCheckTimeout<T>(
timeoutMs: number,
message: string,
) {
void promise.catch(() => undefined);
let timeoutId: number | undefined;
const timeout = new Promise<T>((_, reject) => {
timeoutId = window.setTimeout(() => reject(new Error(message)), timeoutMs);
@@ -492,10 +493,14 @@ export function AuthenticatedClient({
password,
loginApiBaseUrl,
);
const committedGeneration = await commitAuthenticatedPlatformSession(
user,
loginGeneration,
loginApiBaseUrl,
const committedGeneration = await withAuthCheckTimeout(
commitAuthenticatedPlatformSession(
user,
loginGeneration,
loginApiBaseUrl,
),
AUTH_CHECK_RUNNER_TIMEOUT_MS,
'连接本地运行时超时,请重试或重启客户端',
);
if (committedGeneration === null) {
return;
@@ -524,7 +529,11 @@ export function AuthenticatedClient({
clearStoredAuthAccessToken();
}
try {
await clearCommittedPlatformSession(logoutGeneration);
await withAuthCheckTimeout(
clearCommittedPlatformSession(logoutGeneration),
AUTH_CHECK_RUNNER_TIMEOUT_MS,
'清理本地运行时超时,请重启客户端后再登录',
);
} catch (error) {
nativeClearError = error;
}
@@ -522,6 +522,7 @@ export function WorkspaceLauncherShell({
onStatusChange={setStatus}
recentProjectRows={recentProjectRows}
onCreateDraftAutomatically={createHomeDraftAutomatically}
creationBusy={homeProject.projectAction === 'creating'}
onProjectsOpen={() => setLauncherView('projects')}
onProjectOpen={(path) => {
setProjectPath(path);
@@ -230,6 +230,9 @@ export function buildRecentProjectRows(
>,
recentWorkspaceRefreshing: boolean,
): RecentProjectRow[] {
// The refresh flag is kept for the page-level indicator. Each row owns its
// pending state so a slow directory cannot disable already inspected rows.
void recentWorkspaceRefreshing;
return recentWorkspaces.map((workspace) => {
const directoryStatus = recentWorkspaceStatuses[workspace];
const isPendingStatus = directoryStatus === undefined;
@@ -237,34 +240,31 @@ export function buildRecentProjectRows(
directoryStatus?.projectName ||
workspace.split(/[\\/]/).filter(Boolean).pop() ||
workspace;
const status = recentWorkspaceRefreshing
const status = isPendingStatus
? '检查中'
: isPendingStatus
? '检查'
: directoryStatus === null
? '检查失败'
: directoryStatus?.exists === false
? '未找到'
: directoryStatus?.isDirectory === false
? '不是文件夹'
: directoryStatus?.manifestError
? '无法读取'
: (directoryStatus?.isGodotProject === true ||
directoryStatus?.isCocosProject === true) &&
directoryStatus?.isGameCreatorProject === false
? '可导入'
: directoryStatus?.isGameCreatorProject === false
? '未初始化'
: directoryStatus?.recentRunStatus
? formatRecentProjectRunStatus(
directoryStatus.recentRunStatus,
directoryStatus.recentRunStopReason,
)
: directoryStatus?.isGodotProject
? '可打开'
: '本地项目';
: directoryStatus === null
? '检查失败'
: directoryStatus?.exists === false
? '未找到'
: directoryStatus?.isDirectory === false
? '不是文件夹'
: directoryStatus?.manifestError
? '无法读取'
: (directoryStatus?.isGodotProject === true ||
directoryStatus?.isCocosProject === true) &&
directoryStatus?.isGameCreatorProject === false
? '可导入'
: directoryStatus?.isGameCreatorProject === false
? '未初始化'
: directoryStatus?.recentRunStatus
? formatRecentProjectRunStatus(
directoryStatus.recentRunStatus,
directoryStatus.recentRunStopReason,
)
: directoryStatus?.isGodotProject
? '可打开'
: '本地项目';
const canReveal =
!recentWorkspaceRefreshing &&
Boolean(directoryStatus) &&
directoryStatus?.exists !== false &&
directoryStatus?.isDirectory !== false;
@@ -286,7 +286,6 @@ export function buildRecentProjectRows(
recentRunStopReason: directoryStatus?.recentRunStopReason ?? null,
canReveal,
canOpen:
!recentWorkspaceRefreshing &&
Boolean(directoryStatus) &&
directoryStatus?.exists !== false &&
directoryStatus?.isDirectory !== false &&
@@ -644,37 +644,53 @@ export function useHomeProjectCreation({
startMode: ProjectStartMode,
options: { suggestName: boolean },
) {
if (projectActionRef.current) {
return '已有项目操作进行中,请稍候';
}
const invoke = resolveTauriInvoke();
if (!invoke) {
throw new Error('需要在陶泥儿客户端内运行');
}
const suggestedName = options.suggestName
? await suggestAutomaticProjectName(invoke, draft)
: null;
const result = await invoke<InitLocalProjectResult>(
'create_automatic_local_game_project',
{
name: suggestedName,
planning: startMode === 'planning',
},
);
// This action is owned by WorkspaceLauncher rather than HomeView. The
// launcher survives navigation, so unmounting the home page cannot release
// the guard while project creation or first-turn import is still running.
projectActionRef.current = 'creating';
setProjectAction('creating');
setStatus('正在创建工作区');
try {
await enterCreatedHomeProject(
invoke,
result,
draft.creationType,
draft.prompt,
draft.attachments,
startMode,
const suggestedName = options.suggestName
? await suggestAutomaticProjectName(invoke, draft)
: null;
const result = await invoke<InitLocalProjectResult>(
'create_automatic_local_game_project',
{
name: suggestedName,
planning: startMode === 'planning',
},
);
setStatus('已创建工作区,正在开始智能创作');
return '已创建工作区并进入项目开发';
} catch (error) {
const message = `工作区已创建;首条需求投递失败:${
error instanceof Error ? error.message : String(error)
}`;
setStatus(message);
throw new Error(message);
try {
await enterCreatedHomeProject(
invoke,
result,
draft.creationType,
draft.prompt,
draft.attachments,
startMode,
);
setStatus('已创建工作区,正在开始智能创作');
return '已创建工作区并进入项目开发';
} catch (error) {
const message = `工作区已创建;首条需求投递失败:${
error instanceof Error ? error.message : String(error)
}`;
setStatus(message);
throw new Error(message);
}
} finally {
if (projectActionRef.current === 'creating') {
projectActionRef.current = null;
setProjectAction(null);
}
}
}
@@ -19,6 +19,8 @@ import {
writeRecentWorkspace,
} from './model';
const RECENT_WORKSPACE_CHECK_TIMEOUT_MS = 5_000;
export function useRecentProjects(setStatus: Dispatch<SetStateAction<string>>) {
const [recentWorkspaces, setRecentWorkspaces] =
useState<string[]>(readRecentWorkspaces);
@@ -34,14 +36,26 @@ export function useRecentProjects(setStatus: Dispatch<SetStateAction<string>>) {
invoke: NonNullable<ReturnType<typeof resolveTauriInvoke>>,
workspace: string,
): Promise<[string, LocalProjectDirectoryStatus | null]> {
let timeoutHandle: number | undefined;
try {
const result = await invoke<LocalProjectDirectoryStatus>(
'inspect_local_project_directory',
{ projectPath: workspace },
);
const result = await Promise.race([
invoke<LocalProjectDirectoryStatus>('inspect_local_project_directory', {
projectPath: workspace,
}),
new Promise<never>((_, reject) => {
timeoutHandle = window.setTimeout(
() => reject(new Error('项目目录检查超时')),
RECENT_WORKSPACE_CHECK_TIMEOUT_MS,
);
}),
]);
return [workspace, result];
} catch {
return [workspace, null];
} finally {
if (timeoutHandle !== undefined) {
window.clearTimeout(timeoutHandle);
}
}
}
@@ -53,18 +67,27 @@ export function useRecentProjects(setStatus: Dispatch<SetStateAction<string>>) {
return;
}
let disposed = false;
let pendingCount = recentWorkspaces.length;
setRecentWorkspaceStatuses({});
setRecentWorkspaceRefreshing(true);
void Promise.all(
recentWorkspaces.map((workspace) =>
inspectRecentWorkspace(invoke, workspace),
),
).then((entries) => {
if (disposed) {
return;
}
setRecentWorkspaceStatuses(Object.fromEntries(entries));
setRecentWorkspaceRefreshing(false);
});
for (const workspace of recentWorkspaces) {
void inspectRecentWorkspace(invoke, workspace).then(
([projectPath, status]) => {
if (disposed) {
return;
}
setRecentWorkspaceStatuses((current) => ({
...current,
[projectPath]: status,
}));
pendingCount -= 1;
if (pendingCount === 0) {
setRecentWorkspaceRefreshing(false);
}
},
);
}
return () => {
disposed = true;
};
@@ -15,7 +15,11 @@ import {
API_RESPONSE_ENVELOPE_VERSION,
unwrapApiResponse,
} from '../../../../packages/shared/src/http';
import { fetchClientHttp, getClientServerBaseUrl } from './clientHttp';
import {
fetchClientHttp,
getClientServerBaseUrl,
readClientHttpResponseText,
} from './clientHttp';
const ACCESS_TOKEN_STORAGE_KEY = 'genarrative.auth.access-token.v1';
@@ -104,7 +108,9 @@ export function getClientAuthErrorMessage(error: unknown, fallback: string) {
}
async function readAuthErrorMessage(response: Response, fallback: string) {
const text = await response.text();
const text = await readClientHttpResponseText(response, {
url: 'auth error response',
});
if (!text.trim()) {
return fallback;
}
@@ -158,7 +164,9 @@ async function requestAuthJson<T>(
{ status: response.status },
);
}
const text = await response.text();
const text = await readClientHttpResponseText(response, {
url,
});
return text ? unwrapApiResponse<T>(JSON.parse(text) as T) : (null as T);
}
@@ -31,6 +31,60 @@ export function isClientHttpTimeoutError(
return error instanceof ClientHttpTimeoutError;
}
/**
* Read a response body with the same bounded lifetime as the request that
* produced it. Some transports resolve fetch() after headers arrive while
* leaving body consumption pending indefinitely.
*/
export async function readClientHttpResponseText(
response: Response,
options: { timeoutMs?: number | null; url?: string } = {},
) {
const timeoutMs =
options.timeoutMs === undefined
? CLIENT_HTTP_DEFAULT_TIMEOUT_MS
: options.timeoutMs;
if (timeoutMs === null) {
return response.text();
}
if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) {
throw new RangeError('响应体超时时间必须是大于 0 的有限数值');
}
let timedOut = false;
let timeoutHandle: ReturnType<typeof setTimeout> | undefined;
const bodyPromise = response.text();
// A transport may reject after cancel() unblocks the stream. The race owns
// the observable result, so keep the late rejection out of the global queue.
void bodyPromise.catch(() => undefined);
const timeout = new Promise<never>((_, reject) => {
timeoutHandle = setTimeout(() => {
timedOut = true;
try {
void response.body?.cancel().catch(() => undefined);
} catch {
// Response doubles and older WebViews may not expose cancel().
}
reject(
new ClientHttpTimeoutError(options.url ?? 'response body', timeoutMs),
);
}, timeoutMs);
});
try {
return await Promise.race([bodyPromise, timeout]);
} catch (error) {
if (timedOut) {
throw new ClientHttpTimeoutError(
options.url ?? 'response body',
timeoutMs,
);
}
throw error;
} finally {
if (timeoutHandle !== undefined) clearTimeout(timeoutHandle);
}
}
export type ClientServerPreset = 'release' | 'dev' | 'custom';
export type ClientServerSelection = {
@@ -112,6 +112,7 @@ type HomeViewProps = {
draft: HomeDraft,
startMode: ProjectStartMode,
) => Promise<string>;
creationBusy?: boolean;
onProjectsOpen: () => void;
onProjectOpen: (path: string) => void;
onProjectPick: () => void;
@@ -123,6 +124,7 @@ export default function HomeView({
onStatusChange,
recentProjectRows,
onCreateDraftAutomatically,
creationBusy = false,
onProjectsOpen,
onProjectOpen,
onProjectPick,
@@ -148,7 +150,7 @@ export default function HomeView({
homeCreationType === 'doc' ? 'planning' : 'direct-build';
async function createFromHome() {
if (homeCreationBusyRef.current) {
if (homeCreationBusyRef.current || creationBusy) {
return;
}
const referencedAttachments = richTextToAttachments(homeRichText);
@@ -261,7 +263,7 @@ export default function HomeView({
<div className="flex shrink-0 items-center gap-1.5">
<ConversationModelSelect
className="home-input-model-select"
disabled={homeCreationBusy}
disabled={homeCreationBusy || creationBusy}
/>
<button
className="grid size-7 cursor-pointer place-items-center rounded-full border-0 bg-(image:--platform-button-primary-fill) p-0 text-(--platform-button-primary-text) shadow-(--platform-profile-action-shadow) transition-transform hover:scale-105 disabled:cursor-not-allowed disabled:opacity-55"
@@ -269,7 +271,7 @@ export default function HomeView({
aria-label={
startMode === 'planning' ? '进入立项策划' : '开启创作'
}
disabled={homeCreationBusy}
disabled={homeCreationBusy || creationBusy}
>
{homeCreationBusy ? (
<Loader2
@@ -1807,17 +1807,30 @@ export function registerHomeProjectCreationTests() {
expect(screen.getByLabelText('最近项目').textContent).not.toContain(
'选择一个项目继续创作',
);
expect(screen.getByLabelText('最近项目').textContent).not.toContain(
'正在创建工作区',
);
expect(screen.queryByText('正在创建工作区')).toBeNull();
expect(screen.getByText('正在创建工作区')).not.toBeNull();
// The launcher owns the operation, so navigating away and back must not
// release the duplicate-create guard or lose the in-progress status.
fireEvent.click(screen.getByRole('button', { name: '项目组' }));
expect(await screen.findByLabelText('项目列表')).not.toBeNull();
fireEvent.click(screen.getByRole('button', { name: '首页' }));
const returnedCreateButton = await screen.findByRole('button', {
name: '开启创作',
});
expect((returnedCreateButton as HTMLButtonElement).disabled).toBe(true);
expect(screen.getByText('正在创建工作区')).not.toBeNull();
expect(
invoke.mock.calls.filter(
([command]) => command === 'create_automatic_local_game_project',
),
).toHaveLength(1);
await act(async () => {
rejectAutomaticProject?.(new Error('自动创建测试结束'));
await automaticProject.catch(() => undefined);
});
await waitFor(() => {
expect((createButton as HTMLButtonElement).disabled).toBe(false);
expect((returnedCreateButton as HTMLButtonElement).disabled).toBe(false);
});
expect(screen.queryByText('自动创建测试结束')).toBeNull();
});
@@ -6,6 +6,7 @@ import {
requestClientApi,
setStoredAuthAccessToken,
} from '../src/services/clientApi';
import { refreshClientAuthAccessToken } from '../src/services/clientAuth';
import {
beginPlatformSessionTransition,
commitAuthenticatedPlatformSession,
@@ -144,3 +145,34 @@ it('请求期间账号切换后,不替新账号续期或重发旧请求', asyn
await rejection;
expect(fetch).toHaveBeenCalledTimes(1);
});
it('响应体卡住超时后,下一次续期会重新发起请求', async () => {
vi.useFakeTimers();
let refreshCalls = 0;
vi.spyOn(globalThis, 'fetch').mockImplementation((input) => {
if (input === '/api/auth/refresh') {
refreshCalls += 1;
}
return Promise.resolve(
new Response(
new ReadableStream<Uint8Array>({
start() {
// Simulate headers returned while the body remains open.
},
}),
{ status: 200 },
),
);
});
const first = refreshClientAuthAccessToken('http://localhost:3000');
const firstAssertion = expect(first).rejects.toThrow();
await vi.advanceTimersByTimeAsync(15_000);
await firstAssertion;
const second = refreshClientAuthAccessToken('http://localhost:3000');
const secondAssertion = expect(second).rejects.toThrow();
expect(refreshCalls).toBe(2);
await vi.advanceTimersByTimeAsync(15_000);
await secondAssertion;
});
@@ -15,6 +15,7 @@ import {
getClientServerBaseUrl,
getClientServerSelection,
normalizeClientServerBaseUrl,
readClientHttpResponseText,
resetClientServerSelectionForTests,
resolveClientHttpTarget,
setClientServerSelection,
@@ -273,6 +274,29 @@ describe('AGC client HTTP transport', () => {
expect((forwardedInit.signal as AbortSignal).aborted).toBe(true);
});
it('times out after response headers when the response body never completes', async () => {
vi.useFakeTimers();
const response = new Response(
new ReadableStream<Uint8Array>({
start() {
// Keep the stream open forever: headers exist, body does not finish.
},
}),
);
const read = readClientHttpResponseText(response, {
timeoutMs: 25,
url: '/api/auth/refresh',
});
const assertion = expect(read).rejects.toMatchObject({
name: 'ClientHttpTimeoutError',
code: 'CLIENT_HTTP_TIMEOUT',
timeoutMs: 25,
url: '/api/auth/refresh',
});
await vi.advanceTimersByTimeAsync(25);
await assertion;
});
it('preserves caller AbortError and does not report it as a timeout', async () => {
const fetchMock = vi.fn(
(_url: string, init: RequestInit) =>
@@ -0,0 +1,39 @@
import { describe, expect, it } from 'vitest';
import { buildRecentProjectRows } from '../src/features/app-shell/model';
describe('最近项目行状态', () => {
it('已完成的项目不受其它慢目录的全局刷新状态阻塞', () => {
const rows = buildRecentProjectRows(
['C:\\projects\\ready', 'C:\\projects\\slow'],
{
'C:\\projects\\ready': {
projectPath: 'C:\\projects\\ready',
exists: true,
isDirectory: true,
isGameCreatorProject: true,
isGodotProject: false,
isCocosProject: false,
godotProjectRoot: null,
cocosProjectRoot: null,
projectName: '可打开项目',
recentRunStatus: null,
recentRunStopReason: null,
},
},
true,
);
expect(rows[0]).toMatchObject({
name: '可打开项目',
status: '本地项目',
canOpen: true,
canReveal: true,
});
expect(rows[1]).toMatchObject({
status: '检查中',
canOpen: false,
canReveal: false,
});
});
});
+1
View File
@@ -30,6 +30,7 @@
- [LLM 累计额度结算](./technical/【技术方案】LLM累计额度结算-2026-09-05.md):Router 累计额度、首次基线与原子钱包结算。
- [AI 游戏创作智能体 App 实施计划](./technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md):当前 DirectProject、受控语义工具、UI workflow、资源和运行时合同。
- [AGC 异步操作可恢复闭环](./【技术方案】AGC异步操作可恢复闭环-2026-09-14.md):认证响应体、最近项目检查和首页自动创建的超时、逐项恢复与跨页防重合同。
- [策划会话 Runtime V2 接入与旧链路退役方案](./technical/【技术方案】策划会话RuntimeV2接入与旧链路退役-2026-09-03.md):新单 Agent 策划会话、GDD 策略、未来 MCP/Skill 兼容插槽、阶段任务与退役验收合同。
- [DirectProject Codex 原始历史与异常恢复](<./technical/【技术方案】DirectProject Codex原始历史与异常恢复-2026-09-04.md>):原始 Responses item 持久化、线程注入与异常回合收尾。
- [DirectProject 客户端 Skill 与 MCP 扩展导入方案](./technical/【技术方案】DirectProject客户端Skill与MCP扩展导入方案-2026-08-31.md):客户端扩展导入、按独立 Skill/MCP 拆分、命名、启用和启动时注入边界。
@@ -8629,3 +8629,11 @@ CI 上 `background_agent_runtime_recovers_stale_running_before_pending_task` 在
- 决策:`agc-cocos-editor` 只有在当前受控项目通过 Cocos Creator 根目录识别(`package.json.creator.version` + 普通 `assets/`)时才暴露插件、面板和 Cocos 工具;无项目或其它项目类型均隐藏并失败关闭。
- 决策:项目切换离开 Cocos 时立即停止已运行的插件实例;启动、面板读取、插件 RPC、Runtime execute 和 DirectProject MCP 工具目录/执行入口全部再次校验项目类型。Cocos 编辑器操作优先经内置插件入口,禁止回退到项目 `extensions/``package.json` 插件或第三方 MCP。
- 验证:新增 builtin/plugin host 项目级门禁测试,Direct MCP fixture 补最小 Cocos 工程结构;Rust 定向测试、显式 `cocos-editor-execute` feature 编译、编码检查和 `git diff --check` 已执行。
## 2026-09-14 DirectProject Codex 取消路径白名单并启用完整 sandbox
- 背景:DirectProject 原先以 `workspaceWrite(writableRoots=[项目根])``item/fileChange/requestApproval` 的项目根校验限制 Codex 原生文件与命令能力,系统提示词还把 `.agent/``.git/`、项目外路径列为不可访问边界。
- 决策:DirectProject app-server thread 改为 `sandbox="danger-full-access"`turn 改为 `sandboxPolicy.type="dangerFullAccess"`,不再发送 `writableRoots` 或 workspace 网络开关,原生命令网络随完整 sandbox 开放;app-server 交互请求不再按 grant root 做白名单裁剪,直接项目会话统一接受文件变更、命令执行和权限请求。首页只读对话、AGC `agc_tools` 业务授权、Provider 凭据隔离、Runtime 审计和客户端受控文件工具合同继续保留。
- 提示词同步:DirectProject 不再把路径范围描述成 Codex 原生能力禁区,但仍禁止主动输出 Token、Cookie、auth.json、`.env` 和 Runtime 私有控制面。
- 验证:Rust 定向单测覆盖 `danger-full-access` / `dangerFullAccess`、无 `writableRoots`、外部 grant root 仍接受,以及 DirectHome 继续只读拒绝。
@@ -1761,4 +1761,4 @@ V1.54 的公共编排层可以在运行前构造动态 DAG,但 LLM 在执行
本文中 V1.1/V1.52 关于 app-server 全局关闭 native shell、network、browser、plugin 和 multi-agent 的表述继续适用于 ToolHost/DirectHome 与 legacy Runtime;不再作为 DirectProject 的现行实现。DirectProject 恢复原生文件/搜索/命令、图片查看和 Skill,始终注入审核后的 `agc_tools` MCP,并可在启动时从客户端扩展仓库接入用户已启用的独立第三方 MCP 配置;第三方配置不进入全局 Codex home,不开启完整 Plugin Runtime。平台美术、资源投影、浏览器试玩、受控搜索、付费副作用和 durable delegation 仍必须走 AGC 权威链路。
DirectProject 的写入根固定为真实 `game/`审批策略为 `never`,原生命令网络保持关闭,联网资料继续走受控 `agc_web_search`shell 使用 Codex `shell_environment_policy` 的 glob 排除 API key、proxy、loopback bridge 和受控开关。配置了 AGC LLM Key 或可解析的 `OPENAI_API_KEY` 登录态时,真实 provider 凭据只由 AGC 本地 provider proxy 持有,Codex 仅获得连接级随机代理令牌;无法安全代理的 OAuth `auth.json` 继续关闭 native shell/unified exec。Codex 原生子 Agent、Apps、插件、hooks、图片生成、Goals、Workspace Dependencies、Tool Suggestion 以及未接入 AGC 证据链的浏览器/电脑控制保持关闭。系统提示词只传入最小身份、工作区、Skill 索引和副作用边界,不再批量注入源码快照或 Skill 正文。sandbox writableRoots 不提供 deny-read`.agent``../assets` 的不可读约束仍需通过 prompt/Skill 行为合同和真实 smoke 验证,不能误称为 OS 强制隔离。
DirectProject 的历史写入根规则由 2026-09-14 覆盖:现使用 `danger-full-access` sandbox,取消 `workspaceWrite(writableRoots=...)` 与文件变更批准根白名单;项目根继续作为 cwd、连接池和审计身份根。审批策略为 `never`,原生命令网络随完整 sandbox 开放;联网资料仍可走受控 `agc_web_search`shell 使用 Codex `shell_environment_policy` 的 glob 排除 API key、proxy、loopback bridge 和受控开关。配置了 AGC LLM Key 或可解析的 `OPENAI_API_KEY` 登录态时,真实 provider 凭据只由 AGC 本地 provider proxy 持有,Codex 仅获得连接级随机代理令牌;无法安全代理的 OAuth `auth.json` 继续关闭 native shell/unified exec。Codex 原生子 Agent、Apps、插件、hooks、图片生成、Goals、Workspace Dependencies、Tool Suggestion 以及未接入 AGC 证据链的浏览器/电脑控制保持关闭。系统提示词只传入最小身份、工作区、Skill 索引和副作用边界,不再批量注入源码快照或 Skill 正文。sandbox writableRoots 不提供 deny-read`.agent``../assets` 的不可读约束仍需通过 prompt/Skill 行为合同和真实 smoke 验证,不能误称为 OS 强制隔离。
@@ -1271,9 +1271,15 @@ game-project/
## DirectProject 工具权限现行覆盖(2026-08-24)
本文早期关于“DirectProject 关闭通用 shell、原生网络和主动工具”的描述属于迁移前基线,现由以下覆盖规则取代:DirectProject 仅在真实 `game/` cwd 与 `workspaceWrite(writableRoots=[game])` 内恢复 Codex 原生文件/搜索/命令、图片查看和 Skill;其余 ToolHost/DirectHome 合同不变。客户端审核的 `agc_tools` MCP 继续承担平台美术、资源登记、去背景、浏览器试玩和受控搜索,并保留项目锁、幂等账本、下载校验、恢复与投影权威。
本文早期关于“DirectProject 关闭通用 shell、原生网络和主动工具”的描述属于迁移前基线2026-09-14 起,DirectProject 的 Codex sandbox 与审批规则由下方“完整访问覆盖”取代。其余 ToolHost/DirectHome 合同不变。客户端审核的 `agc_tools` MCP 继续承担平台美术、资源登记、去背景、浏览器试玩和受控搜索,并保留项目锁、幂等账本、下载校验、恢复与投影权威。
DirectProject 使用 `approvalPolicy=never`,避免每次原生调用再经过泛化 ToolHost 包装;原生命令网络保持关闭,联网资料继续走受控 `agc_web_search`。多 Agent、Apps、完整插件 Runtime、hooks、Goals、Workspace Dependencies、Tool Suggestion 和原生浏览器/电脑控制仍关闭,避免绕过 AGC durable delegation、浏览器证据和副作用审计;图片生成通过客户端审核的 `agc_tools.agc_generate_image` 暴露普通单图、角色图、视觉规范图和 UI 设计图,完整游戏美术包继续使用 `agc_tools.taonier_prepare_game_art`,两者都复用同一客户端登录态、幂等账本、下载校验和 manifest/revision 投影,不开放 Codex 原生 image tool。app-server 使用隔离 `CODEX_HOME`:内置 `agc_tools` 由客户端启动参数注入,用户在客户端扩展列表启用的独立第三方 MCP 以原生配置写入该次隔离 home;全局 Codex MCP、禁用项、Plugin hooks/apps 和其它插件能力不进入 DirectProject。第三方项固定非 required,配置或启动失败只记录该项,不替换 `agc_tools`provider session token、工具桥地址和受控搜索标记不得通过第三方 MCP 的环境转发字段泄露。配置了 AGC LLM Key 或可解析的 `OPENAI_API_KEY` 登录态时,真实 provider 凭据只由 AGC 本地 provider proxy 持有,Codex 仅使用连接级随机代理令牌;无法安全代理的 OAuth `auth.json` 继续关闭 native shell/unified exec。`agc_tools` 的平台授权由 AGC 客户端当前登录会话和受控后端完成,普通客户端不得把 DirectProject 请求改成外部 API Key 请求;401/403 只投影为客户端登录或权限异常,不向用户索要凭据或暴露内部 URL。shell 子进程采用 `shell_environment_policy` core 继承及 secret/proxy/bridge 排除,provider key 和桥接凭据不得进入命令环境。系统提示词不再预注入项目源码快照或 Skill 正文,Codex 按需读取当前 cwd 文件。
## DirectProject Codex 完整访问覆盖(2026-09-14
DirectProject 现明确采用 Codex app-server 的 `danger-full-access` sandboxthread 使用 `sandbox="danger-full-access"`turn 使用 `sandboxPolicy.type="dangerFullAccess"`,不再发送 `workspaceWrite``writableRoots` 或项目根文件批准白名单。DirectProject 收到 app-server 的文件变更、命令执行和权限请求时直接接受,Codex 原生能力不再按项目路径做二次白名单裁剪;用户选择的项目目录仍作为 cwd 和 AGC 业务身份根,用于连接池、审计与客户端受控 MCP 的项目绑定。
这项覆盖只改变 Codex 原生 app-server 的 sandbox 与审批边界:首页只读对话、AGC `agc_tools` MCP 的业务授权、Provider 凭据隔离、Runtime 审计与客户端 `agc_write_file` 的产品契约继续有效。系统提示词不再把 `.agent/``.git/`、项目外路径等描述为 Codex 原生能力禁区,但仍要求不要把 Token、Cookie、auth.json、`.env` 或 Runtime 私有控制面主动输出到对话、工具参数和日志。
DirectProject 使用 `approvalPolicy=never`,避免每次原生调用再经过泛化 ToolHost 包装;原生命令网络随完整 sandbox 开放;联网资料仍可走受控 `agc_web_search`。多 Agent、Apps、完整插件 Runtime、hooks、Goals、Workspace Dependencies、Tool Suggestion 和原生浏览器/电脑控制仍关闭,避免绕过 AGC durable delegation、浏览器证据和副作用审计;图片生成通过客户端审核的 `agc_tools.agc_generate_image` 暴露普通单图、角色图、视觉规范图和 UI 设计图,完整游戏美术包继续使用 `agc_tools.taonier_prepare_game_art`,两者都复用同一客户端登录态、幂等账本、下载校验和 manifest/revision 投影,不开放 Codex 原生 image tool。app-server 使用隔离 `CODEX_HOME`:内置 `agc_tools` 由客户端启动参数注入,用户在客户端扩展列表启用的独立第三方 MCP 以原生配置写入该次隔离 home;全局 Codex MCP、禁用项、Plugin hooks/apps 和其它插件能力不进入 DirectProject。第三方项固定非 required,配置或启动失败只记录该项,不替换 `agc_tools`provider session token、工具桥地址和受控搜索标记不得通过第三方 MCP 的环境转发字段泄露。配置了 AGC LLM Key 或可解析的 `OPENAI_API_KEY` 登录态时,真实 provider 凭据只由 AGC 本地 provider proxy 持有,Codex 仅使用连接级随机代理令牌;无法安全代理的 OAuth `auth.json` 继续关闭 native shell/unified exec。`agc_tools` 的平台授权由 AGC 客户端当前登录会话和受控后端完成,普通客户端不得把 DirectProject 请求改成外部 API Key 请求;401/403 只投影为客户端登录或权限异常,不向用户索要凭据或暴露内部 URL。shell 子进程采用 `shell_environment_policy` core 继承及 secret/proxy/bridge 排除,provider key 和桥接凭据不得进入命令环境。系统提示词不再预注入项目源码快照或 Skill 正文,Codex 按需读取当前 cwd 文件。
## 2026-08-24 AGC UI 原型桥接与自主 UI workflow
- 2026-08-24 起,`ui-prototype` 与 UI 编辑器的 `UI` JSON 资源明确分离。设计图生成后必须由白名单 `ui.workflow.run` 按页面执行 `prepare → recognize → status → finalize`:为每个功能页面创建并关联 `UI` JSON,载入页面设计图和已登记图片/图标/字体,调用 UI Editor 的 provider-backed 结构识别、多树合并与分批组件绑定,持久化 State/revision,写入 `game/` 应用标记,并把 `reference-ready → structure-ready → merge-ready → binding-ready → application-ready → completed` 各阶段的 `generationKind` 和 manifest revision 投影给客户端。Provider 未配置、请求失败、工具调用缺失、结果不匹配、未知字体引用、未产出可渲染组件或仍有待审节点时保留最近真实阶段并返回 blocker,不得使用 deterministic seed 冒充完成。工作台点击 `ui-prototype` 时通过 `ensure_ui_design_resource_for_prototype` 幂等补齐关联资源;工作流完成后自动打开首个页面的 UI 编辑器 `visual-binding` 最终阶段,交给用户检查和手动调整。UI 编辑器独立的语义建议请求也必须复用统一 LLM 传输选择,`llm.stream=true` 时发送 `stream=true` 并聚合完整工具调用后再校验结果。只生成图片、登记空 JSON 或进入普通图片画布均不构成 UI 工作流完成,详见 [`【技术方案】UI工作流资源桥接与Runtime执行-2026-08-24.md`](../【技术方案】UI工作流资源桥接与Runtime执行-2026-08-24.md)。
@@ -0,0 +1,44 @@
# 【技术方案】AGC 异步操作可恢复闭环
更新时间:`2026-09-14`
## 目标
让 AGC 的认证、最近项目检查和首页自动创建在响应体卡住、单目录慢、页面切换或操作迟到时仍然可观察、可重试且不会重复创建或覆盖当前项目。
## 非目标
- 本轮不改变认证接口、Runner 协议、SpacetimeDB schema 或 External API。
- 不处理环境中其它 worktree 的进程;运行环境清理需单独按进程归属执行。
- 不把 UI 测试警告全部清零,除非它们阻碍本轮新增行为验证。
## 入口与边界
- 用户/系统入口:AGC 登录恢复、登录/验证码/退出、首页最近项目、首页做游戏/做素材/做方案。
- 涉及模块:`clientHttp``clientAuth``AuthenticatedClient`、最近项目 controller/model、`useHomeProjectCreation``HomeView`
- 正式状态来源:认证 token 与 platform session、Tauri 项目 manifest;前端操作状态仅用于防重和恢复提示。
## 必须成立的行为
1. HTTP 响应头已返回但响应体未结束时,认证请求在有界时间内失败并释放 refresh singleflight;下一次重试必须发起新请求。
2. 最近项目逐项独立检查;单项超时/失败只影响该行,已完成且可打开的项目立即可操作。
3. 首页自动创建状态由 `WorkspaceLauncher` 生命周期持有;切页期间仍防重,迟到结果不能覆盖用户已打开的其它项目。
4. 认证恢复和 Runner 连接继续有明确超时、错误和重试入口;本地 Runner 会话安装/清除的阻塞工作不得占用 Tauri 窗口线程。
## 契约与迁移
不新增公开 API、DTO、schema 或持久化字段。Runner command 协议保持不变。
## 验收标准与证据
| 条款 | 验收方式 | 证据 |
| --- | --- | --- |
| 响应体超时 | client auth/http 定向测试 | body 卡住抛出稳定超时,第二次 refresh 请求计数为 2 |
| 最近项目独立完成 | model/controller 定向测试或 appSurface 场景 | A 完成时可打开,B 继续检查 |
| 首页创建跨页防重 | appSurface 场景 | 切页返回后按钮仍禁用,迟到创建不覆盖已有项目 |
| Runner 会话不阻塞窗口 | Rust 编译检查与登录/退出 UI fence | command 使用 blocking worker,前端使用 45 秒可恢复超时 |
| 现有行为不回归 | typecheck、AGC 定向测试、编码和 diff 检查 | 命令输出 |
## 未决问题与决策
Runner 同步 Tauri command 的窗口线程影响需要通过当前 Rust command 注册与调用链复核;若要改为异步 command,应单独补 Rust 线程/取消语义测试,不在未验证前引入表面异步包装。