Merge remote-tracking branch 'origin/master' into feat/adapt-taonier-skill
# Conflicts: # docs/project-memory/shared-memory/pitfalls.md
This commit is contained in:
@@ -103,6 +103,16 @@ pub(crate) struct ProjectCommandSpec {
|
||||
pub(crate) cwd: PathBuf,
|
||||
pub(crate) timeout_seconds: u64,
|
||||
pub(crate) verification_eligible: bool,
|
||||
/// Linux 上 npm 的真实启动锚点:`(node, npm-cli.js)`。保留 `executable` 为 npm shim 以
|
||||
/// 维持既有 program / verification 口径,实际进程改由 node 直接运行 npm-cli.js,避免
|
||||
/// `#!/usr/bin/env node` 在沙箱里落到系统 node。
|
||||
pub(crate) node_launcher: Option<(PathBuf, PathBuf)>,
|
||||
}
|
||||
|
||||
struct ResolvedProjectCommandExecutable {
|
||||
executable: PathBuf,
|
||||
safe_path: OsString,
|
||||
node_launcher: Option<(PathBuf, PathBuf)>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
@@ -538,6 +548,7 @@ where
|
||||
cwd: root.to_path_buf(),
|
||||
timeout_seconds: 15,
|
||||
verification_eligible: false,
|
||||
node_launcher: None,
|
||||
};
|
||||
let launch = prepare_project_command_launch_spec(root, &spec)?;
|
||||
let mut staged = stage_project_command_launch_spec(&spec, launch)?;
|
||||
@@ -645,17 +656,18 @@ pub(crate) fn resolve_project_bootstrap_spec_at(
|
||||
));
|
||||
}
|
||||
let program = "npm".to_string();
|
||||
let (executable, safe_path) = resolve_project_command_executable(root, &program)
|
||||
let resolved = resolve_project_command_executable(root, &program)
|
||||
.map_err(|error| ProjectCommandError::new(ProjectCommandErrorStage::Validation, error))?;
|
||||
Ok(ProjectCommandSpec {
|
||||
program,
|
||||
executable,
|
||||
safe_path,
|
||||
executable: resolved.executable,
|
||||
safe_path: resolved.safe_path,
|
||||
arguments: vec!["install".to_string()],
|
||||
cwd_relative,
|
||||
cwd,
|
||||
timeout_seconds,
|
||||
verification_eligible: false,
|
||||
node_launcher: resolved.node_launcher,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -689,17 +701,18 @@ fn resolve_project_command_spec_inner(
|
||||
if program == "node" {
|
||||
validate_project_command_node_test_files(&cwd, arguments)?;
|
||||
}
|
||||
let (executable, safe_path) = resolve_project_command_executable(root, &program)?;
|
||||
let resolved = resolve_project_command_executable(root, &program)?;
|
||||
let verification_eligible = project_command_verification_eligible(&program, arguments);
|
||||
Ok(ProjectCommandSpec {
|
||||
program,
|
||||
executable,
|
||||
safe_path,
|
||||
executable: resolved.executable,
|
||||
safe_path: resolved.safe_path,
|
||||
arguments: arguments.to_vec(),
|
||||
cwd_relative,
|
||||
cwd,
|
||||
timeout_seconds,
|
||||
verification_eligible,
|
||||
node_launcher: resolved.node_launcher,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -887,36 +900,81 @@ fn project_command_npm_verification_script_suffix_allowed(suffix: &str) -> bool
|
||||
fn resolve_project_command_executable(
|
||||
root: &Path,
|
||||
program: &str,
|
||||
) -> Result<(PathBuf, OsString), String> {
|
||||
) -> Result<ResolvedProjectCommandExecutable, String> {
|
||||
if matches!(program, "node" | "npm") {
|
||||
let runtime = crate::environment_check::resolve_node_runtime(root)?;
|
||||
let executable = if program == "node" {
|
||||
runtime.node.clone()
|
||||
} else {
|
||||
runtime
|
||||
.node
|
||||
.parent()
|
||||
.ok_or("node-runtime-invalid")?
|
||||
.join(if cfg!(windows) { "npm.cmd" } else { "npm" })
|
||||
};
|
||||
if program == "node" {
|
||||
return Ok(ResolvedProjectCommandExecutable {
|
||||
executable: runtime.node,
|
||||
safe_path: runtime.safe_path,
|
||||
node_launcher: None,
|
||||
});
|
||||
}
|
||||
let executable = runtime
|
||||
.node
|
||||
.parent()
|
||||
.ok_or("node-runtime-invalid")?
|
||||
.join(if cfg!(windows) { "npm.cmd" } else { "npm" });
|
||||
if !executable.is_file() {
|
||||
return Err("npm-runtime-missing-launcher".into());
|
||||
}
|
||||
return Ok((executable, runtime.safe_path));
|
||||
#[cfg(target_os = "linux")]
|
||||
let node_launcher = Some((runtime.node.clone(), runtime.npm_cli.clone()));
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
let node_launcher = None;
|
||||
return Ok(ResolvedProjectCommandExecutable {
|
||||
executable,
|
||||
safe_path: runtime.safe_path,
|
||||
node_launcher,
|
||||
});
|
||||
}
|
||||
#[cfg(target_os = "linux")]
|
||||
let path = std::env::join_paths([
|
||||
PathBuf::from("/usr/local/sbin"),
|
||||
PathBuf::from("/usr/local/bin"),
|
||||
PathBuf::from("/usr/sbin"),
|
||||
PathBuf::from("/usr/bin"),
|
||||
PathBuf::from("/sbin"),
|
||||
PathBuf::from("/bin"),
|
||||
])
|
||||
.map_err(|error| format!("构造 command.exec 受信任系统 PATH 失败:{error}"))?;
|
||||
let path = {
|
||||
// 先信任客户端解析出的 Node 工具链 bin(fnm / nvm / 系统),再落到系统目录;
|
||||
// npx / corepack / pnpm / yarn 等随之在沙箱里与 node 同版本。
|
||||
let mut directories = Vec::new();
|
||||
if let Ok(runtime) = crate::environment_check::resolve_node_runtime(root) {
|
||||
if let Some(bin) = runtime.node.parent() {
|
||||
directories.push(bin.to_path_buf());
|
||||
}
|
||||
}
|
||||
directories.extend([
|
||||
PathBuf::from("/usr/local/sbin"),
|
||||
PathBuf::from("/usr/local/bin"),
|
||||
PathBuf::from("/usr/sbin"),
|
||||
PathBuf::from("/usr/bin"),
|
||||
PathBuf::from("/sbin"),
|
||||
PathBuf::from("/bin"),
|
||||
]);
|
||||
std::env::join_paths(directories)
|
||||
.map_err(|error| format!("构造 command.exec 受信任系统 PATH 失败:{error}"))?
|
||||
};
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
let path = std::env::var_os("PATH").ok_or_else(|| "command.exec 缺少 PATH".to_string())?;
|
||||
resolve_project_command_executable_from_path(root, program, &path)
|
||||
let (executable, safe_path) =
|
||||
resolve_project_command_executable_from_path(root, program, &path)?;
|
||||
Ok(ResolvedProjectCommandExecutable {
|
||||
executable,
|
||||
safe_path,
|
||||
node_launcher: None,
|
||||
})
|
||||
}
|
||||
|
||||
/// Linux 上 npm 以 `node <npm-cli.js> <args>` 启动;其余情况保持 `executable + args`。
|
||||
pub(crate) fn project_command_actual_target(spec: &ProjectCommandSpec) -> (PathBuf, Vec<String>) {
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
if let Some((node, npm_cli)) = spec.node_launcher.as_ref() {
|
||||
let mut arguments = Vec::with_capacity(spec.arguments.len() + 1);
|
||||
arguments.push(npm_cli.to_string_lossy().into_owned());
|
||||
arguments.extend(spec.arguments.iter().cloned());
|
||||
return (node.clone(), arguments);
|
||||
}
|
||||
}
|
||||
(
|
||||
spec.executable.clone(),
|
||||
project_command_actual_arguments(spec),
|
||||
)
|
||||
}
|
||||
|
||||
fn resolve_project_command_executable_from_path(
|
||||
@@ -1604,13 +1662,16 @@ pub(crate) fn prepare_project_command_launch_spec(
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
let (target_executable, target_arguments) = project_command_actual_target(spec);
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
let arguments = project_command_actual_arguments(spec);
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
let sandbox = prepare_command_sandbox_launch(
|
||||
root,
|
||||
&spec.executable,
|
||||
&arguments,
|
||||
&target_executable,
|
||||
&target_arguments,
|
||||
&spec.cwd,
|
||||
&environment,
|
||||
)
|
||||
@@ -1713,7 +1774,8 @@ pub(crate) fn stage_project_command_launch_spec(
|
||||
) -> Result<StagedProjectCommandLaunchSpec, ProjectCommandError> {
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
let target_arguments = project_command_actual_arguments(spec)
|
||||
let (target_executable, target_arguments) = project_command_actual_target(spec);
|
||||
let target_arguments = target_arguments
|
||||
.into_iter()
|
||||
.map(OsString::from)
|
||||
.collect::<Vec<_>>();
|
||||
@@ -1725,7 +1787,7 @@ pub(crate) fn stage_project_command_launch_spec(
|
||||
environment: launch.environment,
|
||||
metadata: command_sandbox_platform_metadata(),
|
||||
},
|
||||
&spec.executable,
|
||||
&target_executable,
|
||||
&target_arguments,
|
||||
)
|
||||
.map_err(|error| {
|
||||
@@ -2593,6 +2655,34 @@ where
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
#[test]
|
||||
fn npm_command_targets_node_plus_npm_cli_on_linux() {
|
||||
let root = tempfile::tempdir().unwrap();
|
||||
std::fs::create_dir_all(root.path().join("game")).unwrap();
|
||||
let spec = resolve_project_command_spec_at(
|
||||
root.path(),
|
||||
"npm",
|
||||
&["run".to_string(), "build".to_string()],
|
||||
".",
|
||||
30,
|
||||
)
|
||||
.unwrap();
|
||||
let (executable, arguments) = project_command_actual_target(&spec);
|
||||
assert_eq!(
|
||||
executable.file_name().and_then(|name| name.to_str()),
|
||||
Some("node"),
|
||||
"{executable:?}"
|
||||
);
|
||||
assert!(arguments[0].ends_with("npm-cli.js"), "{arguments:?}");
|
||||
assert_eq!(&arguments[1..], ["run".to_string(), "build".to_string()]);
|
||||
|
||||
let bootstrap = resolve_project_bootstrap_spec_at(root.path(), 30).unwrap();
|
||||
let (_, arguments) = project_command_actual_target(&bootstrap);
|
||||
assert!(arguments[0].ends_with("npm-cli.js"), "{arguments:?}");
|
||||
assert_eq!(arguments[1], "install");
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
#[tokio::test]
|
||||
async fn exited_group_accepts_orphan_zombies_but_rejects_live_members() {
|
||||
@@ -2792,6 +2882,7 @@ mod tests {
|
||||
cwd: root.to_path_buf(),
|
||||
timeout_seconds: 20,
|
||||
verification_eligible: false,
|
||||
node_launcher: None,
|
||||
};
|
||||
let staged = StagedProjectCommandLaunchSpec {
|
||||
launch: ProjectCommandLaunchSpec {
|
||||
|
||||
@@ -295,16 +295,7 @@ mod linux {
|
||||
environment: &[(OsString, OsString)],
|
||||
) -> Result<CommandSandboxLaunch, CommandSandboxError> {
|
||||
let mut metadata = CommandSandboxMetadata::enforced_linux();
|
||||
let network_enabled = executable
|
||||
.file_name()
|
||||
.and_then(OsStr::to_str)
|
||||
.is_some_and(|name| {
|
||||
name.eq_ignore_ascii_case("npm") || name.eq_ignore_ascii_case("npm.cmd")
|
||||
})
|
||||
&& arguments
|
||||
.first()
|
||||
.is_some_and(|argument| argument == "install");
|
||||
if network_enabled {
|
||||
if command_sandbox_requests_npm_install(executable, arguments) {
|
||||
metadata.network = "enabled";
|
||||
}
|
||||
let bwrap = find_trusted_bwrap().map_err(|error| {
|
||||
@@ -365,6 +356,14 @@ mod linux {
|
||||
)
|
||||
},
|
||||
)?;
|
||||
let node_read_only = collect_node_toolchain_mounts(&root, &executable, &target_environment)
|
||||
.map_err(|error| {
|
||||
CommandSandboxError::new(
|
||||
format!("command sandbox node toolchain mount 无效:{error}"),
|
||||
metadata.clone(),
|
||||
)
|
||||
})?;
|
||||
let external_read_only = merge_read_only_mounts(external_read_only, node_read_only);
|
||||
let fixed_system_read_only = collect_fixed_system_mounts();
|
||||
|
||||
let mut launch = build_linux_bwrap_launch(LinuxSandboxPlan {
|
||||
@@ -465,6 +464,11 @@ mod linux {
|
||||
let mut values = BTreeMap::<OsString, OsString>::new();
|
||||
for (name, value) in environment {
|
||||
validate_environment_name(name)?;
|
||||
if name == OsStr::new("FNM_MULTISHELL_PATH") {
|
||||
// fnm 的 multishell 路径位于 /run 下,而 sandbox 用 tmpfs 覆盖 /run;
|
||||
// 保留会让子进程按一个不存在的目录查找 node。
|
||||
continue;
|
||||
}
|
||||
let replacement = match name.to_str().unwrap_or_default() {
|
||||
"HOME" | "USERPROFILE" => Some(COMMAND_SANDBOX_PRIVATE_HOME),
|
||||
"TMPDIR" | "TEMP" | "TMP" => Some("/tmp"),
|
||||
@@ -542,6 +546,108 @@ mod linux {
|
||||
.collect())
|
||||
}
|
||||
|
||||
/// `npm install` 是唯一允许联网的受控入口。Linux 以 `node <npm-cli.js> install` 启动时
|
||||
/// executable 是 node,网络判定不能只看 executable 文件名。
|
||||
fn command_sandbox_requests_npm_install(executable: &Path, arguments: &[String]) -> bool {
|
||||
let npm_name = executable
|
||||
.file_name()
|
||||
.and_then(OsStr::to_str)
|
||||
.is_some_and(|name| {
|
||||
name.eq_ignore_ascii_case("npm") || name.eq_ignore_ascii_case("npm.cmd")
|
||||
});
|
||||
if npm_name {
|
||||
return arguments
|
||||
.first()
|
||||
.is_some_and(|argument| argument == "install");
|
||||
}
|
||||
arguments
|
||||
.first()
|
||||
.map(Path::new)
|
||||
.and_then(Path::file_name)
|
||||
.and_then(OsStr::to_str)
|
||||
.is_some_and(|name| name.eq_ignore_ascii_case("npm-cli.js"))
|
||||
&& arguments
|
||||
.get(1)
|
||||
.is_some_and(|argument| argument == "install")
|
||||
}
|
||||
|
||||
/// fnm / nvm / 系统 / 随包 Node 的安装前缀必须整棵只读挂进沙箱:只挂单个 `node` 或 `npm`
|
||||
/// shim 会让 `#!/usr/bin/env node` 落到系统 node,并让 npm 的 `../lib/node_modules/npm`
|
||||
/// 相对 require 失效。前缀本身仍走与主机发现共用的窄叶校验。
|
||||
fn collect_node_toolchain_mounts(
|
||||
root: &Path,
|
||||
executable: &Path,
|
||||
environment: &[(OsString, OsString)],
|
||||
) -> Result<Vec<ReadOnlyMount>, String> {
|
||||
let mut mounts = BTreeMap::<PathBuf, PathBuf>::new();
|
||||
for candidate in node_installation_candidates(executable, environment) {
|
||||
if candidate.starts_with(root) {
|
||||
continue;
|
||||
}
|
||||
let Ok(prefix) =
|
||||
crate::environment_check::validate_node_installation_prefix(&candidate)
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
add_external_mount(root, &prefix, &prefix, &mut mounts)?;
|
||||
}
|
||||
Ok(mounts
|
||||
.into_iter()
|
||||
.map(|(destination, source)| ReadOnlyMount {
|
||||
source,
|
||||
destination,
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
fn node_installation_candidates(
|
||||
executable: &Path,
|
||||
environment: &[(OsString, OsString)],
|
||||
) -> Vec<PathBuf> {
|
||||
let mut candidates = Vec::new();
|
||||
if let Ok(canonical) = fs::canonicalize(executable) {
|
||||
push_node_prefix_candidates(&canonical, &mut candidates);
|
||||
}
|
||||
if let Some(path) = environment
|
||||
.iter()
|
||||
.find(|(name, _)| name == OsStr::new("PATH"))
|
||||
.map(|(_, value)| value)
|
||||
{
|
||||
for directory in std::env::split_paths(path) {
|
||||
if !directory.is_absolute() {
|
||||
continue;
|
||||
}
|
||||
let Ok(directory) = fs::canonicalize(&directory) else {
|
||||
continue;
|
||||
};
|
||||
if directory.is_dir() {
|
||||
push_node_prefix_candidates(&directory.join("node"), &mut candidates);
|
||||
}
|
||||
}
|
||||
}
|
||||
candidates
|
||||
}
|
||||
|
||||
fn push_node_prefix_candidates(path: &Path, candidates: &mut Vec<PathBuf>) {
|
||||
let Some(parent) = path.parent() else {
|
||||
return;
|
||||
};
|
||||
for ancestor in parent.ancestors().take(5) {
|
||||
candidates.push(ancestor.to_path_buf());
|
||||
}
|
||||
}
|
||||
|
||||
fn merge_read_only_mounts(
|
||||
first: Vec<ReadOnlyMount>,
|
||||
second: Vec<ReadOnlyMount>,
|
||||
) -> Vec<ReadOnlyMount> {
|
||||
let mut mounts = first;
|
||||
mounts.extend(second);
|
||||
mounts.sort_by(|left, right| left.destination.cmp(&right.destination));
|
||||
mounts.dedup_by(|left, right| left.destination == right.destination);
|
||||
mounts
|
||||
}
|
||||
|
||||
fn validate_external_toolchain_root(name: &str, root: &Path) -> Result<(), String> {
|
||||
if root.parent() == Some(Path::new("/home")) {
|
||||
return Err(format!(
|
||||
@@ -609,16 +715,7 @@ mod linux {
|
||||
let mut args = Vec::<OsString>::new();
|
||||
push_namespace_arguments(
|
||||
&mut args,
|
||||
plan.executable
|
||||
.file_name()
|
||||
.and_then(OsStr::to_str)
|
||||
.is_some_and(|name| {
|
||||
name.eq_ignore_ascii_case("npm") || name.eq_ignore_ascii_case("npm.cmd")
|
||||
})
|
||||
&& plan
|
||||
.arguments
|
||||
.first()
|
||||
.is_some_and(|argument| argument == "install"),
|
||||
command_sandbox_requests_npm_install(&plan.executable, &plan.arguments),
|
||||
);
|
||||
push_ro_bind(&mut args, Path::new("/usr"), Path::new("/usr"));
|
||||
for (target, destination) in &plan.merged_usr_links {
|
||||
@@ -1150,6 +1247,113 @@ mod linux {
|
||||
assert_eq!(mounts[0].source, rustup_home);
|
||||
}
|
||||
|
||||
fn install_node_prefix_fixture(prefix: &Path) {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
|
||||
let bin = prefix.join("bin");
|
||||
let npm = prefix.join("lib/node_modules/npm/bin");
|
||||
std::fs::create_dir_all(&bin).expect("create node bin");
|
||||
std::fs::create_dir_all(&npm).expect("create npm bin");
|
||||
let node = bin.join("node");
|
||||
std::fs::write(&node, b"node").expect("write node");
|
||||
std::fs::set_permissions(&node, std::fs::Permissions::from_mode(0o755))
|
||||
.expect("chmod node");
|
||||
std::fs::write(npm.join("npm-cli.js"), b"npm").expect("write npm-cli");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn node_installation_prefix_is_mounted_read_only_from_executable_and_path() {
|
||||
let tree = unique_temp_tree();
|
||||
let root = tree.0.join("workspace-node-prefix");
|
||||
let installation = tree
|
||||
.0
|
||||
.join("fnm")
|
||||
.join("node-versions")
|
||||
.join("v22.23.3")
|
||||
.join("installation");
|
||||
std::fs::create_dir_all(&root).expect("create workspace");
|
||||
install_node_prefix_fixture(&installation);
|
||||
let node = installation.join("bin/node");
|
||||
|
||||
let mounts = collect_node_toolchain_mounts(
|
||||
&root,
|
||||
&node,
|
||||
&[(
|
||||
OsString::from("PATH"),
|
||||
installation.join("bin").into_os_string(),
|
||||
)],
|
||||
)
|
||||
.expect("node installation prefix should mount");
|
||||
assert_eq!(mounts.len(), 1);
|
||||
assert_eq!(mounts[0].source, installation.canonicalize().unwrap());
|
||||
assert_eq!(mounts[0].destination, installation.canonicalize().unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn node_toolchain_mount_skips_wide_and_incomplete_candidates() {
|
||||
let tree = unique_temp_tree();
|
||||
let root = tree.0.join("workspace-node-skip");
|
||||
std::fs::create_dir_all(&root).expect("create workspace");
|
||||
let incomplete = tree.0.join("incomplete-node");
|
||||
std::fs::create_dir_all(incomplete.join("bin")).expect("create bin");
|
||||
std::fs::write(incomplete.join("bin/node"), b"node").expect("write node");
|
||||
|
||||
let mounts = collect_node_toolchain_mounts(
|
||||
&root,
|
||||
&incomplete.join("bin/node"),
|
||||
&[(
|
||||
OsString::from("PATH"),
|
||||
OsString::from("/usr/bin:/root:/tmp:/"),
|
||||
)],
|
||||
)
|
||||
.expect("wide or incomplete candidates must be skipped, not fatal");
|
||||
assert!(mounts.is_empty(), "unexpected mounts: {mounts:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normalize_target_environment_drops_fnm_multishell_path() {
|
||||
let values = normalize_target_environment(&[
|
||||
(
|
||||
OsString::from("FNM_MULTISHELL_PATH"),
|
||||
OsString::from("/run/user/0/fnm_multishells/1_2"),
|
||||
),
|
||||
(OsString::from("PATH"), OsString::from("/usr/bin")),
|
||||
])
|
||||
.expect("normalize environment");
|
||||
assert!(!values
|
||||
.iter()
|
||||
.any(|(name, _)| name == OsStr::new("FNM_MULTISHELL_PATH")));
|
||||
assert!(
|
||||
values
|
||||
.iter()
|
||||
.any(|(name, value)| name == OsStr::new("PATH")
|
||||
&& value == OsStr::new("/usr/bin"))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn npm_install_network_detection_supports_node_launcher() {
|
||||
assert!(command_sandbox_requests_npm_install(
|
||||
Path::new("/opt/node/bin/node"),
|
||||
&[
|
||||
"/opt/node/lib/node_modules/npm/bin/npm-cli.js".to_string(),
|
||||
"install".to_string(),
|
||||
],
|
||||
));
|
||||
assert!(!command_sandbox_requests_npm_install(
|
||||
Path::new("/opt/node/bin/node"),
|
||||
&[
|
||||
"/opt/node/lib/node_modules/npm/bin/npm-cli.js".to_string(),
|
||||
"run".to_string(),
|
||||
"build".to_string(),
|
||||
],
|
||||
));
|
||||
assert!(command_sandbox_requests_npm_install(
|
||||
Path::new("/usr/bin/npm"),
|
||||
&["install".to_string()],
|
||||
));
|
||||
}
|
||||
|
||||
struct TempTree(PathBuf);
|
||||
|
||||
impl Drop for TempTree {
|
||||
@@ -1283,6 +1487,130 @@ print("SANDBOX_OK")
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn command_sandbox_real_linux_opt_in_runs_host_node_and_npm_cli() {
|
||||
if std::env::var_os("GENARRATIVE_COMMAND_SANDBOX_REAL_TEST").is_none() {
|
||||
return;
|
||||
}
|
||||
let tree = unique_temp_tree();
|
||||
let root = tree.0.join("workspace-node-runtime");
|
||||
std::fs::create_dir_all(root.join(".agent")).expect("create runtime control");
|
||||
let Ok(runtime) = crate::environment_check::resolve_node_runtime(&root) else {
|
||||
return;
|
||||
};
|
||||
let environment = vec![
|
||||
(OsString::from("PATH"), runtime.safe_path.clone()),
|
||||
(OsString::from("HOME"), OsString::from("/host/home")),
|
||||
];
|
||||
let run = |executable: &Path, arguments: &[String]| {
|
||||
let launch = prepare_command_sandbox_launch(
|
||||
&root,
|
||||
executable,
|
||||
arguments,
|
||||
&root,
|
||||
&environment,
|
||||
)
|
||||
.expect("prepare node sandbox");
|
||||
let output = Command::new(&launch.executable)
|
||||
.args(&launch.arguments)
|
||||
.current_dir(&launch.cwd)
|
||||
.env_clear()
|
||||
.envs(launch.environment.iter().cloned())
|
||||
.output()
|
||||
.expect("run node sandbox");
|
||||
assert!(
|
||||
output.status.success(),
|
||||
"stderr={}",
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
);
|
||||
String::from_utf8_lossy(&output.stdout).trim().to_string()
|
||||
};
|
||||
|
||||
let version = run(
|
||||
&runtime.node,
|
||||
&[
|
||||
"-e".to_string(),
|
||||
"process.stdout.write(process.version)".to_string(),
|
||||
],
|
||||
);
|
||||
assert!(
|
||||
version.starts_with('v'),
|
||||
"unexpected node version: {version}"
|
||||
);
|
||||
|
||||
let npm_cli = runtime.npm_cli.to_string_lossy().into_owned();
|
||||
let npm_version = run(&runtime.node, &[npm_cli, "--version".to_string()]);
|
||||
assert!(!npm_version.is_empty(), "npm --version returned nothing");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn command_sandbox_real_linux_opt_in_runs_nvm_installation_prefix() {
|
||||
if std::env::var_os("GENARRATIVE_COMMAND_SANDBOX_REAL_TEST").is_none() {
|
||||
return;
|
||||
}
|
||||
let Some(nvm_dir) = std::env::var_os("NVM_DIR").map(PathBuf::from) else {
|
||||
return;
|
||||
};
|
||||
let versions_dir = nvm_dir.join("versions").join("node");
|
||||
let Ok(entries) = std::fs::read_dir(&versions_dir) else {
|
||||
return;
|
||||
};
|
||||
let mut versions = entries
|
||||
.flatten()
|
||||
.filter_map(|entry| entry.file_name().to_str().map(str::to_string))
|
||||
.collect::<Vec<_>>();
|
||||
versions.sort();
|
||||
let Some(version) = versions.pop() else {
|
||||
return;
|
||||
};
|
||||
let prefix = crate::environment_check::validate_node_installation_prefix(
|
||||
&versions_dir.join(&version),
|
||||
)
|
||||
.expect("nvm 安装前缀应通过窄叶校验");
|
||||
let node = prefix.join("bin").join("node");
|
||||
let npm_cli = prefix.join("lib/node_modules/npm/bin/npm-cli.js");
|
||||
|
||||
let tree = unique_temp_tree();
|
||||
let root = tree.0.join("workspace-nvm-runtime");
|
||||
std::fs::create_dir_all(root.join(".agent")).expect("create runtime control");
|
||||
let environment = vec![
|
||||
(OsString::from("PATH"), prefix.join("bin").into_os_string()),
|
||||
(OsString::from("HOME"), OsString::from("/host/home")),
|
||||
];
|
||||
let run = |arguments: &[String]| {
|
||||
let launch =
|
||||
prepare_command_sandbox_launch(&root, &node, arguments, &root, &environment)
|
||||
.expect("prepare nvm sandbox");
|
||||
let output = Command::new(&launch.executable)
|
||||
.args(&launch.arguments)
|
||||
.current_dir(&launch.cwd)
|
||||
.env_clear()
|
||||
.envs(launch.environment.iter().cloned())
|
||||
.output()
|
||||
.expect("run nvm sandbox");
|
||||
assert!(
|
||||
output.status.success(),
|
||||
"stderr={}",
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
);
|
||||
String::from_utf8_lossy(&output.stdout).trim().to_string()
|
||||
};
|
||||
|
||||
let version = run(&[
|
||||
"-e".to_string(),
|
||||
"process.stdout.write(process.version)".to_string(),
|
||||
]);
|
||||
assert!(
|
||||
version.starts_with('v'),
|
||||
"unexpected node version: {version}"
|
||||
);
|
||||
let npm_version = run(&[
|
||||
npm_cli.to_string_lossy().into_owned(),
|
||||
"--version".to_string(),
|
||||
]);
|
||||
assert!(!npm_version.is_empty(), "npm --version returned nothing");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn command_sandbox_staged_gate_real_linux_opt_in_blocks_until_commit() {
|
||||
if std::env::var_os("GENARRATIVE_COMMAND_SANDBOX_REAL_TEST").is_none() {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -44,9 +44,11 @@ mod linux {
|
||||
launch: &ProjectCommandLaunchSpec,
|
||||
spec: &ProjectCommandSpec,
|
||||
) -> Result<Self, String> {
|
||||
let (target_executable, target_arguments) =
|
||||
crate::command_exec::project_command_actual_target(spec);
|
||||
if !launch.executable.is_absolute()
|
||||
|| !launch.cwd.is_absolute()
|
||||
|| !spec.executable.is_absolute()
|
||||
|| !target_executable.is_absolute()
|
||||
{
|
||||
return Err("process session bridge launch path 必须是绝对路径".to_string());
|
||||
}
|
||||
@@ -68,8 +70,8 @@ mod linux {
|
||||
)
|
||||
})
|
||||
.collect(),
|
||||
target_executable: spec.executable.as_os_str().as_bytes().to_vec(),
|
||||
target_arguments: crate::command_exec::project_command_actual_arguments(spec)
|
||||
target_executable: target_executable.as_os_str().as_bytes().to_vec(),
|
||||
target_arguments: target_arguments
|
||||
.into_iter()
|
||||
.map(|argument| OsString::from(argument).into_vec())
|
||||
.collect(),
|
||||
|
||||
@@ -11892,7 +11892,10 @@ button.design-workspace-tree__entry:hover,
|
||||
left: 0;
|
||||
margin: 16px;
|
||||
max-height: none;
|
||||
z-index: 1;
|
||||
/* 抬到列表里「回到底部」胶囊(z-index: 10)之上:输入盒自己是层叠上下文,模型 / 附件
|
||||
菜单的 z-index 只在盒内有效,盒子不抬高就会被胶囊盖住。30 仍低于拖拽落点(60)与
|
||||
设置弹窗(240)这两个整屏浮层,它们照旧盖在输入盒之上。 */
|
||||
z-index: 30;
|
||||
/* 盒内内边距四边同值:文字/光标的左内缩与上内缩必须相等,
|
||||
否则点进输入框时(`.resource-reference-input:focus-within` 会画出焦点环)
|
||||
会明显看出文字离左边近、离上边远。 */
|
||||
@@ -12858,7 +12861,13 @@ button.design-workspace-tree__entry:hover,
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
z-index: 240;
|
||||
/*
|
||||
* 必须低于二级浮层:这里点「运行配置」打开的是既有 `.settings-overlay`
|
||||
* (z-index 220),插件面板 `.agc-plugin-panel-overlay` 为 230。
|
||||
* 设置浮层若压在最上层,二级弹窗就会盖在它下面;210 仍高于工作台内容
|
||||
* 与启动器(200),同时让运行配置 / 插件面板正常叠在其上。
|
||||
*/
|
||||
z-index: 210;
|
||||
display: grid;
|
||||
padding: 20px;
|
||||
background: rgb(65 43 34 / 42%);
|
||||
@@ -13401,10 +13410,11 @@ button.design-workspace-tree__entry:hover,
|
||||
grid-row: 1 / span 2;
|
||||
}
|
||||
|
||||
/* 消息之间的间距:消息列表是块级滚动容器(不是 flex/grid),基础规则里的 `gap: 14px`
|
||||
对它无效,而被改写成 `margin-top: 0` 的 `.message + .message` 又让相邻消息贴在一起。
|
||||
这里给列表的相邻子元素统一加间距(消息、工具调用折叠块、思考过程块都适用),
|
||||
间距值与消息内部的行距观感一致。 */
|
||||
/* 消息之间的间距:消息列表是块级滚动流,基础规则里的 `gap: 14px` 对它无效,而被改写成
|
||||
`margin-top: 0` 的 `.message + .message` 又让相邻消息贴在一起。这里给列表的相邻子元素统一
|
||||
加间距(消息、工具调用折叠块、思考过程块都适用),间距值与消息内部的行距观感一致。
|
||||
(列表下面被改成 flex 列容器,只是为了给「回到底部」胶囊一个 `margin-top: auto` 的落点;
|
||||
flex 里 margin 不折叠,这条间距规则仍照常生效。) */
|
||||
.game-workbench-chat
|
||||
.project-chat-surface.is-direct-codex
|
||||
.project-chat-message-list
|
||||
@@ -13413,6 +13423,37 @@ button.design-workspace-tree__entry:hover,
|
||||
margin-top: 14px;
|
||||
}
|
||||
|
||||
/* 「回到底部」胶囊的位置真源:把消息列表做成 **flex 列容器**,胶囊是最后一个 flex 项。
|
||||
- 内容不足一屏:`margin-top: auto` 吃掉剩余空间,把胶囊顶到列表可见底边;
|
||||
- 内容溢出一屏:auto 归零,胶囊落回内容末尾,再由自身的 `sticky bottom-3` 上拉贴底。
|
||||
只有 sticky 是不够的:sticky 只能把元素**上拉**,不能下推——内容不足一屏时胶囊会停在
|
||||
文档流里(表现为悬空,见 `docs/adr/【ADR】DirectProject对话滚动与历史自动加载-2026-10-02.md`)。
|
||||
`gap: 0` 必须显式写:上面 `.… .project-chat-message-list { gap: 14px }` 是给 flex 预备的,
|
||||
改成 flex 后它会生效,与下面那些 `> * + *` 的 margin 叠成双倍间距。
|
||||
`> * { flex: 0 0 auto }` 保证内容溢出一屏时子项不被纵向压缩,列表仍是唯一滚动区;
|
||||
子项间距仍由上面那条 `> * + *` 给(flex 里 margin 不折叠,差值与块级最多 2px)。 */
|
||||
.game-workbench-chat
|
||||
.project-chat-surface.is-direct-codex
|
||||
.project-chat-message-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
.game-workbench-chat
|
||||
.project-chat-surface.is-direct-codex
|
||||
.project-chat-message-list
|
||||
> * {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.game-workbench-chat
|
||||
.project-chat-surface.is-direct-codex
|
||||
.project-chat-message-list
|
||||
> .project-chat-scroll-to-bottom {
|
||||
margin-top: auto;
|
||||
}
|
||||
|
||||
/* 发送时间与完成后的过程区沿用对话面板的次要信息样式。 */
|
||||
.message-sent-at {
|
||||
display: block;
|
||||
|
||||
+12
-4
@@ -3,9 +3,17 @@ import { ArrowDown } from 'lucide-react';
|
||||
/**
|
||||
* 底部居中的「回到底部」胶囊。
|
||||
*
|
||||
* 用 `sticky bottom-*` 而不是浮层:工作台里 `.project-chat-conversation` 是 `display: block`
|
||||
* 加 `height: 100%` 的几何,在列表外套一层定位容器会把列表的 `height: 100%` 塌成内容高度;
|
||||
* 粘在列表内部的胶囊零结构改动,两块宿主(工作台侧栏与独立页面)都能拿到。
|
||||
* 它是消息列表的**最后一个 flex 项**,贴底靠两件事配合(见 `styles.css` 里
|
||||
* `.project-chat-scroll-to-bottom` 那组规则):
|
||||
* - `margin-top: auto`:内容不足一屏时吃掉剩余空间,把胶囊顶到列表可见底边;
|
||||
* - `sticky bottom-3`:内容溢出一屏时把胶囊从内容末尾**上拉**贴住可见底边。
|
||||
* 只用 `sticky` 不够——它只能上拉、不能下推,内容不足一屏时元素会停在文档流里(悬空)。
|
||||
*
|
||||
* 用列表内的 flex 项而不是浮层:工作台里 `.project-chat-surface.is-direct-codex >
|
||||
* .project-chat-conversation` 是纵向 flex,`.project-chat-message-list` 是其中
|
||||
* `flex: 1 1 auto` 的唯一滚动项;在列表外套一层定位容器会让列表塌成内容高度,
|
||||
* 改公共类又会连带 `PlanningChatView`。粘在列表内部零结构改动,两块宿主
|
||||
* (工作台侧栏与导出面板)都能拿到。
|
||||
*
|
||||
* 显隐与文案由调用方决定(距底超过阈值才出现;不跟随时来了新回复就换文案),这里只负责表现。
|
||||
*/
|
||||
@@ -19,7 +27,7 @@ export function DirectProjectScrollToBottomCapsule({
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className="sticky bottom-3 z-10 mx-auto mt-1 flex w-fit items-center gap-1.5 rounded-full border border-[color:var(--platform-subpanel-border)] bg-[var(--platform-neutral-bg)] px-3 py-1.5 text-xs font-medium text-[color:var(--platform-text-strong)] shadow-[0_6px_18px_rgba(24,32,47,0.16)] backdrop-blur"
|
||||
className="project-chat-scroll-to-bottom sticky bottom-3 z-10 mx-auto flex w-fit items-center gap-1.5 rounded-full border border-[color:var(--platform-subpanel-border)] bg-[var(--platform-neutral-bg)] px-3 py-1.5 text-xs font-medium text-[color:var(--platform-text-strong)] shadow-[0_6px_18px_rgba(24,32,47,0.16)] backdrop-blur"
|
||||
onClick={onClick}
|
||||
>
|
||||
<ArrowDown size={13} aria-hidden="true" />
|
||||
|
||||
+9
@@ -20,6 +20,15 @@ export const DIRECT_SCROLL_BOTTOM_THRESHOLD = 48;
|
||||
/** 加载行延迟多少毫秒才显示:本地读取常常瞬间返回,立即显示会闪一下。 */
|
||||
export const HISTORY_LOADING_INDICATOR_DELAY_MS = 150;
|
||||
|
||||
/**
|
||||
* 加载行显示后,加载态转假再等多少毫秒才卸载。
|
||||
*
|
||||
* 填充视口的自动加载是**连续翻页**的:一次加载落地后下一帧就又起一次,两次之间只有一个 effect
|
||||
* 回流。立即卸载会把加载行一帧内拆了又挂(表现为「正在加载更早的对话」忽隐忽现),这段隐藏延迟
|
||||
* 让连续加载之间的小空隙不触发卸载;真正的结束(超时后仍为假)照常卸载。
|
||||
*/
|
||||
export const HISTORY_LOADING_INDICATOR_HIDE_DELAY_MS = 300;
|
||||
|
||||
export const HISTORY_LOADING_TEXT = '正在加载更早的对话';
|
||||
export const HISTORY_ERROR_TEXT = '加载更早对话失败';
|
||||
export const HISTORY_RETRY_TEXT = '重试';
|
||||
|
||||
+34
-3
@@ -17,6 +17,7 @@ import {
|
||||
} from './conversationScrollAnchor';
|
||||
import {
|
||||
HISTORY_LOADING_INDICATOR_DELAY_MS,
|
||||
HISTORY_LOADING_INDICATOR_HIDE_DELAY_MS,
|
||||
isNearBottom,
|
||||
readConversationListMetrics,
|
||||
scrollToBottomLabel,
|
||||
@@ -127,6 +128,7 @@ export function useConversationScroll({
|
||||
const showHistoryLoading = useDelayedFlag(
|
||||
historyLoading,
|
||||
HISTORY_LOADING_INDICATOR_DELAY_MS,
|
||||
HISTORY_LOADING_INDICATOR_HIDE_DELAY_MS,
|
||||
);
|
||||
|
||||
/**
|
||||
@@ -141,6 +143,26 @@ export function useConversationScroll({
|
||||
loadEarlierRef.current();
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* 按真实几何复核「在底部」,返回复核结果。
|
||||
*
|
||||
* `atBottom` 的唯一常规来源是 `onScroll`,但内容**变短**到一屏以内时不会有滚动事件:回合收口把
|
||||
* 过程折进收起的 `<details>`、历史加载行卸载,都会让 `scrollHeight` 掉回 `clientHeight` 以下,
|
||||
* 之后列表根本滚不动,可 `atBottom` 还停在离开底部时的假值——「回到底部」胶囊就永远挂在一个没有
|
||||
* 可滚余量的列表上。布局变化与内容变化都是复核点。
|
||||
*
|
||||
* 复核为真时同步恢复跟随并清掉「有新回复」:都已经滚无可滚了,留着未读提示只会是指向空处的入口。
|
||||
*/
|
||||
const syncBottomFromLayout = useCallback((): boolean => {
|
||||
const list = listRef.current;
|
||||
if (!list) return followLatestRef.current;
|
||||
if (!isNearBottom(readConversationListMetrics(list))) return false;
|
||||
followLatestRef.current = true;
|
||||
setAtBottom(true);
|
||||
setHasNewReply(false);
|
||||
return true;
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* 布局变化后的补偿:跟随时贴底;否则优先冻结刚展开的折叠头,再退到前插锚点。
|
||||
*
|
||||
@@ -151,6 +173,9 @@ export function useConversationScroll({
|
||||
const list = listRef.current;
|
||||
if (!list) return;
|
||||
|
||||
// 先按真实几何复核贴底:内容塌回一屏以内时不会再有任何滚动事件来翻转 `atBottom`。
|
||||
syncBottomFromLayout();
|
||||
|
||||
// 程序化滚动在飞:滚动位置归这次动画所有,直接写 `scrollTop` 会把动画取消在半路。
|
||||
// 但内容在这期间变高(流式正文、图片撑开)时,点击瞬间记下的目标已经不是底部了——动画会
|
||||
// 停在旧目标上,而 `programmaticScrollRef` 只在「贴底」那次滚动事件里交还,于是跟随与补偿
|
||||
@@ -187,7 +212,7 @@ export function useConversationScroll({
|
||||
// 锚点还原失败(块已经不在列表里,或收口后被折进收起的 `<details>`、没有布局盒算不出偏移):
|
||||
// 拿当前位置重新起锚,别用过期基准写 `scrollTop`;只是这次不自动对齐,不会回跳。
|
||||
preserveAnchorRef.current = readTopVisibleTurnAnchor(list);
|
||||
}, []);
|
||||
}, [syncBottomFromLayout]);
|
||||
|
||||
// 换会话:列表容器不重挂载,滚动所有权必须显式复位,否则上一个会话的「已离开底部」
|
||||
// 会漏到新会话。必须先于下面那条「内容变化」的 effect:终态指纹要先同步成新会话的内容,
|
||||
@@ -214,8 +239,11 @@ export function useConversationScroll({
|
||||
scrollListToBottom(listRef.current, 'auto');
|
||||
return;
|
||||
}
|
||||
// 回合收口会把过程折进收起的 `<details>`,内容可能就此塌回一屏以内:先按真实几何复核,
|
||||
// 已经滚无可滚就不再点亮「有新回复」(那时列表里根本没有可去的底部)。
|
||||
if (syncBottomFromLayout()) return;
|
||||
if (gainedTerminalContent) setHasNewReply(true);
|
||||
}, [turns]);
|
||||
}, [syncBottomFromLayout, turns]);
|
||||
|
||||
// 观察列表的直接子元素:展开 / 收起、流式正文增长、图片撑高、历史前插都只有这一个入口。
|
||||
// 子元素增删(回合追加、加载行挂卸)由 MutationObserver 触发重订阅,同时让锚点的块集合缓存失效。
|
||||
@@ -232,6 +260,9 @@ export function useConversationScroll({
|
||||
for (const child of Array.from(list.children)) {
|
||||
observer.observe(child);
|
||||
}
|
||||
// 子元素增删本身就可能让内容长度跨过视口(历史加载行挂卸、回合收口折叠),而这不会命中任何
|
||||
// 被观察子元素的高度变化:这里按真实几何补一次贴底复核,避免胶囊赖在滚不动的列表上。
|
||||
syncBottomFromLayout();
|
||||
};
|
||||
subscribe();
|
||||
const mutations = new MutationObserver(subscribe);
|
||||
@@ -241,7 +272,7 @@ export function useConversationScroll({
|
||||
mutations.disconnect();
|
||||
observer?.disconnect();
|
||||
};
|
||||
}, [compensateLayout]);
|
||||
}, [compensateLayout, syncBottomFromLayout]);
|
||||
|
||||
// 程序化滚动被用户接手(滚轮 / 触摸 / 键盘)时立刻交还控制权。
|
||||
// 动画被打断后浏览器不会再发贴底滚动事件,标记不交还就会永远挂着,后续跟随与补偿全失效。
|
||||
|
||||
+14
-8
@@ -1,22 +1,28 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
/**
|
||||
* 延迟显示一个开关位:为真后等 `delayMs` 才转成可见,转假立即隐藏。
|
||||
* 延迟显示一个开关位:为真后等 `delayMs` 才转成可见,转假后再等 `hideDelayMs` 才隐藏。
|
||||
*
|
||||
* 用来避免「本地读取瞬间返回」时闪一下加载行:反馈要等得起,但不能一闪而过。
|
||||
* `delayMs` 用来避免「本地读取瞬间返回」时闪一下加载行:反馈要等得起,但不能一闪而过。
|
||||
* `hideDelayMs` 是反向的滞回:连续加载之间只隔一次 effect 回流,立即隐藏会让加载行一帧内反复
|
||||
* 挂卸。默认 0 表示转假即隐藏,保持「只延迟显示」的原语义。
|
||||
*/
|
||||
export function useDelayedFlag(value: boolean, delayMs: number): boolean {
|
||||
export function useDelayedFlag(
|
||||
value: boolean,
|
||||
delayMs: number,
|
||||
hideDelayMs = 0,
|
||||
): boolean {
|
||||
const [visible, setVisible] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!value) {
|
||||
setVisible(false);
|
||||
return undefined;
|
||||
if (value) {
|
||||
const timer = setTimeout(() => setVisible(true), delayMs);
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
|
||||
const timer = setTimeout(() => setVisible(true), delayMs);
|
||||
const timer = setTimeout(() => setVisible(false), hideDelayMs);
|
||||
return () => clearTimeout(timer);
|
||||
}, [value, delayMs]);
|
||||
}, [value, delayMs, hideDelayMs]);
|
||||
|
||||
return visible;
|
||||
}
|
||||
|
||||
@@ -5969,7 +5969,9 @@ export function registerProjectWorkbenchFoundationTests() {
|
||||
expect(composerRule?.[1]).not.toBeUndefined();
|
||||
expect(composerRule?.[1]).toContain('position: relative;');
|
||||
expect(composerRule?.[1]).toContain('gap: 8px;');
|
||||
expect(composerRule?.[1]).toContain('z-index: 1;');
|
||||
// 输入盒必须高于列表里的「回到底部」胶囊(胶囊 z-10),盒内向上展开的模型 / 附件菜单
|
||||
// 才能盖住它;大小关系由 chatDialogFrameLayout.test.ts 的层叠用例守卫,这里钉字面值。
|
||||
expect(composerRule?.[1]).toContain('z-index: 30;');
|
||||
// 四边留白统一 16px、盒内内边距四边同为 12px(文字左内缩必须等于上内缩);
|
||||
// 不允许再出现 `8px 12px 10px` / `10px 12px` 这类「左右一个值、上下另一个值」的写法。
|
||||
expect(composerRule?.[1]).toContain('margin: 16px;');
|
||||
|
||||
@@ -22,6 +22,10 @@ const INPUT_SOURCE_PATH = repoPath(
|
||||
const CHAT_COMPOSER_CONTROLS_PATH = repoPath(
|
||||
'apps/ai-game-creator-shell/src/view/project-development/chat/components/DirectProjectComposer/ComposerControls.tsx',
|
||||
);
|
||||
/** 「回到底部」胶囊:z-index 写在 Tailwind 类上(`z-10`),styles.css 的层叠求值看不到。 */
|
||||
const CAPSULE_PATH = repoPath(
|
||||
'apps/ai-game-creator-shell/src/view/project-development/chat/components/DirectProjectConversation/DirectProjectScrollToBottomCapsule.tsx',
|
||||
);
|
||||
|
||||
function lengthPx(rawValue: string, label: string): number {
|
||||
const value = rawValue.trim();
|
||||
@@ -329,10 +333,33 @@ describe('陶泥儿对话区:Codex 三段式(顶栏 / 唯一滚动区 / 文
|
||||
);
|
||||
expect(declaration(composer, 'border-radius')).toBe('14px');
|
||||
|
||||
// 对话内容左右留白 16px,消息之间 14px。
|
||||
// 对话内容左右留白 16px。列表现在是 flex 列容器,`gap` 必须显式归 0:基础规则里那条
|
||||
// `gap: 14px` 在块级容器上一直没生效,改成 flex 后若不归零,会和子项的 `margin-top`
|
||||
// 叠成双倍间距;子项间距仍由 `> * + *` 的 margin-top 提供。
|
||||
expect(paddingBox(list).left).toBe(16);
|
||||
expect(paddingBox(list).right).toBe(16);
|
||||
expect(declaration(list, 'gap')).toBe('14px');
|
||||
expect(declaration(list, 'gap')).toBe('0');
|
||||
});
|
||||
|
||||
it('「回到底部」胶囊不盖住输入盒弹层:胶囊层叠低于输入盒(宽屏)', () => {
|
||||
// 胶囊是列表内的 sticky 浮标(Tailwind `z-10`),模型菜单(盒内 z-index: 20)与附件菜单
|
||||
// (盒内 z-index: 3)都从输入盒向上展开、会盖到列表底部。输入盒自己是 `position: relative`
|
||||
// + z-index 的层叠上下文,盒内菜单的 z-index 只在盒内有效,所以要让**盒子**高于胶囊,
|
||||
// 菜单才能盖住胶囊;否则重叠处命中的会是胶囊(真机 hit-test 实测:盒 z-index:1 时命中胶囊)。
|
||||
const capsuleClassName =
|
||||
/className="([^"]*)"/u.exec(readFileSync(CAPSULE_PATH, 'utf8'))?.[1] ??
|
||||
'';
|
||||
const capsuleZIndex = Number(/\bz-(\d+)\b/u.exec(capsuleClassName)?.[1]);
|
||||
const composer = desktopDeclarations(COMPOSER, COMPOSER_PLAIN);
|
||||
const composerZIndex = Number(declaration(composer, 'z-index'));
|
||||
expect(Number.isFinite(capsuleZIndex), '胶囊必须带 Tailwind z-* 类').toBe(
|
||||
true,
|
||||
);
|
||||
expect(
|
||||
Number.isFinite(composerZIndex),
|
||||
'输入盒必须声明 z-index 来建立层叠上下文',
|
||||
).toBe(true);
|
||||
expect(composerZIndex).toBeGreaterThan(capsuleZIndex);
|
||||
});
|
||||
|
||||
it('消息列表底部不留给浮层的空白,滚动到底不会多出一段空白(宽屏)', () => {
|
||||
|
||||
@@ -50,4 +50,19 @@ describe('窗口标题栏与全屏弹层的层叠约定', () => {
|
||||
`${selector} 必须声明 top: var(--window-chrome-height)`,
|
||||
).toBe('var(--window-chrome-height)');
|
||||
});
|
||||
|
||||
it('从「对话设置」打开的二级弹窗叠在设置浮层之上', () => {
|
||||
const zIndexOf = (selector: string) => {
|
||||
const raw = declarationsForSelector(css, selector).get('z-index');
|
||||
expect(raw, `${selector} 必须声明 z-index`).toBeDefined();
|
||||
return Number(raw);
|
||||
};
|
||||
const settingsZIndex = zIndexOf('.project-chat-settings-backdrop');
|
||||
const runtimeConfigZIndex = zIndexOf('.settings-overlay');
|
||||
const pluginPanelZIndex = zIndexOf('.agc-plugin-panel-overlay');
|
||||
// 对话设置里点「运行配置」会就地打开 `.settings-overlay`,插件面板又从
|
||||
// 运行配置里打开;三层必须严格递增,否则后开的会盖在上一层下面。
|
||||
expect(runtimeConfigZIndex).toBeGreaterThan(settingsZIndex);
|
||||
expect(pluginPanelZIndex).toBeGreaterThan(runtimeConfigZIndex);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -20,7 +20,8 @@ DirectProject 聊天区(`apps/ai-game-creator-shell/src/view/project-developme
|
||||
- 触发一(滚动):`scrollTop <= 24` 且 `historyHasMore` 且不在加载中且没有失败记录时自动加载。
|
||||
- 触发二(填充视口):首帧之后内容填不满视口(`scrollHeight <= clientHeight`)时继续加载,直到填满或 `hasMore=false`;不允许出现「历史比视口短、又没有按钮」的死局。
|
||||
- 两个触发都不越过既有的首屏订阅锚点 `lastCompletedItemId`;一次加载仍最多连拉 5 页(口径见 [`【ADR】DirectProject对话历史单一事实源-2026-09-16`](./【ADR】DirectProject对话历史单一事实源-2026-09-16.md))。
|
||||
- 加载中在列表最上方(比最旧一条回合更靠上)挂载一行 `role="status"`、`aria-live="polite"` 的「正在加载更早的对话」,带旋转圈;延迟 150ms 才显示,加载结束即卸载。它按需挂载,靠位置补偿(见第 2 条)保证下面的消息不跳。
|
||||
- 加载中在列表最上方(比最旧一条回合更靠上)挂载一行 `role="status"`、`aria-live="polite"` 的「正在加载更早的对话」,带旋转圈;延迟 150ms 才显示,加载态转假后再留 300ms 才卸载(隐藏滞回)。它按需挂载,靠位置补偿(见第 2 条)保证下面的消息不跳。
|
||||
- 隐藏滞回是必须的:填充视口的自动加载是连续翻页的(一次加载落地后下一帧又起一次),两次之间只有一个 effect 回流;立即卸载会把加载行一帧内拆了又挂。全是工具调用时更明显——工具组折在收起的 `<details>` 里,每页几乎不增加可见高度,视口一直填不满,加载行就在「页与页之间」忽隐忽现。300ms 的隐藏延迟让连续加载之间的小空隙不卸载,真正结束(超时后仍为假)照常卸载。
|
||||
- 失败:挂起自动加载,列表顶部保留一行内联错误行——`role="alert"` 只包住「加载更早对话失败」文案本身,重试是可聚焦按钮、留在 live region 之外(assertive + atomic 的 live region 里不放交互控件);**不自动重试**,只有点重试(或切换项目)才重新开始;重试成功后错误行消失。
|
||||
- 一次加载与它所属的**世代**绑定:切换项目或新起一次读取都推进世代号(`historyLoadTokenRef`),旧世代落地时整段失效——不合并条目、不写游标、不关加载态。只比项目路径不够:A→B→A 之后在飞的旧读取又落回同一个路径,原守卫放行,会把新一代的加载行与 `historyLoadingRef` 这道并发闸门一起改掉。换项目的推进放在**渲染期**(与 `projectPathRef` 同一处),不放在复位 effect 里:passive effect 走宏任务、promise 续体走微任务,旧读取可能在「切换提交完成、复位 effect 还没跑」的窗口里落地,那时世代号还是旧的,守卫会放行。
|
||||
|
||||
@@ -37,8 +38,9 @@ DirectProject 聊天区(`apps/ai-game-creator-shell/src/view/project-developme
|
||||
|
||||
### 3. 回到底部胶囊
|
||||
|
||||
- 列表底部居中的悬浮胶囊(`sticky`),跟着滚动容器走、不随内容滚走。
|
||||
- 列表底部居中的悬浮胶囊,**位置基于列表可见底边**:列表是 flex 列容器,胶囊是最后一个 flex 项,`margin-top: auto` 在内容不足一屏时把它顶到可见底边,内容溢出一屏时 auto 归零、再由 `sticky bottom-3` 上拉贴底。只用 `sticky` 不够——它只能把元素**上拉**、不能下推,内容不足一屏时胶囊会停在文档流里(悬空),所以必须由 flex 的 auto margin 兜住「不足一屏」这一半。
|
||||
- 距底部超过 48px 时出现,文案「回到底部」;用户不跟随时来了新的终态内容就改成「有新回复 · 回到底部」。
|
||||
- 贴底判定不能只认 `onScroll`:内容**变短**到一屏以内时不会再有任何滚动事件(回合收口把过程折进收起的 `<details>`、历史加载行卸载),`atBottom` 会停在离开底部时的假值,胶囊就永远挂在一个滚不动的列表上(短历史一屏显示完却还悬着「回到底部」)。因此每次布局变化(`ResizeObserver`)、子元素增删(`MutationObserver`)与内容变化后都按真实几何复核一次 `isNearBottom`,复核为真即收起胶囊并清掉「有新回复」。
|
||||
- 点击:平滑滚到底部 + 恢复跟随最新 + 清除「有新回复」,随后按钮自行消失。
|
||||
- 平滑滚动期间滚动位置归这次程序化滚动所有:滚动事件不再翻转「跟随最新」,布局补偿也不写 `scrollTop`(写一次就会取消动画并把画面拉回原处,表现为「点了只下去一屏、到不了底」)。滚到贴底阈值即交还控制权;用户中途用滚轮 / 触摸 / 键盘打断则立刻交还,不会卡住后续跟随。
|
||||
- 动画期间内容变高(流式正文、图片撑开)时,点击瞬间记下的 `scrollHeight` 已经不是底部:补偿不写 `scrollTop`,而是把动画目标重新对准新的底部。否则动画停在旧目标上、等不到「贴底」那次滚动事件,`programmaticScrollRef` 不会交还——跟随与布局补偿整段挂起,而 `scrollToBottom` 已把胶囊按「已贴底」隐掉,用户停在底部之上却没有任何指示与自动跟随。
|
||||
@@ -67,7 +69,7 @@ DirectProject 聊天区(`apps/ai-game-creator-shell/src/view/project-developme
|
||||
## 备选方案与取舍
|
||||
|
||||
1. **保留按钮 + 只加自动加载**:加载中仍靠按钮做唯一反馈,且删掉按钮后失败路径没有补救入口;按钮本身与滚动自动加载重复。
|
||||
2. **在列表外面套一层 viewport 做浮层定位**:`PlanningChatView` 共用同一套容器规则,且工作台里 `.project-chat-conversation` 是 `display: block` + `height: 100%` 几何,套一层就会让 `height: 100%` 的列表塌成内容高度;改公共类会连带策划对话。改用列表内的 `sticky` 胶囊,零结构改动。
|
||||
2. **在列表外面套一层 viewport 做浮层定位**:`PlanningChatView` 共用同一套容器规则,且工作台里 `.project-chat-surface.is-direct-codex > .project-chat-conversation` 是纵向 flex、`.project-chat-message-list` 是其中 `flex: 1 1 auto` 的唯一滚动项,套一层定位容器会让列表塌成内容高度;改公共类会连带策划对话。改用列表内的 flex 项(`margin-top: auto` + `sticky`),零结构改动。
|
||||
3. **只依赖原生 CSS scroll anchoring**:前插能免费对齐,但做不到「跟随时展开要贴底」,也无法在加载期间冻结同一套锚点;因此显式补偿 + 关闭原生锚定。
|
||||
4. **展开后总是把正文滚进视口**:对正文比视口矮的折叠块会把画面大幅上移,打断正在读历史的用户;采用「跟随时贴底 / 否则冻结折叠头 + 只滚到刚好露出新展开正文的最小位移」。
|
||||
5. **平滑滚动期间照常处理滚动事件与布局补偿**:程序化滚动会被应用自己的补偿打断(第一次写 `scrollTop` 即取消动画),用户点了「回到底部」也停在半路;因此改为滚动期间冻结这两条路径。
|
||||
@@ -80,6 +82,6 @@ DirectProject 聊天区(`apps/ai-game-creator-shell/src/view/project-developme
|
||||
|
||||
- 历史加载失败不再只写顶部状态行,而是落到列表里的内联错误行;顶部状态行仍保留首屏读取失败等其它用途。
|
||||
- 滚动是表现层行为,正式状态仍在后端投影与运行态事件;本 ADR 不新增领域概念。
|
||||
- 阈值(触顶 24px、贴底 48px、spinner 150ms)是可按手感调整的常量,集中放在 `components/DirectProjectConversation/conversationScrollPolicy.ts`。
|
||||
- 阈值(触顶 24px、贴底 48px、spinner 显示 150ms / 隐藏滞回 300ms)是可按手感调整的常量,集中放在 `components/DirectProjectConversation/conversationScrollPolicy.ts`。
|
||||
- 验收:纯函数与 jsdom 组件测试覆盖阈值、锚点还原(含收口后按块身份仍指向同一块、锚点块被折叠隐藏时放弃还原)、加载/错误行、胶囊文案与显隐;滚动观感(顶部加载圈、胶囊出现与消失、底部展开回贴、历史前插不跳、长回合收口时视口不跳、切项目后首屏贴底且不误报「有新回复」)必须真机手动验收——jsdom 没有布局。
|
||||
- 明确的后续项(不在本次范围):`PlanningChatView` 与 `App.tsx` 遗留 `message-history-more` 路径的同款改造、未读条数徽标、Playwright 端到端。
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
# 【里程碑】AGC命令沙箱Node版本管理器支持-2026-10-07
|
||||
|
||||
| 字段 | 值 |
|
||||
| ----------- | ------------------------------------------------------------------- |
|
||||
| Version | 1.0 |
|
||||
| Status | implemented-awaiting-runtime-acceptance |
|
||||
| Date | 2026-10-07 |
|
||||
| Parent Spec | `docs/technical/【技术方案】AI游戏创作Agent Runtime V1.1-2026-07-12.md` |
|
||||
|
||||
## 目标
|
||||
|
||||
开发构建的 Linux 命令沙箱能原生解析并复用宿主 fnm / nvm 托管的 Node 安装:宿主发现与沙箱只读挂载使用同一套窄叶校验;`.nvmrc` / `.node-version` pin 权威、`engines.node` 仅为偏好;npm 以受信任 `node <npm-cli.js>` 形态启动且不丢失 `npm install` 的联网判定。不再要求用户改系统 Node、把宿主 shim 指向 `/usr/bin`,或把托管目录软链进系统路径。
|
||||
|
||||
## 范围
|
||||
|
||||
- 移除把 `node` / `npm` / `npx` 指向 `/usr/bin/*` 的宿主 shim 依赖,开发构建默认解析托管安装。
|
||||
- 枚举 fnm `node-versions/<version>/installation`(含 `aliases/default`)与 nvm `versions/node/<version>`,按窄叶规则校验完整安装前缀。
|
||||
- 版本选择:`.nvmrc` / `.node-version` pin > PATH 可解析的可用 Node > 版本管理器回退链(`engines.node` 最高匹配 > 活动版本 > 默认别名 > 已安装最高版本)。
|
||||
- Linux 命令沙箱把通过校验的完整安装前缀只读挂载,并以 `node <npm-cli.js>` 启动 npm;`npm install` 保持联网判定。
|
||||
- 沙箱内联环境不继承 fnm multishell 等临时版本管理器变量。
|
||||
|
||||
## 不在范围内
|
||||
|
||||
- 不把 `fnm` / `nvm` CLI 本身做成沙箱内可用工具。
|
||||
- 不改变发布构建的 bundle / 系统 Node 解析策略。
|
||||
- 不改变 Windows 的 npm.cmd 处理与 V1.10 固定 program 口径。
|
||||
- 不引入 fnm / nvm 之外的版本管理器。
|
||||
- 不为了兼容挂载整个用户 HOME、`FNM_DIR` / `NVM_DIR` 根或 `aliases` 目录。
|
||||
|
||||
## 依赖与前置条件
|
||||
|
||||
- 主机已安装 fnm 或 nvm,且至少有一个 Node 22 安装;项目按 `@types/node ^22.14` 面向 Node 22。
|
||||
- Linux bubblewrap 可用;测试主机需能创建 user / mount / pid namespace。
|
||||
|
||||
## 验收标准
|
||||
|
||||
- [x] 开发构建在没有 `/usr/bin` shim 的情况下从 fnm / nvm 找到 Node,并在沙箱内运行出真实 `node --version` 与 `npm --version`。
|
||||
- [x] `.nvmrc` / `.node-version` 指定的版本未安装时命令失败关闭,且不回退到其它已安装版本;`engines.node` 不匹配时不阻塞。
|
||||
- [x] 不支持的 pin 写法(如 `iojs`、`>20`、`<=20`)按未 pin 处理并回退,而不是误判为「已理解但未命中」。
|
||||
- [x] 只有通过窄叶校验的完整安装前缀会被只读挂载;HOME、管理器根、`aliases`、宽泛目录、不完整前缀和逃逸 symlink 全部失败关闭。
|
||||
- [x] npm 在 Linux 上以受信任 `node <npm-cli.js> ...` 启动,`npm install` 仍被判定为联网命令,普通 `npm run` 仍离线。
|
||||
- [x] 宿主发现与沙箱挂载共用同一套窄叶校验,不存在第二份信任口径。
|
||||
- [x] 真实 nvm(v0.40.8 + Node v22.23.3)安装前缀的端到端沙箱运行,以及在仅 nvm 环境(`NVM_DIR` + 空 PATH + 临时 HOME)下的托管解析;同时修正 nvm `alias/default` 只写主版本号(如 `22`)时被当作 `(22,0,0)` 而匹配不到已安装补丁版本的问题。
|
||||
|
||||
## 证据要求
|
||||
|
||||
- 自动化:
|
||||
- `cargo test --locked -p genarrative-ai-game-creator-shell --bin genarrative-ai-game-creator-shell -- environment_check:: --test-threads=1`
|
||||
- `cargo test --locked -p genarrative-ai-game-creator-shell --bin genarrative-ai-game-creator-shell -- command_sandbox:: command_exec:: process_session:: --test-threads=1`
|
||||
- 可选真机:`GENARRATIVE_COMMAND_SANDBOX_REAL_TEST=1 cargo test … -- command_sandbox_real_linux_opt_in_runs_host_node_and_npm_cli`
|
||||
- 可选真机 nvm:`NVM_DIR=<nvm 目录> GENARRATIVE_COMMAND_SANDBOX_REAL_TEST=1 cargo test … -- command_sandbox_real_linux_opt_in_runs_nvm_installation_prefix`;仅 nvm 解析:`env -i HOME=<临时家目录> NVM_DIR=<nvm 目录> PATH=/nonexistent GENARRATIVE_COMMAND_SANDBOX_REAL_TEST=1 <测试二进制> --exact environment_check::tests::real_node_npm_environment_versions --ignored`
|
||||
- `cargo fmt --check`、`npm run check:encoding`、`git diff --check`。
|
||||
- 运行时:真实 fnm v22 前缀下,bwrap 内 `node -e 'process.stdout.write(process.version)'` 与 `node <npm-cli.js> --version` 均成功且版本为 v22。
|
||||
- 边界:`.nvmrc` pin 未安装、`engines.node` 不匹配、不支持 pin、宽泛 / 不完整前缀、逃逸 symlink、`npm run` 离线与 `npm install` 联网。
|
||||
@@ -1,5 +1,16 @@
|
||||
# 决策记录
|
||||
|
||||
## 2026-10-07 AGC 命令沙箱原生支持 fnm/nvm:只读挂载窄叶安装前缀,npm 改走 node + npm-cli.js
|
||||
|
||||
- 背景:开发构建里 AGC 让命令沙箱执行 `npm run build` / `npm install` 时,宿主 Node 由 fnm 托管,`node` / `npm` 实际是随 shell 会话变化的 fnm multishell 目录里的 shim;bwrap `--tmpfs /run` 会抹掉该路径,而只按单文件挂载 `<前缀>/bin/npm`(它软链到 `lib/node_modules/npm/bin/npm-cli.js`)会因 `Cannot find module '../lib/cli.js'` 失败。此前把宿主 `node` / `npm` / `npx` shim 指到 `/usr/bin/*` 是错误取舍:系统 Node 26 默认启用实验性 Web Storage,会顶掉 vitest 0.34 jsdom 的 localStorage,使 AGC 测试套件在 HEAD 即失败(见 `pitfalls.md` 2026-10-03 条)。
|
||||
- 决策(宿主发现与沙箱挂载共用窄叶校验):新增 `validate_node_installation_prefix`,canonicalize 后拒绝 `/home`、`/root`、`/tmp`、`/var`、`/etc`、`/proc`、`/dev`、`/run`、`/sys`、`/boot`、`/srv` 根、HOME 及其祖先和相对路径,并要求前缀同时含 `bin/node` 与 `lib/node_modules/npm/bin/npm-cli.js`(bundle 形态为 `<前缀>/node` + `node_modules/npm/bin/npm-cli.js`)。宿主版本枚举与 Linux 沙箱只读挂载都调用它,避免两处信任口径漂移。
|
||||
- 决策(版本选择):`.nvmrc` / `.node-version` 是权威 pin,能理解但未安装时返回 `node-version-pinned-not-installed` 失败关闭;`package.json` `engines.node` 只是偏好,永不阻塞;不支持的写法(`iojs`、`||`、部分 `>` / `<=`、hyphen range、prerelease)按未 pin 回退。整体顺序为 pin 命中 > PATH 可解析的可用 Node > 版本管理器回退链(`engines` 最高匹配 > 活动版本 > 默认别名 > 已安装最高版本),只实现文档化比较子集(精确三元组、major、`>=` / `>` / `<=` / `<`、`^`、`~`、`x` / `*`、`lts/*`);`v22.23.3` 这类带 `v` 的 `.nvmrc` 必须先剥前缀。nvm 的 `alias/default` 常只写主版本号(如 `22`),必须按同一 pin 子集在已安装版本里选最高匹配,不能当成完整三元组 `(22,0,0)`。
|
||||
- 决策(沙箱内启动形态):Linux npm 改为 `node <npm-cli.js>`,因为单文件挂载 npm 软链必然丢 `../lib/cli.js`;只读挂载整棵已验证的安装前缀(不是整个 HOME、`FNM_DIR` 或 `NVM_DIR`)。`npm install` 的联网判定跟随真实启动形态(`node` + `npm-cli.js` + `install`),不因包装变化丢 `--share-net`。
|
||||
- 决策(范围与非目标):托管版本管理器发现只在 `debug_assertions` / development 生效,发布构建继续只认随包 bundle;不把 fnm / nvm CLI 做成沙箱内工具;Windows 不变;不新增 fnm / nvm 之外的版本管理器。
|
||||
- 影响范围:`apps/ai-game-creator-shell/src-tauri/src/environment_check.rs`(窄叶校验、托管安装枚举、pin / engines 解析、选择与回退)、`command_sandbox.rs`(Node 工具链挂载收集与合并、`command_sandbox_requests_npm_install`、`FNM_MULTISHELL_PATH` 清理)、`command_exec.rs`(Linux npm `node_launcher`、`project_command_actual_target`、非 Node 程序 PATH 前置工具链 bin)、`process_session_bridge.rs`(`ProcessSessionLaunchPlan::from_launch` 改用实际目标)。
|
||||
- 验证方式:`environment_check` 24 passed、`command_sandbox` 14 passed、`command_exec` 19 passed、`process_session` 27 passed(均 `--test-threads=1`);`GENARRATIVE_COMMAND_SANDBOX_REAL_TEST=1` 真机 bwrap 内 fnm v22.23.3 的 `node --version` 与 npm 10.9.9 通过;另装 nvm v0.40.8 + Node v22.23.3,`command_sandbox_real_linux_opt_in_runs_nvm_installation_prefix` 证明真实 nvm 前缀可在 bwrap 内跑 node / npm,`real_node_npm_environment_versions`(仅 `NVM_DIR` + 空 PATH + 临时 HOME)证明托管解析确实选中 nvm;`cargo fmt --check`、`npm run check:encoding`、`git diff --check` 通过。验证后 fnm 仍是宿主默认,nvm 未写入任何 shell profile。
|
||||
- 边界:nvm 已在验证主机安装(v0.40.8 + Node v22.23.3)并跑通真实前缀与仅 nvm 解析;CI 仍由临时目录夹具覆盖 fnm / nvm 布局,真实安装路径测试保持 opt-in。真实验收前不宣称发布构建也支持 fnm / nvm。
|
||||
|
||||
## 2026-10-06 小红书导出 validate/pack:Chrome 61 能力按硬性 ERROR 拦下,pack 自带白名单不再共享
|
||||
|
||||
- 背景:真实项目适配反馈。① `validate.mjs` 的 `/#[A-Za-z_$][\w$]*/g`(class 私有字段)直接在原始文本上匹配,把 `"#e8f4ff"`、`document.querySelector('#hit')` 误判为 ES2018 语法并报 ERROR,把排查引向「构建链没转译」。② `CSS_MODERN_PATTERNS` / `MODERN_RUNTIME_API` 告警被移除后,`flex gap`(Chrome 84+)、`min()/max()/clamp()`(Chrome 79+)等晚于 Chrome 61 的写法被静默忽略却无人拦截,`references/manual-checks.md` 只有人读清单没有工具兜底。③ 宿主在**项目根** `.export/xhs-minitool.zip` 找产物,而 `--zip-out` 相对 cwd 解析;npm 脚本挂在 `game/` 子工程时 `.export/...` 会落到 `game/.export/`,没有任何提示。④ `pack.mjs` 直接 `import './validate.mjs'`,只复制 pack.mjs 会 `ERR_MODULE_NOT_FOUND`。⑤ `validate.mjs` 的 `[project]` 默认值只在源码 USAGE 里,SKILL 参数表没写默认值。
|
||||
|
||||
@@ -10,6 +10,23 @@
|
||||
- **taonier zip 必须存档根 `index.html`**:`.export/taonier.zip` 的存档根必须直接有 `index.html`,不能套外层文件夹(`taonier/index.html`、`game/index.html` 都算失败)。`vite-export-taonier/scripts/pack.mjs` 在落盘前按根入口校验并拒绝;即使绕过,服务端发行合同只认根 `index.html`,会以 `ReleasePackageError::MissingEntry` 映射 422 `PACKAGE_VALIDATION_FAILED` 拒收。适配非标准工程布局时,让 `build:taonier` 把 `--vite-built-dir` 指到真正含 `index.html` 的目录,或让 `pack.mjs` 不套外层;客户端 `run_taonier_export_build` 也在产物收尾复核根入口。
|
||||
- **作者预览未发布作品必须走 owner 媒体读,不能再回退素材库 ACL**:发布媒体是项目快照桶对象、不建 `asset_object`,`/api/assets/read-url` 不会给它授权;公开读路由 `GET /api/game-distribution/media/read-url` 只认「已发布且 active」,两条都不覆盖"作者看自己未发布/被驳回作品"。症状是作者中心与 AGC 线上值封面/截图静默退化成占位图(换签失败被吞成空地址),不报错、不阻断。现行口径:作者侧一律走 `GET /api/game-distribution/my-games/{gameId}/media/read-url`(`read-bytes` 同理,需 bearer),判定 = 作品归属 + objectKey 命中该作品当前行的 `cover_object_key` / `screenshots_json`;AGC `resolve_preview_url` 与平台 web 的 `useGameDistributionMediaReadUrl({ gameId })`、`resolveGamePublishImagePreview(objectKey, gameId)` 都已切换。新增读路径时先确认它属于公开面还是 owner 面,别再让作者预览落到公开判定上。
|
||||
|
||||
## 2026-10-07 fnm/nvm 托管的 Node 进 bwrap:单文件挂载 npm 必失败,`--tmpfs /run` 会抹掉 multishell PATH
|
||||
|
||||
- **现象**:开发构建在 Linux 命令沙箱里执行 `npm run build` / `npm install` 时,宿主 shell 里明明能跑通的 fnm Node,进沙箱后报 `Node.js v26.10.0` 与 `Cannot find module '../lib/cli.js'`,或 npm 命令在路径解析阶段就失败。
|
||||
- **根因 1(单文件挂载软链前缀)**:fnm / nvm 的 `<前缀>/bin/npm` 是指向 `<前缀>/lib/node_modules/npm/bin/npm-cli.js` 的软链。bwrap `--ro-bind <前缀>/bin/npm <前缀>/bin/npm` 只挂载这一个文件,`npm-cli.js` 里的 `require('../lib/cli.js')` 找不到同安装内的相对目标,于是报错;必须整棵只读挂载通过窄叶校验的完整安装前缀(含 `bin/node`、`lib/node_modules/npm`),不能只挂 shim 或 `bin/`。
|
||||
- **根因 2(`--tmpfs /run` 抹掉活动版本)**:fnm 的活动 `PATH` 项是 `/run/user/<uid>/fnm_multishells/<pid>/bin`;sandbox 的 `--tmpfs /run` 会清空该目录,sandbox 内解析到的 `node` 随之失效或退回系统版本。不要依赖宿主 `PATH` 原样进入沙箱:canonicalize 路径,并把活动版本管理器变量(如 `FNM_MULTISHELL_PATH`)从 sandbox 环境里剔除。
|
||||
- **根因 3(错误取舍会打穿测试)**:把宿主 `node` / `npm` / `npx` shim 指到 `/usr/bin/*` 能让沙箱借用系统 Node,但在本机系统 Node 26 上会默认启用实验性 Web Storage,顶掉 vitest 0.34 jsdom 的 localStorage,AGC 测试在 HEAD 即红(见下方 2026-10-03「AGC 测试不在任何 tsconfig 里」条的环境提示)。正确方向是原生支持托管安装,而不是改宿主 shim。
|
||||
- **补充(nvm default 别名是主版本号)**:`nvm alias default 22` 写进 `$NVM_DIR/alias/default` 的内容是 `22`,不是完整三元组。若按精确 `(22,0,0)` 去匹配 `versions/node/v22.23.3` 会永远落空,默认别名形同不存在;必须用与 `.nvmrc` 相同的比较子集在已安装版本里选最高匹配。
|
||||
- **现行口径**:宿主发现与沙箱挂载共用 `validate_node_installation_prefix`;`.nvmrc` / `.node-version` 权威、`engines.node` 偏好;Linux npm 以 `node <npm-cli.js>` 启动并保留 `npm install` 联网判定。契约见技术方案 V1.11.2。
|
||||
- **验证**:`GENARRATIVE_COMMAND_SANDBOX_REAL_TEST=1` 跑 `command_sandbox_real_linux_opt_in_runs_host_node_and_npm_cli`,在真实 fnm v22 前缀下 bwrap 内 `node --version` 与 `node <npm-cli.js> --version` 均通过;单元用例覆盖 pin / engines / 不支持写法 / 宽叶与逃逸前缀 / 联网判定。
|
||||
|
||||
## 2026-10-07 cargo 目标目录里被"刷新 mtime"的陈旧 shared-contracts 会让编译报源文件里明明存在的字段缺失
|
||||
|
||||
- **现象**:`cargo check` 在 AGC shell 上报 `unresolved import shared_contracts::runtime::ProfileMembershipUpgradeQuoteResponse`、`no field project_version / publication`、`LlmModelsResponse: Deserialize` 等一整组「契约落后」错误;但 `server-rs/crates/shared-contracts/src` 里这些符号确实存在,`git status` 干净,刚重建的 rlib 也含符号。
|
||||
- **根因**:`target/debug/deps` 里留着早先构建的 `libshared_contracts-<hash>.rmeta`,其 `.d` 依赖文件停留在旧时间戳,而 `.rmeta` 的 mtime 在快照 / 拷贝过程中被刷新成新时间;cargo 按 mtime 判定该 crate 仍然新鲜,于是把 `--extern shared_contracts=` 指到旧 rmeta,下游就看到旧 API。多份不同 feature 组合的 `shared-contracts-<hash>` 并存时更容易踩中。
|
||||
- **处理**:删除该 crate 的全部指纹与产物后重编即可,不必清整棵 target:`rm -rf target/debug/.fingerprint/shared-contracts-* target/debug/deps/*shared_contracts*`,再跑 `cargo check`。判断依据是错误集中在某个 `shared-contracts` API,而源文件与 `git status` 都正常;先用 `cargo check -v 2>&1 | grep -m1 -- '--extern shared_contracts='` 找到实际使用的 rmeta,再核对它同名 `.d` 里的源文件路径与时间戳。
|
||||
- **边界**:这是构建缓存 / 快照产物问题,不是契约真源问题;不要因为这类报错去改 `shared-contracts` 或回退下游代码。
|
||||
|
||||
## 2026-10-05 PR #607 复核修复:档位点对齐/对比度、状态文案也走浮层、键盘去重、卸载 flush
|
||||
|
||||
- **档位圆点已删除(D1 的收口)**:这一轮把档位圆点**整体删除**(半透明备选方案未采用)。现在滑块只剩轨道 + 圆钮:轨道 6px 圆头、已选段 `--platform-accent` 由 `--strength-ratio` 驱动、**终点落在圆钮中心**(`calc(10px + ratio * (100% - 20px))`)、圆钮 20px 实心暖白(`--platform-panel-fill` + `--platform-subpanel-border` 1px 描边 + `color-mix` 柔影);强度区横向内边距 `4px 6px 2px` → `4px 0 2px`(滑块铺满卡片内容宽度,填充段与圆钮两端与轨道两端贴齐);相关 CSS(`space-between` 排布 / `z-index: 2` 抬层 / `.is-active{opacity:0}` / 点的 `color-mix` 底色 / 只为点对齐的 `padding: 0 7px`)与渲染标记一并删除。**判据**:`chatDialogFrameLayout.test.ts` 反向守卫(样式表里不再有 `.project-chat-composer-strength-stops` 规则、组件源码不再渲染该类名;滑块契约仍在:宽 100% / 高 26 / 圆钮 20×20、强度区左右内边距 0)+ `home.suite.ts` 首页菜单里查不到那组点。实测(447 视口,像素扫描):轨道 90..324(宽 234 = 卡片内容宽),档位 0 时圆钮左缘 90.5(距轨道左端 0.5px)、档位 4 时圆钮右缘 322.3(距右端 1.8px,扫描行不在圆钮正中所以略窄),填充段终点落在**圆钮中心**(`calc(10px + ratio * (100% - 20px))`,被圆钮盖住),因此圆钮右侧不会露出橙色(终点曾写成 `20px + …` = 圆钮右缘,4× 设备像素下能看到一小截溢出)。**历史成因(只留一句,细节由 Git 追溯)**:圆点此前被 6px 轨道盖住、且与圆钮两端错位 ±12.2px,曾用「抬到轨道之上 + space-between 对齐 + 浅暖色」修过一轮,最终整体删除。
|
||||
|
||||
@@ -616,6 +616,16 @@ Runner-kill E2E 不再以 latest task 或单个 process record 推断整体恢
|
||||
|
||||
`command.poll` 私有正文虽然必须进入 owning Agent context 供后续交互,但模型 prompt 不是持久化隔离边界。后台 finalization 在创建 assistant journal 前检查当前 run 的成功 poll observation;只要存在非空输出,就把模型最终回复整体收束为固定安全完成摘要,再计算 response fingerprint 并写 conversation/event/Agent DB。该边界不按长度猜测 token,因此 challenge、ready/echo/stopped 行和短 PIN 的局部回显都不能扩大到公共持久面;没有私有 poll 正文的普通回复保持原样。
|
||||
|
||||
### V1.11.2 命令沙箱 Node 版本管理器支持
|
||||
|
||||
开发构建(`debug_assertions`)下,AGC 生成和验证 Web 项目所用的 Node 往往由 fnm / nvm 等版本管理器托管:安装前缀位于用户目录下,活动 `PATH` 指向随 shell 会话变化的临时目录(如 fnm 的 multishell)。V1.11.2 让开发构建的命令沙箱原生解析并只读复用这些托管安装,不再要求用户改系统 Node、把宿主 shim 指到 `/usr/bin`,或把托管目录软链进系统路径。发布构建继续只认随包 bundle,不新增托管版本管理器探测,也不改变 bundle 缺失时的失败口径;Windows 行为不变。
|
||||
|
||||
- 版本来源是机器上可枚举的托管安装:fnm 的 `node-versions/<version>/installation`(含 `aliases/default` 指向的默认别名)与 nvm 的 `versions/node/<version>`。宿主发现和沙箱只读挂载共用同一套窄叶校验,不允许两处信任口径漂移。
|
||||
- 解析优先级为:`.nvmrc` / `.node-version` 的权威 pin 命中 > 宿主 `PATH` 能解析出的可用 Node > 版本管理器回退链(`package.json` `engines.node` 偏好中的最高匹配 > 当前活动版本 > 默认别名 > 已安装最高版本)。`.nvmrc` / `.node-version` 能理解但未安装时必须失败关闭(`node-version-pinned-not-installed`),不得静默回退;`engines.node` 只是偏好,任何情况下都不阻塞。只实现文档化的比较子集(精确三元组、major、`>=` / `>` / `<=` / `<`、`^`、`~`、`x` / `*` 通配、`lts/*`);不支持或无法解析的写法按「未 pin」处理并回退。
|
||||
- 只读挂载只允许通过窄叶校验的完整安装前缀(同时含 `bin/node` 与 npm 的 `npm-cli.js`)。HOME、`FNM_DIR` / `NVM_DIR` 根、`aliases` 目录、宽泛用户目录、不完整前缀,以及 canonicalize 后逃逸出受控前缀的 symlink 全部拒绝并失败关闭;不得为了兼容而挂载整个用户 HOME 或版本管理器数据目录。
|
||||
- Linux 上 npm 不再直接执行 npm shim,而是以受信任的 `node <npm-cli.js> ...` 启动;`npm install` 的联网判定必须跟随这条真实启动形态,不能因为包装方式变化而丢失联网或反向放开。
|
||||
- 沙箱内联环境不继承宿主活动版本管理器的临时变量(如 fnm multishell 路径);版本管理器 CLI(`fnm` / `nvm`)本身不需要在沙箱内可用。
|
||||
|
||||
## V1.12 受控本地 Git 提交
|
||||
|
||||
V1.12 首个切片补齐“修改、验证、审阅、提交”的单 Agent 本地闭环,只新增 `project.git_commit`。它不是通用 Git 写权限:不开放 `push / fetch / pull`、分支创建或切换、merge / rebase、reset、stash、tag、submodule、worktree,也不能通过 `command.exec` 绕过 `.git` 只读沙箱。
|
||||
|
||||
@@ -865,6 +865,8 @@ Agent 可见的系统指令、工具与参数说明、恢复指引和上下文
|
||||
|
||||
2026-07-14 V1.11.1 最终真实验收:发布 AppData 的真实 `gpt-5.5` `process-session` 形成 41 条 task、75 条 event、63 条 Agent DB 和 8 条 receipt,唯一 start、3 poll、唯一 stdin / terminate、3 次 cursor 推进及唯一 terminal / completed / assistant全部通过;Runner kill套件形成 13 条 task、19 条 event、19 条 Agent DB,真实 SIGKILL后项目 cwd进程清零、新 boot保持同 run / session并只形成 1 条 reconciliation。两套的 reconnect、重放、重复 action / message / receipt、公共进程正文、密钥和诱饵泄漏均为 0,disposable项目均自动清理;V1.11.1 持久进程链路据此完成验收。
|
||||
|
||||
2026-10-07 V1.11.2 切片:Linux 命令沙箱原生支持 fnm / nvm 托管的 Node。开发构建(`debug_assertions`)先从 `.nvmrc` / `.node-version` 的权威 pin、再按 `package.json` `engines.node` 偏好、活动版本、版本管理器默认别名和已安装最高版本选择托管安装;`.nvmrc` / `.node-version` 能理解但未安装时失败关闭,`engines.node` 和无法解析的写法不阻塞。宿主发现与沙箱只读挂载共用同一窄叶校验,只挂载完整安装前缀(含 `bin/node` 与 npm 的 `npm-cli.js`),拒绝 HOME、管理器根、`aliases`、宽泛目录、不完整前缀和逃逸 symlink。Linux npm 以 `node <npm-cli.js>` 启动,`npm install` 的联网判定跟随该真实形态;sandbox 环境剔除 fnm multishell 变量。发布构建仍只认随包 bundle,Windows 不变。定向 Rust 测试与真实 bwrap 内 fnm v22 的 `node` / npm 运行已通过;真实 nvm 端到端待补。
|
||||
|
||||
2026-07-14 起,同一文档的“V1.12 受控本地 Git 提交”补齐单 Agent 的修改、验证、审阅、本地提交闭环。新增且只新增 `project.git_commit`,输入绑定 `message / paths / expectedHead / expectedSnapshotFingerprint`,最多提交 12 个显式安全路径;它是不可降为 `auto` 的强制确认工具,legacy 空策略也继续要求确认,项目策略仍可显式拒绝。动态隔离 child 无条件禁用该工具,最终提交由父 Agent 统一发起。当前 run 必须在当前非零 project revision 上已有 passed verification gate。`git.inspect` 签发的 `commitSnapshotFingerprint` 绑定 HEAD、附着分支、规范化安全状态和全部安全变更文件内容;`.agent` 等控制面正常落盘不制造跨动作漂移,安全源码、HEAD、分支、revision 或 gate 任一变化仍失败关闭。提交只支持标准仓库根和本地附着分支,要求真实 index 没有 staged 内容,并用临时 index、真实 `index.lock`、`commit-tree` 和带 expected old HEAD 的 `update-ref HEAD` 精确前移 ref,同步 HEAD / branch reflog后跨平台原子安装 index;未选改动保持未暂存。它不开放 remote、分支切换、merge / rebase、reset、stash、tag、submodule 或 worktree 写操作,也不能通过 `command.exec` 绕过 `.git` 只读沙箱。成功 observation、Agent DB 审计和 terminal receipt 只保留 parent / commit SHA、分支、安全路径、message SHA-256 和剩余变更计数;ref 前移后的不确定错误或审计失败进入 `needs-reconciliation`,已知 commit 的审计失败仍在 fallback receipt 保存 commit SHA,恢复不得重放提交。
|
||||
|
||||
2026-07-14 V1.12 真实 `gpt-5.5` 验收已通过。现有 `llm-runtime` disposable 套件要求模型在完整修改、验证和审阅链路末尾自行创建唯一受控提交,并从原始 commit object、真实 Git parent / HEAD / tree、空 staged index、提交后所选路径状态、封闭字段专用审计、terminal receipt 和 HEAD / branch 双 reflog 验真。最终收紧版形成 151 条 task、258 条 event、266 条 Agent DB、17 次代表性成功工具执行、7 套确认生命周期、9 个实际副作用 action 和 44 条 receipt;唯一提交精确包含 2 个目标路径,预存 sentinel 未被夹带,project revision 保持 3。Runner 强杀恢复后 run / session 身份稳定,副作用重放、重复 action / message / receipt、密钥和诱饵泄漏均为 0,disposable 项目已自动清理。
|
||||
|
||||
Reference in New Issue
Block a user