Merge remote-tracking branch 'web/master' into feat/five_min_design

冲突两处,均为两侧独立新增:
- provider_tool_plan.rs:本分支的 force_autonomous_owner_artifact_delivery 与
  master 的 force_root_goal_contract 是各自独立的 let 绑定,两者都保留。
- provider_request_builders.rs:本分支新增测试
  full_dag_pre_code_owner_requests_do_not_advertise_manual_verification,
  master 把紧随其后的 trusted_root_supervisor_receives_dynamic_goal_control_tools
  改名为 trusted_root_supervisor_first_turn_only_receives_goal_contract_tool。
  保留本分支新增的测试,共享的那个测试采用 master 的新名(其函数体已随
  master 自动合并)。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-13 08:28:21 +00:00
60 changed files with 1917 additions and 401 deletions
@@ -253,12 +253,24 @@ export function defaultRuntimeConfigDirCandidates({
),
);
} else {
const configuredRoot = environment.XDG_CONFIG_HOME;
const posixAbsoluteConfiguredRoot =
configuredRoot && path.posix.isAbsolute(configuredRoot);
const hostAbsoluteConfiguredRoot =
configuredRoot &&
!posixAbsoluteConfiguredRoot &&
path.isAbsolute(configuredRoot);
const configRoot =
environment.XDG_CONFIG_HOME &&
path.posix.isAbsolute(environment.XDG_CONFIG_HOME)
? environment.XDG_CONFIG_HOME
configuredRoot &&
(posixAbsoluteConfiguredRoot || hostAbsoluteConfiguredRoot)
? configuredRoot
: path.posix.join(homeDirectory, '.config');
pushUnique(candidates, path.posix.join(configRoot, appIdentifier));
pushUnique(
candidates,
hostAbsoluteConfiguredRoot
? path.join(configRoot, appIdentifier)
: path.posix.join(configRoot, appIdentifier),
);
}
return candidates;
}
@@ -1594,7 +1594,8 @@ for (const snippet of [
'const GAME_CREATOR_LOCAL_CONFIG_FILE_NAME: &str = "game-creator.config.local.json"',
'const DEFAULT_GAME_CREATOR_APP_CONFIG_JSON: &str = include_str!("../../game-creator.config.json")',
'fn configure_game_creator_runtime_config_dir(',
'app.path().app_config_dir()?',
'game_creator_runtime_config_dir()',
'.unwrap_or_else(|| app.path().app_config_dir())?',
'fn load_game_creator_app_config()',
'fn read_game_creator_app_config()',
'fn write_game_creator_app_config(',
@@ -46,6 +46,8 @@ export function buildProcessSessionFixtureSource({
' if (!echoed && line === challenge) {',
' echoed = true;',
" console.log(echoPrefix + ' ' + challenge);",
" } else if (line === challenge + ':stop') {",
' stop();',
' }',
' }',
'});',
@@ -4,8 +4,9 @@ import fs from 'node:fs/promises';
import http from 'node:http';
import os from 'node:os';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const appRoot = path.resolve(new URL('..', import.meta.url).pathname);
const appRoot = fileURLToPath(new URL('..', import.meta.url));
const localConfigPath = path.join(appRoot, 'game-creator.config.local.json');
const projectRoot = path.join(
os.tmpdir(),
@@ -884,7 +885,28 @@ function readBrowserDom(url) {
}
function resolveChromeBin() {
const windowsRoot = path.parse(os.homedir()).root;
for (const candidate of [
path.join(
windowsRoot,
'Program Files/Google/Chrome/Application/chrome.exe',
),
path.join(
windowsRoot,
'Program Files (x86)/Google/Chrome/Application/chrome.exe',
),
path.join(
os.homedir(),
'AppData/Local/Google/Chrome/Application/chrome.exe',
),
path.join(
windowsRoot,
'Program Files/Microsoft/Edge/Application/msedge.exe',
),
path.join(
windowsRoot,
'Program Files (x86)/Microsoft/Edge/Application/msedge.exe',
),
'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
'/Applications/Chromium.app/Contents/MacOS/Chromium',
'/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge',
@@ -45,12 +45,16 @@ function buildTauriArguments(argv, devUrl = readAgcDevEndpoint().url) {
if (separatorIndex < 0) {
return ['dev', ...args, '--config', configOverride];
}
const separatedArguments = args.slice(separatorIndex);
if (separatedArguments[1] !== '--') {
separatedArguments.unshift('--');
}
return [
'dev',
...args.slice(0, separatorIndex),
'--config',
configOverride,
...args.slice(separatorIndex),
...separatedArguments,
];
}
@@ -22,6 +22,9 @@ mod runtime_state;
mod runtime_tools;
use codex_app_server::*;
use codex_cli::*;
pub(crate) use codex_cli::{
game_creator_codex_cli_executable_path, game_creator_codex_cli_version_identity,
};
pub(crate) use generation::*;
pub(crate) use interaction::*;
pub(crate) use prompt::*;
@@ -7,7 +7,6 @@ use std::sync::{Arc, OnceLock, Weak};
use tokio::io::{AsyncBufRead, AsyncBufReadExt, AsyncReadExt, AsyncWriteExt, BufReader};
use tokio::sync::{mpsc, oneshot, Mutex};
const GAME_CREATOR_CODEX_APP_SERVER_EXECUTABLE: &str = "codex";
const GAME_CREATOR_CODEX_APP_SERVER_PROVIDER_ID: &str = "genarrative_agc";
const GAME_CREATOR_CODEX_APP_SERVER_API_KEY_ENV: &str = "GENARRATIVE_AGC_CODEX_API_KEY";
const GAME_CREATOR_CODEX_APP_SERVER_PROTOCOL: &str = "genarrative-codex-app-server.v2";
@@ -482,10 +481,8 @@ fn configure_game_creator_codex_app_server_command(
"plugins",
"remote_plugin",
"shell_tool",
"skill_search",
"tool_suggest",
"unified_exec",
"view_image",
"workspace_dependencies",
] {
command.arg("--disable").arg(feature);
@@ -608,12 +605,9 @@ impl CodexAppServerConnection {
llm: &GameCreatorLlmConfig,
credential: &CodexAppServerCredential,
) -> Result<Self, platform_llm::LlmError> {
Self::spawn_with_executable_and_credential(
llm,
credential,
std::ffi::OsStr::new(GAME_CREATOR_CODEX_APP_SERVER_EXECUTABLE),
)
.await
let executable = game_creator_codex_cli_executable_path()
.map_err(platform_llm::LlmError::InvalidConfig)?;
Self::spawn_with_executable_and_credential(llm, credential, executable.as_os_str()).await
}
async fn spawn_with_executable(
@@ -1748,6 +1742,41 @@ mod tests {
assert!(game_creator_codex_app_server_validate_llm_config(&llm).is_err());
}
#[test]
fn codex_app_server_command_uses_only_current_cli_feature_flags() {
let mut command = tokio::process::Command::new("codex");
configure_game_creator_codex_app_server_command(&mut command, &test_llm())
.expect("configure app-server command");
let arguments = command
.as_std()
.get_args()
.map(|argument| argument.to_string_lossy().into_owned())
.collect::<Vec<_>>();
assert!(arguments
.windows(2)
.any(|pair| pair == ["--disable", "shell_tool"]));
assert!(!arguments.iter().any(|argument| argument == "skill_search"));
assert!(!arguments.iter().any(|argument| argument == "view_image"));
}
#[cfg(windows)]
#[test]
fn codex_app_server_current_cli_accepts_configured_arguments() {
let executable = game_creator_codex_cli_executable_path().expect("Codex CLI executable");
let mut command = std::process::Command::new(executable);
let mut configured = tokio::process::Command::new("codex");
configure_game_creator_codex_app_server_command(&mut configured, &test_llm())
.expect("configure app-server command");
command.args(configured.as_std().get_args());
command.arg("--help").stdin(Stdio::null());
let output = command.output().expect("run Codex app-server help");
assert!(
output.status.success(),
"configured app-server arguments must be accepted: {}",
String::from_utf8_lossy(&output.stderr)
);
}
#[test]
fn codex_app_server_pool_key_isolated_by_credentials_and_route() {
let mut base = test_llm();
@@ -1,4 +1,5 @@
use super::*;
use std::path::{Path, PathBuf};
use std::process::Stdio;
use sha2::{Digest, Sha256};
@@ -9,29 +10,128 @@ const GAME_CREATOR_CODEX_CLI_PROMPT_MAX_BYTES: usize = 4 * 1024 * 1024;
const GAME_CREATOR_CODEX_CLI_STDOUT_MAX_BYTES: usize = 4 * 1024 * 1024;
const GAME_CREATOR_CODEX_CLI_STDERR_MAX_BYTES: usize = 256 * 1024;
fn game_creator_codex_cli_executable_candidates_for(
app_data: Option<&Path>,
local_app_data: Option<&Path>,
runtime_config_dir: Option<&Path>,
path: Option<&std::ffi::OsStr>,
) -> Vec<PathBuf> {
let mut candidates = Vec::new();
#[cfg(windows)]
{
fn append_native_npm_candidates(candidates: &mut Vec<PathBuf>, npm_root: &Path) {
let vendor_root = npm_root
.join("node_modules")
.join("@openai")
.join("codex")
.join("node_modules")
.join("@openai")
.join("codex-win32-x64")
.join("vendor");
if let Ok(entries) = std::fs::read_dir(vendor_root) {
let mut targets = entries
.filter_map(Result::ok)
.map(|entry| entry.path().join("bin").join("codex.exe"))
.collect::<Vec<_>>();
targets.sort();
candidates.extend(targets);
}
}
fn append_desktop_codex_candidates(candidates: &mut Vec<PathBuf>, local_app_data: &Path) {
let bin_root = local_app_data.join("OpenAI").join("Codex").join("bin");
if let Ok(entries) = std::fs::read_dir(bin_root) {
let mut targets = entries
.filter_map(Result::ok)
.map(|entry| entry.path().join("codex.exe"))
.collect::<Vec<_>>();
targets.sort();
targets.reverse();
candidates.extend(targets);
}
}
if let Some(app_data) = app_data {
append_native_npm_candidates(&mut candidates, &app_data.join("npm"));
}
if let Some(local_app_data) = local_app_data {
append_desktop_codex_candidates(&mut candidates, local_app_data);
}
if let Some(app_data) = runtime_config_dir.and_then(Path::parent) {
append_native_npm_candidates(&mut candidates, &app_data.join("npm"));
if let Some(user_profile) = app_data.parent() {
append_desktop_codex_candidates(&mut candidates, &user_profile.join("Local"));
}
}
if let Some(path) = path {
for entry in std::env::split_paths(&path) {
append_native_npm_candidates(&mut candidates, &entry);
candidates.push(entry.join("codex.exe"));
}
}
}
candidates.push(PathBuf::from(GAME_CREATOR_CODEX_CLI_EXECUTABLE));
candidates
}
fn game_creator_codex_cli_executable_candidates() -> Vec<PathBuf> {
game_creator_codex_cli_executable_candidates_for(
std::env::var_os("APPDATA").as_deref().map(Path::new),
std::env::var_os("LOCALAPPDATA").as_deref().map(Path::new),
game_creator_runtime_config_dir().as_deref(),
std::env::var_os("PATH").as_deref(),
)
}
fn game_creator_codex_cli_version_at(executable: &Path) -> Result<String, String> {
let output = std::process::Command::new(executable)
.arg("--version")
.stdin(Stdio::null())
.stderr(Stdio::null())
.output()
.map_err(|error| error.to_string())?;
if !output.status.success() {
return Err(format!("版本检查退出状态为 {}", output.status));
}
let version = std::str::from_utf8(&output.stdout)
.map_err(|_| "版本信息不是 UTF-8".to_string())?
.trim();
if !version.starts_with("codex-cli ") || version.len() > 120 {
return Err("返回了无法识别的版本信息".to_string());
}
Ok(version.to_string())
}
pub(crate) fn game_creator_codex_cli_executable_path() -> Result<PathBuf, String> {
let mut last_error = None;
let mut seen = std::collections::HashSet::new();
for candidate in game_creator_codex_cli_executable_candidates() {
let identity = candidate.to_string_lossy().to_ascii_lowercase();
if !seen.insert(identity) {
continue;
}
match game_creator_codex_cli_version_at(&candidate) {
Ok(_) => return Ok(candidate),
Err(error) => last_error = Some(error),
}
}
Err(format!(
"Codex CLI 未安装或当前 Agent Runner 无法启动;已检查 PATH 和 npm 全局安装目录{}",
last_error
.map(|error| format!("(最后错误:{error}"))
.unwrap_or_default()
))
}
struct CodexCliStderrSummary {
byte_len: usize,
sha256: String,
classification: &'static str,
}
pub(in crate::agent) fn game_creator_codex_cli_version_identity() -> Result<String, String> {
let output = std::process::Command::new(GAME_CREATOR_CODEX_CLI_EXECUTABLE)
.arg("--version")
.stdin(Stdio::null())
.stderr(Stdio::null())
.output()
.map_err(|_| "Codex CLI 未安装或不在当前 Agent Runner PATH 中".to_string())?;
if !output.status.success() {
return Err("Codex CLI 版本检查失败".to_string());
}
let version = std::str::from_utf8(&output.stdout)
.map_err(|_| "Codex CLI 版本信息不是 UTF-8".to_string())?
.trim();
if !version.starts_with("codex-cli ") || version.len() > 120 {
return Err("Codex CLI 返回了无法识别的版本信息".to_string());
}
Ok(version.to_string())
pub(crate) fn game_creator_codex_cli_version_identity() -> Result<String, String> {
let executable = game_creator_codex_cli_executable_path()?;
game_creator_codex_cli_version_at(&executable)
}
pub(in crate::agent) fn game_creator_codex_cli_reasoning_effort(
@@ -546,11 +646,9 @@ async fn request_game_creator_agent_codex_cli_with_executable(
pub(in crate::agent) async fn request_game_creator_agent_codex_cli(
request: LlmRunRequest,
) -> Result<platform_llm::LlmRunResponse, platform_llm::LlmError> {
request_game_creator_agent_codex_cli_with_executable(
std::ffi::OsStr::new(GAME_CREATOR_CODEX_CLI_EXECUTABLE),
request,
)
.await
let executable =
game_creator_codex_cli_executable_path().map_err(platform_llm::LlmError::InvalidConfig)?;
request_game_creator_agent_codex_cli_with_executable(executable.as_os_str(), request).await
}
#[cfg(test)]
@@ -567,6 +665,103 @@ mod tests {
])
}
#[cfg(windows)]
#[test]
fn codex_cli_candidates_prefer_sorted_native_npm_targets_before_path() {
let temp = tempfile::tempdir().expect("temp dir");
let app_data = temp.path().join("app-data");
let vendor = app_data
.join("npm/node_modules/@openai/codex/node_modules/@openai/codex-win32-x64/vendor");
std::fs::create_dir_all(vendor.join("z-target/bin")).expect("z target");
std::fs::create_dir_all(vendor.join("a-target/bin")).expect("a target");
let path_dir = temp.path().join("path");
std::fs::create_dir_all(&path_dir).expect("path dir");
let candidates = game_creator_codex_cli_executable_candidates_for(
Some(&app_data),
None,
None,
Some(path_dir.as_os_str()),
);
assert_eq!(
candidates[0],
vendor.join("a-target/bin/codex.exe"),
"native npm targets must be deterministic and precede PATH"
);
assert_eq!(candidates[1], vendor.join("z-target/bin/codex.exe"));
assert_eq!(candidates[2], path_dir.join("codex.exe"));
assert_eq!(candidates.last(), Some(&PathBuf::from("codex")));
}
#[cfg(windows)]
#[test]
fn codex_cli_candidates_discover_native_npm_target_from_path_without_appdata() {
let temp = tempfile::tempdir().expect("temp dir");
let npm_root = temp.path().join("npm");
let native = npm_root
.join("node_modules/@openai/codex/node_modules/@openai/codex-win32-x64/vendor")
.join("x86_64-pc-windows-msvc/bin/codex.exe");
std::fs::create_dir_all(native.parent().expect("native parent"))
.expect("native target directory");
let candidates = game_creator_codex_cli_executable_candidates_for(
None,
None,
None,
Some(npm_root.as_os_str()),
);
assert_eq!(candidates[0], native);
assert_eq!(candidates[1], npm_root.join("codex.exe"));
}
#[cfg(windows)]
#[test]
fn codex_cli_candidates_discover_native_npm_target_from_runtime_config_dir() {
let temp = tempfile::tempdir().expect("temp dir");
let app_data = temp.path().join("roaming");
let config_dir = app_data.join("world.genarrative.ai-game-creator");
let native = app_data
.join("npm/node_modules/@openai/codex/node_modules/@openai/codex-win32-x64/vendor")
.join("x86_64-pc-windows-msvc/bin/codex.exe");
std::fs::create_dir_all(native.parent().expect("native parent"))
.expect("native target directory");
let candidates =
game_creator_codex_cli_executable_candidates_for(None, None, Some(&config_dir), None);
assert_eq!(candidates[0], native);
}
#[cfg(windows)]
#[test]
fn codex_cli_candidates_discover_desktop_native_target() {
let temp = tempfile::tempdir().expect("temp dir");
let local_app_data = temp.path().join("local");
let older = local_app_data.join("OpenAI/Codex/bin/111/codex.exe");
let newer = local_app_data.join("OpenAI/Codex/bin/222/codex.exe");
std::fs::create_dir_all(older.parent().expect("older parent")).expect("older dir");
std::fs::create_dir_all(newer.parent().expect("newer parent")).expect("newer dir");
let candidates = game_creator_codex_cli_executable_candidates_for(
None,
Some(&local_app_data),
None,
None,
);
assert_eq!(candidates[0], newer);
assert_eq!(candidates[1], older);
}
#[cfg(windows)]
#[test]
fn codex_cli_resolver_finds_current_native_install() {
let executable = game_creator_codex_cli_executable_path().expect("Codex CLI executable");
assert!(executable.is_absolute());
assert_eq!(
game_creator_codex_cli_version_identity().expect("Codex CLI version"),
game_creator_codex_cli_version_at(&executable).expect("same executable version")
);
}
#[test]
fn codex_cli_mode_renders_runtime_messages_and_structured_tool_contract() {
let prompt = render_game_creator_codex_cli_prompt(&tool_request()).expect("render prompt");
@@ -2265,7 +2265,19 @@ struct TrustedPlatformArtTransactionDirectory {
impl TrustedPlatformArtTransactionDirectory {
fn open_anchored(root: &Path, path: &Path) -> Result<Self, String> {
let parent = TrustedPlatformArtRecoveryParent::open(root, path, false)?;
// The transaction leaf is a directory. Anchor and validate its parent using a
// missing sibling path so the regular-file recovery preflight does not reject the
// directory itself before the dedicated directory validation below.
let anchor_target = path.with_file_name(".art-spritesheet-contract-transaction.anchor");
let parent = TrustedPlatformArtRecoveryParent::open(root, &anchor_target, false)?;
#[cfg(unix)]
let parent = TrustedPlatformArtRecoveryParent {
leaf: path
.file_name()
.ok_or_else(|| "平台图集事务目录缺少叶子文件名".to_string())?
.to_os_string(),
..parent
};
#[cfg(unix)]
{
use std::os::unix::ffi::OsStrExt;
@@ -3658,11 +3670,14 @@ fn sync_strict_platform_art_contract_state_at(
path.display()
));
}
Ok(_) => fs::File::open(&path)
.and_then(|file| file.sync_all())
.map_err(|error| {
format!("同步平台图集合同文件失败:{}: {error}", path.display())
})?,
Ok(_) => {
#[cfg(unix)]
fs::File::open(&path)
.and_then(|file| file.sync_all())
.map_err(|error| {
format!("同步平台图集合同文件失败:{}: {error}", path.display())
})?;
}
Err(error) if error.kind() == std::io::ErrorKind::NotFound && !require_complete => {}
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
return Err(format!("平台图集提交缺少完整合同文件:{}", path.display()));
@@ -4698,6 +4713,7 @@ impl PlatformArtSliceContractRollback {
&journal,
"平台图集事务 journal",
)?;
#[cfg(unix)]
trusted_transaction_directory
.handle
.sync_all()
@@ -4794,6 +4810,7 @@ impl PlatformArtSliceContractRollback {
));
}
}
#[cfg(unix)]
trusted_transaction_directory.handle.sync_all().map_err(|error| {
format!(
"{PLATFORM_ART_LOCAL_RECONCILIATION_PREFIX} 平台图集合同已提交,但同步 prepared marker 清理失败:{error}"
@@ -7852,6 +7869,7 @@ mod canvas_generation_tests {
drop(project_lock);
}
#[cfg(unix)]
#[test]
fn durable_strict_contract_transaction_rejects_same_length_snapshot_rewrite_during_read() {
let temporary = tempfile::tempdir().expect("create concurrent snapshot project");
@@ -8322,6 +8340,7 @@ mod canvas_generation_tests {
assert!(!root.join(STRICT_PLATFORM_ART_TRANSACTION_PATH).exists());
}
#[cfg(unix)]
#[test]
fn durable_strict_contract_transaction_rejects_replaced_transaction_directory() {
let temporary = tempfile::tempdir().expect("create replaced transaction directory fixture");
@@ -8689,6 +8708,7 @@ mod canvas_generation_tests {
.expect("open sparse main sheet");
main.set_len(STRICT_PLATFORM_ART_TRANSACTION_MAX_SNAPSHOT_BYTES + 1)
.expect("create oversized sparse main sheet");
drop(main);
let error = match PlatformArtSliceContractRollback::capture(root, "oversized-snapshot") {
Ok(_) => panic!("oversized sparse snapshot must fail before an unbounded read"),
@@ -93,9 +93,10 @@ pub(crate) async fn control_agent_run_at(
let prompt = resumed_agent_run_prompt(&previous_trace.goal, action, detail);
let generated = generate_local_game_draft_at(root, &prompt, progress).await?;
let trace = read_latest_agent_run_trace(root)?;
let game_index_path = generated.game_index_path.replace('\\', "/");
let message = format!(
"{},已重新运行为 {}{}",
control_result.message, trace.run_id, generated.game_index_path
control_result.message, trace.run_id, game_index_path
);
let event = if action == "retry" {
"agent.retry.run"
@@ -214,7 +214,9 @@ pub(in crate::agent) fn validate_root_goal_contract_control_plan_at(
|| !plan.plan.is_empty()
|| !plan.response.trim().is_empty()
{
return Err("根 Project Supervisor 必须先把自己对当前用户最终意图的理解作为本轮唯一动作提交 agent.goal_contract;固定规则只提供上下文,不能先调度、委派、修改项目或回复完成".to_string());
return Err(format!(
"{AGENT_RUNTIME_ROOT_GOAL_CONTRACT_REQUIRED_ERROR_PREFIX};固定规则只提供上下文,不能先调度、委派、修改项目或回复完成"
));
}
return Ok(());
}
@@ -236,6 +238,29 @@ pub(in crate::agent) fn validate_root_goal_contract_control_plan_at(
Ok(())
}
pub(super) const AGENT_RUNTIME_ROOT_GOAL_CONTRACT_REQUIRED_ERROR_PREFIX: &str =
"根 Project Supervisor 必须先把自己对当前用户最终意图的理解作为本轮唯一动作提交 agent.goal_contract";
pub(in crate::agent) fn restrict_agent_runtime_root_goal_contract_tools(
request: &mut LlmRunRequest,
) -> Result<(), String> {
let goal_contract_function = native_runtime_function_name("agent.goal_contract")
.ok_or_else(|| "无法生成根 Goal Contract 工具函数名".to_string())?;
request
.function_tools
.retain(|tool| tool.name == goal_contract_function);
if request.function_tools.len() != 1 {
return Err("根 Goal Contract 工具目录缺少 agent.goal_contract".to_string());
}
request.max_output_tokens = Some(
request
.max_output_tokens
.unwrap_or(AGENT_RUNTIME_AUTONOMOUS_FORCED_ACTION_MAX_OUTPUT_TOKENS)
.min(AGENT_RUNTIME_AUTONOMOUS_FORCED_ACTION_MAX_OUTPUT_TOKENS),
);
Ok(())
}
pub(super) const AGENT_RUNTIME_AUTONOMOUS_SUPERVISOR_DELIVERY_CONVERGENCE_LIVENESS_ERROR_PREFIX:
&str = "自主构建 Project Supervisor 必须先收束已有专业 Agent 委派";
pub(super) const AGENT_RUNTIME_AUTONOMOUS_PREVIEW_AFTER_STATIC_LIVENESS_ERROR_PREFIX: &str =
@@ -1688,6 +1713,35 @@ pub(in crate::agent) fn agent_runtime_protocol_error_requires_supervisor_collabo
mod tests {
use super::*;
#[test]
fn root_goal_contract_repair_catalog_contains_only_goal_contract() {
let catalog = GameCreatorMcpCatalog {
fingerprint: String::new(),
servers: Vec::new(),
tools: Vec::new(),
};
let mut request = LlmRunRequest::new(Vec::new())
.with_function_tools(
build_agent_runtime_native_function_tools(&catalog)
.expect("build native function tools"),
)
.with_tool_choice(platform_llm::LlmToolChoice::Required);
restrict_agent_runtime_root_goal_contract_tools(&mut request)
.expect("restrict root Goal Contract tools");
assert_eq!(request.function_tools.len(), 1);
assert_eq!(
request.function_tools[0].name,
native_runtime_function_name("agent.goal_contract")
.expect("goal contract function name")
);
assert_eq!(
request.tool_choice,
Some(platform_llm::LlmToolChoice::Required)
);
}
fn autonomous_initial_delegate(
agent_id: &str,
expected_artifacts: &[&str],
@@ -136,6 +136,8 @@ pub(in crate::agent) fn build_game_creator_agent_background_tool_plan_request(
let root_goal_contract_context =
render_game_creator_agent_runtime_goal_contract_for_prompt_at(root, agent_id, run_id)?
.unwrap_or_else(|| "null".to_string());
let root_goal_contract_required =
root_control_authority && root_goal_contract_context == "null";
let acceptance_graph_context =
render_game_creator_agent_runtime_acceptance_graph_for_prompt_at(root, agent_id, run_id)?
.unwrap_or_else(|| "null".to_string());
@@ -407,8 +409,10 @@ pub(in crate::agent) fn build_game_creator_agent_background_tool_plan_request(
.any(is_agent_runtime_project_mutation_observation)
});
if autonomous_game_build && plan_rejection_needs_repair {
let supervisor_orchestrator_repair = if agent_id == GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID {
let policy = resolve_supervisor_collaboration_policy_for_run_at(root, agent_id, run_id)?.policy;
let supervisor_orchestrator_repair = if agent_id == GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID
{
let policy =
resolve_supervisor_collaboration_policy_for_run_at(root, agent_id, run_id)?.policy;
let state = read_supervisor_collaboration_state_at(root, agent_id, run_id)?;
policy.orchestrator_only_after_delegation && state.has_collaboration()
} else {
@@ -417,15 +421,25 @@ pub(in crate::agent) fn build_game_creator_agent_background_tool_plan_request(
let repair_tools: &[&str] = if supervisor_orchestrator_repair {
&["agent.delegate", "agent.run_status"]
} else {
&["file.write", "file.patch", "file.delete", "project.patchset", "project.restore", "canvas.asset_generate"]
&[
"file.write",
"file.patch",
"file.delete",
"project.patchset",
"project.restore",
"canvas.asset_generate",
]
};
let mut allowed_function_names = BTreeSet::from([AGENT_RUNTIME_RESPOND_FUNCTION_NAME.to_string()]);
let mut allowed_function_names =
BTreeSet::from([AGENT_RUNTIME_RESPOND_FUNCTION_NAME.to_string()]);
for tool in repair_tools {
if let Some(name) = native_runtime_function_name(tool) {
allowed_function_names.insert(name);
}
}
request.function_tools.retain(|tool| allowed_function_names.contains(&tool.name));
request
.function_tools
.retain(|tool| allowed_function_names.contains(&tool.name));
request.messages.push(LlmMessage::user(if supervisor_orchestrator_repair {
"上一轮 runtime.plan_update 被拒绝。本轮 Supervisor 已进入协作编排模式,只能调用 agent.run_status 或 agent.delegate 继续收束,或在证据足够时 respond_to_user;禁止再次规划、读取、搜索、验证或直接修改项目。"
} else {
@@ -446,6 +460,12 @@ pub(in crate::agent) fn build_game_creator_agent_background_tool_plan_request(
.function_tools
.retain(|tool| tool.name != project_verify_function);
}
if root_goal_contract_required {
restrict_agent_runtime_root_goal_contract_tools(&mut request)?;
request.messages.push(LlmMessage::user(
"当前根 Run 尚未冻结 Goal Contract。本轮唯一可用工具是 agent.goal_contract;必须且只能调用一次,用 outcome 具体概括当前用户最终意图,acceptanceNodes 至少提交一项可核对标准。每个 requiredEvidence 必须选择在该标准所有合法结果下都能成功产生回执的工具;环境探测可能以 rejected/failed 表示正常否定结果时,不得把该探测工具写成必需成功回执(例如非 Git 项目不得要求 git.inspect 成功,应使用 project.index 的成功回执证明 isRepository=false)。nonNegotiables、preferences、forbiddenAssumptions、openQuestions 没有内容时传空数组。不得调用 update_agent_plan、respond_to_user 或任何其他动作,不得输出普通文本。",
));
}
request = apply_game_creator_llm_web_search(
apply_game_creator_llm_reasoning_effort(request, &llm)?,
&llm,
@@ -647,15 +667,14 @@ mod tests {
new_game_creation_app_seed_tasks, provider_command_exec_contract,
provider_command_start_contract, render_autonomous_manifest_ready_task_background_prompt,
required_runtime_prompt_section, resolve_agent_conversation_session_id_at,
start_game_creator_agent_runtime_task_at, AgentRuntimeTaskLink, AgentRuntimeToolObservation,
AgentRuntimeToolPlan,
GameCreatorMcpCatalog, GameCreatorMcpCatalogTool,
AGENT_RUNTIME_COMPLETION_BLOCKER_TOOL_PLAN_PROTOCOL,
AGENT_RUNTIME_RESPOND_FUNCTION_NAME,
start_game_creator_agent_runtime_task_at, AgentRuntimeGoalContractAcceptanceNodeDraft,
AgentRuntimeGoalContractDraft, AgentRuntimeTaskLink, AgentRuntimeToolObservation,
AgentRuntimeToolPlan, GameCreatorMcpCatalog, GameCreatorMcpCatalogTool,
AGENT_RUNTIME_COMPLETION_BLOCKER_TOOL_PLAN_PROTOCOL, AGENT_RUNTIME_RESPOND_FUNCTION_NAME,
AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD, AGENT_RUNTIME_SUPERVISOR_CLI_SOURCE,
AGENT_RUNTIME_SUPERVISOR_GAME_CHAT_SOURCE, AGENT_RUNTIME_SUPERVISOR_GUI_SOURCE,
AGENT_RUNTIME_UPDATE_PLAN_FUNCTION_NAME,
GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, RUNTIME_PROMPT_SUPERVISOR_CHAT_COMPOSITION,
AGENT_RUNTIME_UPDATE_PLAN_FUNCTION_NAME, GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID,
RUNTIME_PROMPT_SUPERVISOR_CHAT_COMPOSITION,
};
fn native_input_required_fields(
@@ -701,6 +720,27 @@ mod tests {
vec!["立即修改 game/index.html".to_string()],
)
.expect("start task");
crate::agent::create_game_creator_agent_runtime_goal_contract_at(
&root,
&binding.agent_id,
&binding.run_id,
&state.current_task,
&AgentRuntimeGoalContractDraft {
outcome: "修复现有游戏".to_string(),
non_negotiables: Vec::new(),
preferences: Vec::new(),
forbidden_assumptions: Vec::new(),
open_questions: Vec::new(),
acceptance_nodes: vec![AgentRuntimeGoalContractAcceptanceNodeDraft {
criterion_id: "repair-game".to_string(),
criterion: "完成项目修改".to_string(),
required: true,
required_evidence: vec!["file.patch".to_string()],
dependencies: Vec::new(),
}],
},
)
.expect("create goal contract");
let catalog = GameCreatorMcpCatalog {
fingerprint: String::new(),
servers: Vec::new(),
@@ -1109,7 +1149,7 @@ mod tests {
}
#[test]
fn trusted_root_supervisor_receives_dynamic_goal_control_tools() {
fn trusted_root_supervisor_first_turn_only_receives_goal_contract_tool() {
let directory = crate::tests::canonical_test_tempdir("provider-goal-control-");
let root = directory.path().join("project");
init_local_game_project_at(&root, "goal-control-project", "完成可验证游戏")
@@ -1153,6 +1193,7 @@ mod tests {
assert!(prompt.contains("动态目标协议:agent.goal_contract"));
assert!(prompt.contains("固定规则、关键词、资产探测和专家建议只能作为上下文"));
assert!(prompt.contains("未提交的 passed 节点保持不变"));
assert_eq!(request.function_tools.len(), 1);
assert_eq!(
native_input_required_fields(&request, "agent.goal_contract"),
[
@@ -1164,10 +1205,9 @@ mod tests {
"acceptanceNodes"
]
);
assert_eq!(
native_input_required_fields(&request, "agent.acceptance_update"),
["contractFingerprint", "evaluations"]
);
assert!(request.messages.iter().any(|message| message
.content
.contains("本轮唯一可用工具是 agent.goal_contract")));
}
#[test]
@@ -871,11 +871,14 @@ pub(in crate::agent) async fn request_game_creator_agent_background_tool_plan_at
&& (force_autonomous_specialist_verification_only
|| force_autonomous_pending_verification
|| force_autonomous_reverify_after_mutation);
let force_root_goal_contract = protocol_error
.starts_with(AGENT_RUNTIME_ROOT_GOAL_CONTRACT_REQUIRED_ERROR_PREFIX);
let force_supervisor_initial_collaboration =
agent_runtime_protocol_error_requires_supervisor_collaboration_repair(
&protocol_error,
) && !request.function_tools.is_empty();
if force_supervisor_initial_collaboration
if force_root_goal_contract
|| force_supervisor_initial_collaboration
|| force_autonomous_specialist_mutation_only
|| force_autonomous_specialist_verification_only
|| force_autonomous_response_plan_completion
@@ -914,7 +917,12 @@ pub(in crate::agent) async fn request_game_creator_agent_background_tool_plan_at
.retain(|tool| tool.name != project_verify_function);
}
}
if force_supervisor_initial_collaboration {
if force_root_goal_contract {
restrict_agent_runtime_root_goal_contract_tools(&mut request)?;
request.messages.push(LlmMessage::user(format!(
"上一条输出不符合工具计划协议:{protocol_error}\n当前根 Run 尚未冻结 Goal Contract。本次修复的原生工具目录只保留 agent.goal_contract;必须且只能调用一次,用 outcome 具体概括当前用户最终意图,acceptanceNodes 至少提交一项可核对标准。每个 requiredEvidence 必须选择在该标准所有合法结果下都能成功产生回执的工具;环境探测可能以 rejected/failed 表示正常否定结果时,不得把该探测工具写成必需成功回执(例如非 Git 项目不得要求 git.inspect 成功,应使用 project.index 的成功回执证明 isRepository=false)。nonNegotiables、preferences、forbiddenAssumptions、openQuestions 没有内容时传空数组。不得调用 update_agent_plan、respond_to_user 或任何其他动作,不得输出普通文本、解释、markdown 或代码围栏。"
)));
} else if force_supervisor_initial_collaboration {
supervisor_collaboration_repair_active = true;
if let Some(actions) = supervisor_collaboration_candidate_actions.take() {
supervisor_collaboration_repair_actions =
@@ -311,8 +311,7 @@ pub(crate) use provider_recovery::{
#[cfg(test)]
pub(crate) use provider_recovery::{
drive_waiting_autonomous_manifest_parent_wake_budget_for_test,
ensure_static_delegate_user_input_wait_at,
ensure_waiting_provider_retry_records_for_test,
ensure_static_delegate_user_input_wait_at, ensure_waiting_provider_retry_records_for_test,
mark_autonomous_manifest_parent_wake_needs_reconciliation_for_test,
prepare_waiting_autonomous_manifest_parent_for_test,
probe_static_delegate_parent_wake_singleflight_coalescing,
@@ -1234,12 +1234,13 @@ fn game_chat_main_without_asset_audit_fixture(root: &Path) -> String {
async fn game_chat_main_agent_delegates_only_real_missing_art_and_limits_children_to_assets() {
let temporary = tempfile::tempdir().expect("create game-chat art child root");
let root = temporary.path().join("project");
let (_main, mut child, _delegation_id, _child_lane) = game_chat_main_art_child_fixture_with_lane(
&root,
"art-asset-plan",
&["core-spritesheet"],
true,
);
let (_main, mut child, _delegation_id, _child_lane) =
game_chat_main_art_child_fixture_with_lane(
&root,
"art-asset-plan",
&["core-spritesheet"],
true,
);
assert_eq!(child.agent_id, "art-asset-plan");
assert_eq!(child.source, "agent-delegate");
assert_eq!(child.parent_agent_id.as_deref(), Some("code-prototype"));
@@ -1350,6 +1350,50 @@ mod tests {
&contract_plan,
)
.expect("sole goal contract action is allowed");
let contract_with_extra_action = AgentRuntimeToolPlan {
actions: vec![action("agent.goal_contract"), action("file.list")],
..AgentRuntimeToolPlan::default()
};
assert!(validate_root_goal_contract_control_plan_at(
&root,
&binding.agent_id,
&binding.run_id,
&contract_with_extra_action,
)
.expect_err("extra action before Goal Contract must fail")
.contains("必须先"));
let contract_with_plan_update = AgentRuntimeToolPlan {
actions: vec![action("agent.goal_contract")],
plan_update: Some(AgentRuntimePlanUpdate {
explanation: "不应与合同同轮".to_string(),
steps: vec![AgentRuntimePlanUpdateStep {
step: "不应先规划".to_string(),
status: "in_progress".to_string(),
}],
}),
..AgentRuntimeToolPlan::default()
};
assert!(validate_root_goal_contract_control_plan_at(
&root,
&binding.agent_id,
&binding.run_id,
&contract_with_plan_update,
)
.expect_err("plan update before Goal Contract must fail")
.contains("必须先"));
let contract_with_response = AgentRuntimeToolPlan {
actions: vec![action("agent.goal_contract")],
response: "不应先回复".to_string(),
..AgentRuntimeToolPlan::default()
};
assert!(validate_root_goal_contract_control_plan_at(
&root,
&binding.agent_id,
&binding.run_id,
&contract_with_response,
)
.expect_err("response before Goal Contract must fail")
.contains("必须先"));
let contract_with_legacy_plan = AgentRuntimeToolPlan {
actions: vec![action("agent.goal_contract")],
plan: vec!["先执行旧式计划".to_string()],
@@ -2336,12 +2336,18 @@ pub(super) fn try_open_game_creator_agent_runtime_task_lock_file(
if let Some(component) = component {
current.push(component);
if !current.exists() {
fs::create_dir(&current).map_err(|error| {
format!(
"创建 Agent Runtime 锁目录失败:{}: {error}",
current.display()
)
})?;
if let Err(error) = fs::create_dir(&current) {
// 另一并发锁请求可能在 exists 与 create_dir 之间创建同一目录;
// 下方元数据检查仍是权威校验,并会拒绝普通文件或 reparse point。
if error.kind() != std::io::ErrorKind::AlreadyExists
&& error.raw_os_error() != Some(183)
{
return Err(format!(
"创建 Agent Runtime 锁目录失败:{}: {error}",
current.display()
));
}
}
}
}
let metadata = fs::symlink_metadata(&current).map_err(|error| {
@@ -4129,6 +4135,10 @@ pub(super) fn redact_agent_runtime_project_paths_raw(root: &Path, value: &str) -
let root_display = root.to_string_lossy();
if !root_display.is_empty() {
redacted = redacted.replace(root_display.as_ref(), "$PROJECT_ROOT");
#[cfg(windows)]
if let Some(non_verbatim_root) = root_display.strip_prefix(r"\\?\") {
redacted = redacted.replace(non_verbatim_root, "$PROJECT_ROOT");
}
}
if let Ok(canonical_root) = root.canonicalize() {
let canonical_display = canonical_root.to_string_lossy();
@@ -4148,6 +4158,10 @@ pub(super) fn redact_agent_runtime_project_paths_preserving_tail(
let root_display = root.to_string_lossy();
if !root_display.is_empty() {
redacted = redacted.replace(root_display.as_ref(), "$PROJECT_ROOT");
#[cfg(windows)]
if let Some(non_verbatim_root) = root_display.strip_prefix(r"\\?\") {
redacted = redacted.replace(non_verbatim_root, "$PROJECT_ROOT");
}
}
if let Ok(canonical_root) = root.canonicalize() {
let canonical_display = canonical_root.to_string_lossy();
@@ -842,7 +842,7 @@ fn result_serializes_with_camel_case_evidence_paths() {
#[test]
fn persisted_report_uses_only_relative_evidence_paths() {
let evidence_root = PathBuf::from("/tmp/browser-evidence");
let evidence_root = std::env::temp_dir().join("browser-evidence");
let result = BrowserValidationResult {
schema_version: RESULT_SCHEMA_VERSION.to_string(),
url: "http://127.0.0.1:34567/".to_string(),
@@ -807,6 +807,26 @@ fn strip_agent_runtime_cli_private_paths(value: &mut serde_json::Value) {
}
}
pub(crate) fn start_cli_agent_task_at(
project_path: &Path,
agent_id: &str,
task: &str,
run_id: &str,
) -> Result<AgentRuntimeResult, String> {
if agent_id == GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID {
start_game_creator_supervisor_background_task_for_session_at(
project_path,
None,
task,
run_id,
AGENT_RUNTIME_SUPERVISOR_CLI_SOURCE,
AGENT_RUNTIME_RUN_PROFILE_STANDARD,
)
} else {
start_game_creator_agent_background_task_at(project_path, agent_id, task, run_id)
}
}
fn serialize_agent_runtime_cli_payload<T: serde::Serialize>(payload: &T) -> Result<String, String> {
let mut value = serde_json::to_value(payload)
.map_err(|error| format!("序列化 Agent Runtime 状态失败:{error}"))?;
@@ -906,12 +926,7 @@ pub(crate) fn run_cli_command(command: CliCommand) -> Result<(), String> {
.map_err(|error| format!("创建 CLI runtime 失败:{error}"))?;
let run_id = format!("cli-{agent_id}-{}", unix_millis());
let terminal = runtime.block_on(async {
let started = start_game_creator_agent_background_task_at(
&project_path,
&agent_id,
&task,
&run_id,
)?;
let started = start_cli_agent_task_at(&project_path, &agent_id, &task, &run_id)?;
let canonical_run_id = started.state.run_id.clone();
let deadline = std::time::Instant::now() + Duration::from_secs(600);
loop {
@@ -631,6 +631,14 @@ fn resolve_project_command_executable_from_path(
}
let executable =
executable.ok_or_else(|| format!("command.exec 找不到受信任的 {program} 可执行文件"))?;
#[cfg(windows)]
let safe_directories = safe_directories
.into_iter()
.map(|directory| {
let directory = directory.to_string_lossy();
PathBuf::from(directory.strip_prefix(r"\\?\").unwrap_or(&directory))
})
.collect::<Vec<_>>();
let safe_path = std::env::join_paths(safe_directories)
.map_err(|error| format!("构造 command.exec 安全 PATH 失败:{error}"))?;
Ok((executable, safe_path))
@@ -1149,6 +1157,10 @@ pub(crate) fn prepare_project_command_launch_spec(
),
(OsString::from("CARGO_NET_OFFLINE"), OsString::from("true")),
(OsString::from("CARGO_TERM_COLOR"), OsString::from("never")),
// 受控命令不得继承用户级 Cargo rustc-wrapper(例如 sccache);
// 隔离 HOME/CARGO_HOME 下这类包装器既不可复现,也可能无法启动。
(OsString::from("RUSTC_WRAPPER"), OsString::new()),
(OsString::from("RUSTC_WORKSPACE_WRAPPER"), OsString::new()),
(OsString::from("npm_config_audit"), OsString::from("false")),
(OsString::from("npm_config_fund"), OsString::from("false")),
(
@@ -1213,10 +1225,18 @@ pub(crate) fn prepare_project_command_launch_spec(
environment.push((
OsString::from("ComSpec"),
PathBuf::from(system_root)
.join("System32/cmd.exe")
.join("System32")
.join("cmd.exe")
.into_os_string(),
));
}
#[cfg(windows)]
for (_, value) in &mut environment {
let rendered = value.to_string_lossy();
if let Some(without_prefix) = rendered.strip_prefix(r"\\?\") {
*value = OsString::from(without_prefix);
}
}
let arguments = project_command_actual_arguments(spec);
#[cfg(target_os = "linux")]
@@ -1247,10 +1267,71 @@ pub(crate) fn prepare_project_command_launch_spec(
}
#[cfg(not(target_os = "linux"))]
{
#[cfg(windows)]
let (executable, arguments, cwd) = {
fn without_windows_verbatim_prefix(path: PathBuf) -> PathBuf {
let value = path.to_string_lossy();
PathBuf::from(value.strip_prefix(r"\\?\").unwrap_or(&value))
}
let is_npm_batch = spec
.executable
.file_name()
.and_then(|name| name.to_str())
.is_some_and(|name| name.eq_ignore_ascii_case("npm.cmd"));
if is_npm_batch {
let npm_directory = spec.executable.parent().ok_or_else(|| {
ProjectCommandError::new(
ProjectCommandErrorStage::Preflight,
"command.exec 无法定位 Windows npm 安装目录",
)
})?;
let node_executable = npm_directory.join("node.exe");
let npm_cli = npm_directory.join("node_modules/npm/bin/npm-cli.js");
if !node_executable.is_file() || !npm_cli.is_file() {
return Err(ProjectCommandError::new(
ProjectCommandErrorStage::Preflight,
"command.exec Windows npm 安装缺少 node.exe 或 npm-cli.js",
));
}
let node_executable = fs::canonicalize(node_executable).map_err(|error| {
ProjectCommandError::new(
ProjectCommandErrorStage::Preflight,
format!("command.exec 定位 Windows node.exe 失败:{error}"),
)
})?;
let npm_cli = fs::canonicalize(npm_cli).map_err(|error| {
ProjectCommandError::new(
ProjectCommandErrorStage::Preflight,
format!("command.exec 定位 Windows npm-cli.js 失败:{error}"),
)
})?;
let node_executable = without_windows_verbatim_prefix(node_executable);
let npm_cli = without_windows_verbatim_prefix(npm_cli);
let mut node_arguments = vec![npm_cli.into_os_string()];
node_arguments.extend(arguments.into_iter().map(OsString::from));
(
node_executable,
node_arguments,
without_windows_verbatim_prefix(spec.cwd.clone()),
)
} else {
(
without_windows_verbatim_prefix(spec.executable.clone()),
arguments.into_iter().map(OsString::from).collect(),
without_windows_verbatim_prefix(spec.cwd.clone()),
)
}
};
#[cfg(not(windows))]
let (executable, arguments, cwd) = (
spec.executable.clone(),
arguments.into_iter().map(OsString::from).collect(),
spec.cwd.clone(),
);
Ok(ProjectCommandLaunchSpec {
executable: spec.executable.clone(),
arguments: arguments.into_iter().map(OsString::from).collect(),
cwd: spec.cwd.clone(),
executable,
arguments,
cwd,
environment,
sandbox_backend: "legacy-host-restricted".to_string(),
sandbox_mode: "fixed-command".to_string(),
@@ -1308,7 +1389,10 @@ pub(crate) fn stage_project_command_launch_spec(
}
}
fn configure_project_command_process_group(command: &mut tokio::process::Command) {
fn configure_project_command_process_group(
command: &mut tokio::process::Command,
launch: &ProjectCommandLaunchSpec,
) {
#[cfg(unix)]
{
use std::os::unix::process::CommandExt;
@@ -1316,6 +1400,13 @@ fn configure_project_command_process_group(command: &mut tokio::process::Command
}
#[cfg(windows)]
{
let npm_cli_host = launch
.arguments
.first()
.and_then(|argument| Path::new(argument).file_name())
.and_then(|name| name.to_str())
.is_some_and(|name| name.eq_ignore_ascii_case("npm-cli.js"));
let _ = npm_cli_host;
crate::configure_windows_background_tokio_command(command, true);
}
}
@@ -1347,7 +1438,7 @@ where
for (name, value) in &staged.launch.environment {
command.env(name, value);
}
configure_project_command_process_group(&mut command);
configure_project_command_process_group(&mut command, &staged.launch);
#[cfg(target_os = "linux")]
staged
.gate
@@ -1803,6 +1894,12 @@ where
Err(_) => {
let termination = match terminate_project_command_process_group(&mut child).await {
Ok(termination) => termination,
#[cfg(windows)]
Err(error) if child.try_wait().ok().flatten().is_some() => {
format!(
"请求终止受控进程组后主进程已回收(taskkill 未找到已退出进程:{error}"
)
}
Err(error) => {
stdout_task.abort();
stderr_task.abort();
@@ -2144,7 +2241,13 @@ mod tests {
"expected rejection for {program} {args:?}"
);
}
let absolute = vec!["test".to_string(), "/tmp/outside.rs".to_string()];
let absolute = vec![
"test".to_string(),
std::env::temp_dir()
.join("outside.rs")
.to_string_lossy()
.into_owned(),
];
assert!(resolve_project_command_spec_at(root, "cargo", &absolute, ".", 30).is_err());
let sensitive = vec!["status".to_string(), ".agent/agent.db".to_string()];
assert!(resolve_project_command_spec_at(root, "git", &sensitive, ".", 30).is_err());
@@ -2512,6 +2615,18 @@ raise SystemExit(code)'
);
}
#[cfg(windows)]
#[test]
fn project_command_safe_path_uses_win32_compatible_directories() {
let dir = command_project("windows-safe-path");
let raw_path = std::env::var_os("PATH").expect("PATH");
let (_, safe_path) =
resolve_project_command_executable_from_path(dir.path(), "node", &raw_path)
.expect("resolve node executable");
assert!(std::env::split_paths(&safe_path)
.all(|directory| { !directory.as_os_str().to_string_lossy().starts_with(r"\\?\") }));
}
#[cfg(not(target_os = "linux"))]
#[test]
fn project_command_injects_git_safety_options_before_pathspec_separator() {
@@ -368,23 +368,12 @@ pub(crate) fn game_creator_codex_app_server_llm_route_error(
}
pub(crate) fn check_game_creator_codex_cli_available() -> Result<(), String> {
let output = std::process::Command::new("codex")
.arg("--version")
.stdin(std::process::Stdio::null())
.output()
.map_err(|_| "Codex CLI 未安装或不在当前客户端 PATH 中".to_string())?;
if !output.status.success() {
return Err("Codex CLI 版本检查失败".to_string());
}
let version = String::from_utf8_lossy(&output.stdout);
if !version.trim().starts_with("codex-cli ") {
return Err("Codex CLI 返回了无法识别的版本信息".to_string());
}
Ok(())
crate::agent::game_creator_codex_cli_version_identity().map(|_| ())
}
fn check_game_creator_codex_app_server_available() -> Result<(), String> {
let output = std::process::Command::new("codex")
let executable = crate::agent::game_creator_codex_cli_executable_path()?;
let output = std::process::Command::new(executable)
.args(["app-server", "--help"])
.stdin(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
@@ -860,6 +849,11 @@ pub(crate) fn windows_private_dacl_security_information(
} else {
0
}
| if initialize_owner && !owner_matches {
OWNER_SECURITY_INFORMATION
} else {
0
}
}
#[cfg(windows)]
@@ -1251,7 +1245,10 @@ fn secure_windows_game_creator_path_for_current_user_with_owner_policy(
pub(crate) fn configure_game_creator_runtime_config_dir(
app: &tauri::AppHandle,
) -> Result<(), Box<dyn std::error::Error>> {
let config_dir = prepare_game_creator_runtime_config_dir(&app.path().app_config_dir()?)
let requested_config_dir = game_creator_runtime_config_dir()
.map(Ok)
.unwrap_or_else(|| app.path().app_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() {
@@ -2008,6 +2008,10 @@ fn main() {
}
}
if let Some(config_dir) = runtime_config_dir {
set_game_creator_runtime_config_dir(config_dir);
}
let mut tauri_context = tauri::generate_context!();
let startup_log = if cfg!(all(not(debug_assertions), feature = "game-chat-release")) {
let path = initialize_game_chat_startup_log(&tauri_context.config().identifier);
@@ -206,6 +206,9 @@ where
}
let mut bytes = data.as_bytes().to_vec();
if append_newline {
#[cfg(windows)]
bytes.extend_from_slice(b"\r\n");
#[cfg(not(windows))]
bytes.push(b'\n');
}
if bytes.len() > PROCESS_SESSION_MAX_STDIN_BYTES {
@@ -265,7 +268,7 @@ where
}
Ok(ProcessSessionStdinResult {
process_id: process_id.to_string(),
bytes_written: bytes.len(),
bytes_written: data.len() + usize::from(append_newline),
content_sha256,
stdin_open: output.stdin_open,
eof,
@@ -50,10 +50,22 @@ pub(crate) fn validate_process_session_command_spec(
Ok(())
}
fn process_session_command_builder(
pub(super) fn process_session_command_builder(
launch: &ProjectCommandLaunchSpec,
#[cfg(target_os = "linux")] bridge: &ProcessSessionBridgeServer,
) -> Result<CommandBuilder, String> {
#[cfg(windows)]
let is_npm_launch = launch
.executable
.file_name()
.and_then(|name| name.to_str())
.is_some_and(|name| name.eq_ignore_ascii_case("npm.cmd"))
|| launch.arguments.first().is_some_and(|argument| {
std::path::Path::new(argument)
.file_name()
.and_then(|name| name.to_str())
.is_some_and(|name| name.eq_ignore_ascii_case("npm-cli.js"))
});
#[cfg(target_os = "linux")]
let mut command = {
let current_executable = std::env::current_exe()
@@ -76,16 +88,72 @@ fn process_session_command_builder(
};
#[cfg(not(target_os = "linux"))]
let mut command = {
let mut command = CommandBuilder::new(&launch.executable);
command.args(&launch.arguments);
command
#[cfg(windows)]
{
if launch
.executable
.file_name()
.and_then(|name| name.to_str())
.is_some_and(|name| name.eq_ignore_ascii_case("npm.cmd"))
{
let npm_directory = launch
.executable
.parent()
.ok_or_else(|| "command.start 无法定位 Windows npm 安装目录".to_string())?;
let node_executable = npm_directory.join("node.exe");
let npm_cli = npm_directory.join("node_modules/npm/bin/npm-cli.js");
if !node_executable.is_file() || !npm_cli.is_file() {
return Err(
"command.start Windows npm 安装缺少 node.exe 或 npm-cli.js".to_string()
);
}
let node_executable = node_executable.to_string_lossy();
let npm_cli = npm_cli.to_string_lossy();
let mut command = CommandBuilder::new(
node_executable
.strip_prefix(r"\\?\")
.unwrap_or(&node_executable),
);
command.arg(npm_cli.strip_prefix(r"\\?\").unwrap_or(&npm_cli));
command.args(&launch.arguments);
command
} else {
let mut command = CommandBuilder::new(&launch.executable);
command.args(&launch.arguments);
command
}
}
#[cfg(not(windows))]
{
let mut command = CommandBuilder::new(&launch.executable);
command.args(&launch.arguments);
command
}
};
#[cfg(windows)]
{
let cwd = launch.cwd.to_string_lossy();
command.cwd(cwd.strip_prefix(r"\\?\").unwrap_or(&cwd));
}
#[cfg(not(windows))]
command.cwd(&launch.cwd);
command.env_clear();
#[cfg(not(target_os = "linux"))]
for (name, value) in &launch.environment {
command.env(name, value);
}
#[cfg(windows)]
if is_npm_launch {
let node_executable = launch.executable.to_string_lossy();
let node_executable = node_executable.strip_prefix(r"\\?\").unwrap_or(&node_executable);
command.env("npm_node_execpath", node_executable);
command.env("NODE", node_executable);
command.env("npm_config_node_gyp", "");
// Windows 环境变量名不区分大小写。先移除继承的拼写,避免
// CommandBuilder 更新值后仍保留 `ComSpec` 而隐藏 npm 的小写键。
command.env_remove("ComSpec");
command.env("npm_config_script_shell", r"C:\Windows\System32\cmd.exe");
}
#[cfg(target_os = "linux")]
{
command.env(
@@ -770,28 +838,6 @@ where
)
})
.map_err(|error| ProjectCommandError::new(ProjectCommandErrorStage::Execution, error))?;
let reader = pair
.master
.try_clone_reader()
.map_err(|error| {
process_session_launch_failed(
root,
&mut durable_record,
format!("克隆 command.start PTY reader 失败:{error}"),
)
})
.map_err(|error| ProjectCommandError::new(ProjectCommandErrorStage::Execution, error))?;
let writer = pair
.master
.take_writer()
.map_err(|error| {
process_session_launch_failed(
root,
&mut durable_record,
format!("取得 command.start PTY writer 失败:{error}"),
)
})
.map_err(|error| ProjectCommandError::new(ProjectCommandErrorStage::Execution, error))?;
let command = process_session_command_builder(launch)
.map_err(|error| process_session_launch_failed(root, &mut durable_record, error))
.map_err(|error| ProjectCommandError::new(ProjectCommandErrorStage::Execution, error))?;
@@ -807,6 +853,30 @@ where
})
.map_err(|error| ProjectCommandError::new(ProjectCommandErrorStage::Execution, error))?;
drop(pair.slave);
let reader = pair.master.try_clone_reader().map_err(|error| {
let _ = child.kill();
let _ = child.wait();
ProjectCommandError::new(
ProjectCommandErrorStage::Execution,
process_session_launch_failed(
root,
&mut durable_record,
format!("克隆 command.start PTY reader 失败:{error}"),
),
)
})?;
let writer = pair.master.take_writer().map_err(|error| {
let _ = child.kill();
let _ = child.wait();
ProjectCommandError::new(
ProjectCommandErrorStage::Execution,
process_session_launch_failed(
root,
&mut durable_record,
format!("取得 command.start PTY writer 失败:{error}"),
),
)
})?;
#[cfg(windows)]
let windows_job = match WindowsProcessJob::assign(child.as_ref()) {
Ok(job) => job,
@@ -1014,21 +1084,129 @@ impl AnsiStripper {
}
}
#[cfg(windows)]
#[derive(Default)]
pub(super) struct AnsiTerminalRepositionDetector {
state: u8,
}
#[cfg(windows)]
impl AnsiTerminalRepositionDetector {
pub(super) fn push(&mut self, byte: u8) -> bool {
match self.state {
0 if byte == 0x1b => self.state = 1,
1 if byte == b'[' => self.state = 2,
1 => self.state = 0,
2 if (0x40..=0x7e).contains(&byte) => {
self.state = 0;
return matches!(byte, b'A'..=b'H' | b'f');
}
2 => {}
_ => self.state = 0,
}
false
}
}
fn drain_process_session_output(
live: Arc<LiveProcessSession>,
mut reader: Box<dyn std::io::Read + Send>,
) {
let mut buffer = [0u8; 4096];
let mut pending = Vec::new();
let mut pending_logical_line_bytes = 0usize;
let mut ansi = AnsiStripper::default();
let mut output_limit = false;
#[cfg(windows)]
let mut conpty_cursor_query_match = 0usize;
#[cfg(windows)]
let mut conpty_cursor_replied = false;
#[cfg(windows)]
let mut terminal_reposition = AnsiTerminalRepositionDetector::default();
#[cfg(windows)]
let mut conpty_soft_wrap = false;
loop {
match reader.read(&mut buffer) {
Ok(0) => break,
Ok(read) => {
for byte in &buffer[..read] {
#[cfg(windows)]
{
const CONPTY_CURSOR_QUERY: &[u8] = b"\x1b[6n";
if !conpty_cursor_replied
&& *byte == CONPTY_CURSOR_QUERY[conpty_cursor_query_match]
{
conpty_cursor_query_match += 1;
if conpty_cursor_query_match == CONPTY_CURSOR_QUERY.len() {
conpty_cursor_query_match = 0;
let reply_result = live
.writer
.lock()
.map_err(|_| "process session stdin 锁已损坏".to_string())
.and_then(|mut writer| {
let Some(writer) = writer.as_mut() else {
// 终止线程会先关闭 stdin;此时 ConPTY 可能仍把启动期
// 光标查询交给 reader。进程树已经进入收束阶段,无需再
// 把无法回复查询升级成 needs-reconciliation。
return Ok(());
};
writer
.write_all(b"\x1b[1;1R")
.and_then(|()| writer.flush())
.map_err(|error| {
format!("回复 Windows ConPTY 光标查询失败:{error}")
})
});
if let Err(error) = reply_result {
if let Ok(mut output) = live.output.lock() {
output.status = "failed".to_string();
output.needs_reconciliation = true;
output.stdin_open = false;
let detail = format!(
"\n<process output handshake failed: {error}>\n"
);
if output.text.len().saturating_add(detail.len())
<= PROCESS_SESSION_MAX_OUTPUT_BYTES
{
output.text.push_str(&detail);
}
live.output_changed.notify_all();
}
let _ = live.control.send(ProcessControl::Terminate);
return;
}
conpty_cursor_replied = true;
}
} else if !conpty_cursor_replied {
conpty_cursor_query_match =
usize::from(*byte == CONPTY_CURSOR_QUERY[0]);
}
}
#[cfg(windows)]
let ends_terminal_reposition = terminal_reposition.push(*byte);
let before = pending.len();
ansi.push(*byte, &mut pending);
let visible_bytes = pending.len().saturating_sub(before);
if visible_bytes > 0
&& !matches!(pending.last(), Some(b'\n' | b'\r'))
{
pending_logical_line_bytes =
pending_logical_line_bytes.saturating_add(visible_bytes);
if pending_logical_line_bytes > PROCESS_SESSION_MAX_PENDING_LINE_BYTES {
output_limit = true;
break;
}
}
#[cfg(windows)]
if ends_terminal_reposition && !pending.is_empty() {
pending.push(b'\n');
if !append_process_output_line(&live, &pending) {
output_limit = true;
break;
}
pending.clear();
continue;
}
if pending.len() == before {
continue;
}
@@ -1037,10 +1215,36 @@ fn drain_process_session_output(
output_limit = true;
break;
}
#[cfg(windows)]
{
// ConPTY materializes an automatic terminal-width wrap as CR/LF.
// It is a display boundary, not an application line terminator, so
// it must not reset the logical-line safety limit. A real short line
// still resets at CR; the immediately following LF preserves that
// decision.
const PROCESS_SESSION_PTY_COLS: usize = 120;
match pending.last() {
Some(b'\r') => {
conpty_soft_wrap = pending_logical_line_bytes
>= PROCESS_SESSION_PTY_COLS;
if !conpty_soft_wrap {
pending_logical_line_bytes = 0;
}
}
Some(b'\n') => {
if !conpty_soft_wrap {
pending_logical_line_bytes = 0;
}
conpty_soft_wrap = false;
}
_ => {}
}
}
#[cfg(not(windows))]
{
pending_logical_line_bytes = 0;
}
pending.clear();
} else if pending.len() > PROCESS_SESSION_MAX_PENDING_LINE_BYTES {
output_limit = true;
break;
}
}
if output_limit {
@@ -121,8 +121,8 @@ pub(crate) fn terminate_process_sessions_for_run_at(
let terminal = terminate_process_session_at(root, &identity, &record.process_id, None)?;
if terminal.status == "running" || terminal.needs_reconciliation {
return Err(format!(
"进程会话 {} 尚未形成可信终态,不能把 run 标记为已取消",
record.process_id
"进程会话 {} 尚未形成可信终态status={}needsReconciliation={},不能把 run 标记为已取消",
record.process_id, terminal.status, terminal.needs_reconciliation
));
}
}

Some files were not shown because too many files have changed in this diff Show More