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:
@@ -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"
|
||||
|
||||
+55
-1
@@ -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],
|
||||
|
||||
+57
-17
@@ -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]
|
||||
|
||||
+10
-2
@@ -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(¤t).map_err(|error| {
|
||||
format!(
|
||||
"创建 Agent Runtime 锁目录失败:{}: {error}",
|
||||
current.display()
|
||||
)
|
||||
})?;
|
||||
if let Err(error) = fs::create_dir(¤t) {
|
||||
// 另一并发锁请求可能在 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(¤t).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
Reference in New Issue
Block a user