From d905fa1c023a906bc0f87022393100a900c91e6e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Wed, 7 Oct 2026 11:43:00 +0800 Subject: [PATCH 01/13] =?UTF-8?q?=E6=8C=89=E6=9C=AA=20pin=20=E5=9B=9E?= =?UTF-8?q?=E9=80=80=20lts/codename=20=E9=81=BF=E5=85=8D=E9=9D=99=E9=BB=98?= =?UTF-8?q?=E9=80=89=E4=B8=AD=E9=94=99=E8=AF=AF=E7=89=88=E6=9C=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - version_matches_pin 对 lts/(如 lts/iron)返回 None,不再当作匹配任意已安装版本 - 补充单测:lts/iron 回退、lts/* 仍匹配,并加入不支持 pin 的回退用例 - decision-log 记录 lts/ 需要 codename → 版本行映射,当前按未 pin 回退 --- .../src-tauri/src/environment_check.rs | 15 ++++++++++----- docs/project-memory/shared-memory/decision-log.md | 2 +- 2 files changed, 11 insertions(+), 6 deletions(-) diff --git a/apps/ai-game-creator-shell/src-tauri/src/environment_check.rs b/apps/ai-game-creator-shell/src-tauri/src/environment_check.rs index 52fe1dbb1..72d2284ed 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/environment_check.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/environment_check.rs @@ -562,11 +562,14 @@ fn version_matches_pin(version: (u64, u64, u64), pin: &str) -> Option { // `.nvmrc` 两种写法都常见:`v22.23.3` 与 `22.23.3`。 let pin = pin.strip_prefix('v').unwrap_or(pin); let lower = pin.to_ascii_lowercase(); - if matches!(lower.as_str(), "*" | "x" | "node" | "latest" | "lts/*") - || lower.starts_with("lts/") - { + if matches!(lower.as_str(), "*" | "x" | "node" | "latest" | "lts/*") { return Some(true); } + if lower.starts_with("lts/") { + // `lts/`(如 `lts/iron` = Node 20)需要 codename → 版本行映射,当前不维护; + // 按“未 pin”回退,绝不能声称匹配任意版本而静默选中已安装的最高版本。 + return None; + } if lower.starts_with("iojs") || lower.contains("||") { return None; } @@ -1369,7 +1372,7 @@ mod tests { let fnm_root = tempfile::tempdir().unwrap(); install_fnm_version(fnm_root.path(), "v22.23.3"); let project = tempfile::tempdir().unwrap(); - for pin in ["iojs\n", ">20\n", "<=20\n"] { + for pin in ["iojs\n", ">20\n", "<=20\n", "lts/iron\n"] { fs::write(project.path().join(".node-version"), pin).unwrap(); let runtime = resolve_at_with_managed_roots( project.path(), @@ -1394,7 +1397,9 @@ mod tests { assert_eq!(version_matches_pin((20, 11, 0), "~20.11.0"), Some(true)); assert_eq!(version_matches_pin((20, 11, 0), ">=20 <23"), Some(true)); assert_eq!(version_matches_pin((20, 11, 0), "lts/*"), Some(true)); - assert_eq!(version_matches_pin((20, 11, 0), "lts/iron"), Some(true)); + // codename 未维护映射时必须按未 pin 回退,不能匹配任意版本。 + assert_eq!(version_matches_pin((20, 11, 0), "lts/iron"), None); + assert_eq!(version_matches_pin((22, 23, 3), "lts/iron"), None); assert_eq!(version_matches_pin((20, 11, 0), ">=22 || 20"), None); assert_eq!(version_matches_pin((20, 11, 0), ">20"), None); assert_eq!(version_matches_pin((20, 11, 0), "<=20"), None); diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index 7c09a4b3e..f9088ccbc 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -4,7 +4,7 @@ - 背景:开发构建里 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)`。 +- 决策(版本选择):`.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/*`;`lts/` 需要 codename → 版本行映射,当前按未 pin 回退而不猜);`v22.23.3` 这类带 `v` 的 `.nvmrc` 必须先剥前缀。nvm 的 `alias/default` 常只写主版本号(如 `22`),必须按同一 pin 子集在已安装版本里选最高匹配,不能当成完整三元组 `(22,0,0)`。 - 决策(沙箱内启动形态):Linux npm 改为 `node `,因为单文件挂载 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` 改用实际目标)。 From 6f4667d96f08b05f4e09082c42412a974637d8d0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Wed, 7 Oct 2026 11:45:02 +0800 Subject: [PATCH 02/13] =?UTF-8?q?=E8=A1=A5fnm=E9=BB=98=E8=AE=A4=E5=88=AB?= =?UTF-8?q?=E5=90=8D=E7=AC=A6=E5=8F=B7=E9=93=BE=E6=8E=A5=E5=9B=9E=E5=BD=92?= =?UTF-8?q?=E6=B5=8B=E8=AF=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增单测 fnm_default_alias_symlink_selects_the_linked_installation,断言 default 别名选中该链接的 v20 而不是最高的 v22 - 核实评审该项前提有误:fnm 1.39 的 aliases/default 是指向安装目录的符号链接,当前实现本就正确 --- .../src-tauri/src/environment_check.rs | 22 +++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/apps/ai-game-creator-shell/src-tauri/src/environment_check.rs b/apps/ai-game-creator-shell/src-tauri/src/environment_check.rs index 72d2284ed..fbe50c395 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/environment_check.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/environment_check.rs @@ -1294,6 +1294,28 @@ mod tests { assert_eq!(default.version, (22, 23, 3)); } + #[cfg(unix)] + #[test] + fn fnm_default_alias_symlink_selects_the_linked_installation() { + use std::os::unix::fs::symlink; + + let fnm_root = tempfile::tempdir().unwrap(); + let older = install_fnm_version(fnm_root.path(), "v20.11.0"); + install_fnm_version(fnm_root.path(), "v22.23.3"); + let alias_dir = fnm_root.path().join("aliases"); + fs::create_dir_all(&alias_dir).unwrap(); + // fnm 1.39 的 default 别名是指向安装目录的符号链接,不是版本字符串文件。 + symlink(&older, alias_dir.join("default")).unwrap(); + let installed = installed_node_versions(&[fnm_root.path().to_path_buf()], &[]); + assert_eq!(installed.len(), 2); + let default = + default_managed_installation(&installed, &[fnm_root.path().to_path_buf()], &[]) + .expect("fnm default alias should resolve"); + // 必须跟随别名选中 v20,而不是回退到最高的 v22。 + assert_eq!(default.prefix, older.canonicalize().unwrap()); + assert_eq!(default.version, (20, 11, 0)); + } + #[test] fn version_file_pin_is_authoritative_and_blocks_when_not_installed() { let fnm_root = tempfile::tempdir().unwrap(); From deecebf0bb170992c219f35fec758936bc60bc12 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Wed, 7 Oct 2026 11:59:42 +0800 Subject: [PATCH 03/13] =?UTF-8?q?=E5=88=A0=E9=99=A4=E6=9C=AA=E7=94=9F?= =?UTF-8?q?=E6=95=88=E7=9A=84=E6=B4=BB=E5=8A=A8=E7=89=88=E6=9C=AC=E5=88=86?= =?UTF-8?q?=E6=94=AF=EF=BC=8C=E5=9B=9E=E9=80=80=E9=93=BE=E5=8F=AA=E8=AE=A4?= =?UTF-8?q?=E9=BB=98=E8=AE=A4=E5=88=AB=E5=90=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 删除 active_managed_prefix(FNM_MULTISHELL_PATH / NVM_BIN):激活时活动版本已在 PATH 里,而该分支拿到的 multishell 前缀与枚举出的安装前缀不相等,永远不命中 - resolve_managed_fallback_runtime 回退顺序收敛为 engines 最高匹配 > 默认别名 > 已安装最高版本 - 同步 decision-log、里程碑与两处技术方案的版本选择口径 --- .../src-tauri/src/environment_check.rs | 21 ------------------- ...‘】AGC命令沙箱Node版本管理器支持-2026-10-07.md | 2 +- .../shared-memory/decision-log.md | 2 +- ...案】AI游戏创作Agent Runtime V1.1-2026-07-12.md | 2 +- ...¹案】AI游戏创作智能体App实施计划-2026-06-24.md | 2 +- 5 files changed, 4 insertions(+), 25 deletions(-) diff --git a/apps/ai-game-creator-shell/src-tauri/src/environment_check.rs b/apps/ai-game-creator-shell/src-tauri/src/environment_check.rs index fbe50c395..5c3f1acc8 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/environment_check.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/environment_check.rs @@ -657,22 +657,6 @@ fn read_project_engines_range(root: &Path) -> Option { } } -fn active_managed_prefix() -> Option { - if let Some(path) = std::env::var_os("FNM_MULTISHELL_PATH") { - if let Ok(prefix) = validate_node_installation_prefix(Path::new(&path)) { - return Some(prefix); - } - } - if let Some(bin) = std::env::var_os("NVM_BIN") { - if let Some(parent) = Path::new(&bin).parent() { - if let Ok(prefix) = validate_node_installation_prefix(parent) { - return Some(prefix); - } - } - } - None -} - fn default_managed_installation<'a>( installed: &'a [InstalledNodeVersion], fnm_roots: &[PathBuf], @@ -735,11 +719,6 @@ fn resolve_managed_fallback_runtime( return Ok(Some(runtime_from_installed(node, root, path)?)); } } - if let Some(prefix) = active_managed_prefix() { - if let Some(node) = installed.iter().find(|node| node.prefix == prefix) { - return Ok(Some(runtime_from_installed(node, root, path)?)); - } - } if let Some(node) = default_managed_installation(&installed, fnm_roots, nvm_roots) { return Ok(Some(runtime_from_installed(node, root, path)?)); } diff --git a/docs/project-memory/plans/【里程碑】AGC命令沙箱Node版本管理器支持-2026-10-07.md b/docs/project-memory/plans/【里程碑】AGC命令沙箱Node版本管理器支持-2026-10-07.md index 56a673578..451a38ff6 100644 --- a/docs/project-memory/plans/【里程碑】AGC命令沙箱Node版本管理器支持-2026-10-07.md +++ b/docs/project-memory/plans/【里程碑】AGC命令沙箱Node版本管理器支持-2026-10-07.md @@ -15,7 +15,7 @@ - 移除把 `node` / `npm` / `npx` 指向 `/usr/bin/*` 的宿主 shim 依赖,开发构建默认解析托管安装。 - 枚举 fnm `node-versions//installation`(含 `aliases/default`)与 nvm `versions/node/`,按窄叶规则校验完整安装前缀。 -- 版本选择:`.nvmrc` / `.node-version` pin > PATH 可解析的可用 Node > 版本管理器回退链(`engines.node` 最高匹配 > 活动版本 > 默认别名 > 已安装最高版本)。 +- 版本选择:`.nvmrc` / `.node-version` pin > PATH 可解析的可用 Node > 版本管理器回退链(`engines.node` 最高匹配 > 默认别名 > 已安装最高版本)。活动版本不单独查询:激活时它已在 `PATH` 里,回退链不再重复一份可能失效的副本(原 `FNM_MULTISHELL_PATH` / `NVM_BIN` 分支已删除)。 - Linux 命令沙箱把通过校验的完整安装前缀只读挂载,并以 `node ` 启动 npm;`npm install` 保持联网判定。 - 沙箱内联环境不继承 fnm multishell 等临时版本管理器变量。 diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index f9088ccbc..49f713486 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -4,7 +4,7 @@ - 背景:开发构建里 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/*`;`lts/` 需要 codename → 版本行映射,当前按未 pin 回退而不猜);`v22.23.3` 这类带 `v` 的 `.nvmrc` 必须先剥前缀。nvm 的 `alias/default` 常只写主版本号(如 `22`),必须按同一 pin 子集在已安装版本里选最高匹配,不能当成完整三元组 `(22,0,0)`。 +- 决策(版本选择):`.nvmrc` / `.node-version` 是权威 pin,能理解但未安装时返回 `node-version-pinned-not-installed` 失败关闭;`package.json` `engines.node` 只是偏好,永不阻塞;不支持的写法(`iojs`、`||`、部分 `>` / `<=`、hyphen range、prerelease)按未 pin 回退。整体顺序为 pin 命中 > PATH 可解析的可用 Node > 版本管理器回退链(`engines` 最高匹配 > 默认别名 > 已安装最高版本);活动版本不单独查询(`FNM_MULTISHELL_PATH` / `NVM_BIN`),因为激活时它已在 `PATH` 里,回退链无需重复一份可能失效的副本;只实现文档化比较子集(精确三元组、major、`>=` / `>` / `<=` / `<`、`^`、`~`、`x` / `*`、`lts/*`;`lts/` 需要 codename → 版本行映射,当前按未 pin 回退而不猜);`v22.23.3` 这类带 `v` 的 `.nvmrc` 必须先剥前缀。nvm 的 `alias/default` 常只写主版本号(如 `22`),必须按同一 pin 子集在已安装版本里选最高匹配,不能当成完整三元组 `(22,0,0)`。 - 决策(沙箱内启动形态):Linux npm 改为 `node `,因为单文件挂载 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` 改用实际目标)。 diff --git a/docs/technical/【技术方案】AI游戏创作Agent Runtime V1.1-2026-07-12.md b/docs/technical/【技术方案】AI游戏创作Agent Runtime V1.1-2026-07-12.md index 64eb65456..10fc3593c 100644 --- a/docs/technical/【技术方案】AI游戏创作Agent Runtime V1.1-2026-07-12.md +++ b/docs/technical/【技术方案】AI游戏创作Agent Runtime V1.1-2026-07-12.md @@ -621,7 +621,7 @@ Runner-kill E2E 不再以 latest task 或单个 process record 推断整体恢 开发构建(`debug_assertions`)下,AGC 生成和验证 Web 项目所用的 Node 往往由 fnm / nvm 等版本管理器托管:安装前缀位于用户目录下,活动 `PATH` 指向随 shell 会话变化的临时目录(如 fnm 的 multishell)。V1.11.2 让开发构建的命令沙箱原生解析并只读复用这些托管安装,不再要求用户改系统 Node、把宿主 shim 指到 `/usr/bin`,或把托管目录软链进系统路径。发布构建继续只认随包 bundle,不新增托管版本管理器探测,也不改变 bundle 缺失时的失败口径;Windows 行为不变。 - 版本来源是机器上可枚举的托管安装:fnm 的 `node-versions//installation`(含 `aliases/default` 指向的默认别名)与 nvm 的 `versions/node/`。宿主发现和沙箱只读挂载共用同一套窄叶校验,不允许两处信任口径漂移。 -- 解析优先级为:`.nvmrc` / `.node-version` 的权威 pin 命中 > 宿主 `PATH` 能解析出的可用 Node > 版本管理器回退链(`package.json` `engines.node` 偏好中的最高匹配 > 当前活动版本 > 默认别名 > 已安装最高版本)。`.nvmrc` / `.node-version` 能理解但未安装时必须失败关闭(`node-version-pinned-not-installed`),不得静默回退;`engines.node` 只是偏好,任何情况下都不阻塞。只实现文档化的比较子集(精确三元组、major、`>=` / `>` / `<=` / `<`、`^`、`~`、`x` / `*` 通配、`lts/*`);不支持或无法解析的写法按「未 pin」处理并回退。 +- 解析优先级为:`.nvmrc` / `.node-version` 的权威 pin 命中 > 宿主 `PATH` 能解析出的可用 Node > 版本管理器回退链(`package.json` `engines.node` 偏好中的最高匹配 > 默认别名 > 已安装最高版本)。不单独查询活动版本(`FNM_MULTISHELL_PATH` / `NVM_BIN`):激活时它已经在 `PATH` 里,回退链重复一份可能失效的副本只会带来误导。`.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 install` 的联网判定必须跟随这条真实启动形态,不能因为包装方式变化而丢失联网或反向放开。 - 沙箱内联环境不继承宿主活动版本管理器的临时变量(如 fnm multishell 路径);版本管理器 CLI(`fnm` / `nvm`)本身不需要在沙箱内可用。 diff --git a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md index 65beac350..349e3344c 100644 --- a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md +++ b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md @@ -853,7 +853,7 @@ 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 install` 的联网判定跟随该真实形态;sandbox 环境剔除 fnm multishell 变量。发布构建仍只认随包 bundle,Windows 不变。定向 Rust 测试与真实 bwrap 内 fnm v22 的 `node` / npm 运行已通过;真实 nvm 端到端待补。 +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 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,恢复不得重放提交。 From f6e3e19cd88e7a2af5c787dd38a2d00b40041bb1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Wed, 7 Oct 2026 12:19:00 +0800 Subject: [PATCH 04/13] =?UTF-8?q?=E5=B0=86npm=20install=E8=81=94=E7=BD=91?= =?UTF-8?q?=E6=94=BE=E8=A1=8C=E7=BB=91=E5=AE=9A=E5=88=B0=E5=8F=AF=E4=BF=A1?= =?UTF-8?q?node=20launcher?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - prepare_command_sandbox_launch 新增 trusted_npm_install 参数,command_exec 把 spec.node_launcher 的 (node, npm_cli) 透传给沙箱 - command_sandbox_requests_npm_install 改为与可信 (node, npm_cli) 精确比对(executable、首个参数、install 子命令),删除只看 basename 的判定;拿不到可信对时一律不放网 - LinuxSandboxPlan 增加 npm_install_network,build_linux_bwrap_launch 直接采用,避免重复推导 - 新增/更新单测:项目同名 npm-cli.js 不放网、无可信 launcher 不放网、--share-net 开关、可信 (node, npm_cli) 与实际目标逐字一致 - 同步技术方案 V1.11.2、decision-log 与 pitfalls 记录该安全边界 --- .../src-tauri/src/command_exec.rs | 14 ++ .../src-tauri/src/command_sandbox.rs | 121 +++++++++++++----- .../shared-memory/decision-log.md | 6 +- docs/project-memory/shared-memory/pitfalls.md | 5 +- ...案】AI游戏创作Agent Runtime V1.1-2026-07-12.md | 2 +- 5 files changed, 108 insertions(+), 40 deletions(-) diff --git a/apps/ai-game-creator-shell/src-tauri/src/command_exec.rs b/apps/ai-game-creator-shell/src-tauri/src/command_exec.rs index 5d6e9f58a..dc406153d 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/command_exec.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/command_exec.rs @@ -1661,6 +1661,9 @@ pub(crate) fn prepare_project_command_launch_spec( &target_arguments, &spec.cwd, &environment, + spec.node_launcher + .as_ref() + .map(|(node, npm_cli)| (node.as_path(), npm_cli.as_path())), ) .map_err(|error| { ProjectCommandError::new( @@ -2664,6 +2667,17 @@ mod tests { assert!(arguments[0].ends_with("npm-cli.js"), "{arguments:?}"); assert_eq!(&arguments[1..], ["run".to_string(), "build".to_string()]); + // 沙箱联网判定要求目标与解析层给出的可信 (node, npm_cli) 精确一致。 + let (trusted_node, trusted_npm_cli) = spec + .node_launcher + .as_ref() + .expect("Linux npm spec must carry the trusted node launcher"); + assert_eq!(trusted_node, &executable); + assert_eq!( + trusted_npm_cli.to_string_lossy().as_ref(), + arguments[0].as_str() + ); + 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:?}"); diff --git a/apps/ai-game-creator-shell/src-tauri/src/command_sandbox.rs b/apps/ai-game-creator-shell/src-tauri/src/command_sandbox.rs index 109304b59..8a1d0e63e 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/command_sandbox.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/command_sandbox.rs @@ -110,6 +110,9 @@ pub(crate) fn command_sandbox_platform_metadata() -> CommandSandboxMetadata { /// Builds a fail-closed launcher for a direct executable plus structured argv. /// It never falls back to launching the original command on the host. +/// +/// `trusted_npm_install` 是解析层给出的可信 `(node, npm_cli)`;只有目标命令与它精确一致 +/// 且子命令是 `install` 时才放网,避免同名项目文件冒充 npm。 #[cfg(target_os = "linux")] pub(crate) fn prepare_command_sandbox_launch( root: &Path, @@ -117,8 +120,16 @@ pub(crate) fn prepare_command_sandbox_launch( arguments: &[String], cwd: &Path, environment: &[(OsString, OsString)], + trusted_npm_install: Option<(&Path, &Path)>, ) -> Result { - prepare_linux_command_sandbox_launch(root, executable, arguments, cwd, environment) + prepare_linux_command_sandbox_launch( + root, + executable, + arguments, + cwd, + environment, + trusted_npm_install, + ) } #[cfg(target_os = "linux")] @@ -202,6 +213,7 @@ mod linux { cwd: PathBuf, executable: PathBuf, arguments: Vec, + npm_install_network: bool, target_environment: Vec<(OsString, OsString)>, merged_usr_links: Vec<(OsString, PathBuf)>, protected_read_only: Vec, @@ -293,9 +305,12 @@ mod linux { arguments: &[String], cwd: &Path, environment: &[(OsString, OsString)], + trusted_npm_install: Option<(&Path, &Path)>, ) -> Result { let mut metadata = CommandSandboxMetadata::enforced_linux(); - if command_sandbox_requests_npm_install(executable, arguments) { + let npm_install_network = + command_sandbox_requests_npm_install(executable, arguments, trusted_npm_install); + if npm_install_network { metadata.network = "enabled"; } let bwrap = find_trusted_bwrap().map_err(|error| { @@ -372,6 +387,7 @@ mod linux { cwd, executable, arguments: arguments.to_vec(), + npm_install_network, target_environment, merged_usr_links, protected_read_only, @@ -546,26 +562,21 @@ mod linux { .collect()) } - /// `npm install` 是唯一允许联网的受控入口。Linux 以 `node 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 + /// `npm install` 是唯一允许联网的受控入口。Linux 上 npm 以 `node install` + /// 启动,只看 executable 或第一个参数的 basename 会被项目里同名的文件冒充,因此必须与解析层 + /// 给出的可信 `(node, npm_cli)` 精确比对;拿不到可信对时一律不放网。 + fn command_sandbox_requests_npm_install( + executable: &Path, + arguments: &[String], + trusted_npm_install: Option<(&Path, &Path)>, + ) -> bool { + let Some((trusted_node, trusted_npm_cli)) = trusted_npm_install else { + return false; + }; + executable == trusted_node + && 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")) + .is_some_and(|argument| Path::new(argument) == trusted_npm_cli) && arguments .get(1) .is_some_and(|argument| argument == "install") @@ -713,10 +724,7 @@ mod linux { fn build_linux_bwrap_launch(plan: LinuxSandboxPlan) -> CommandSandboxLaunch { let mut args = Vec::::new(); - push_namespace_arguments( - &mut args, - command_sandbox_requests_npm_install(&plan.executable, &plan.arguments), - ); + push_namespace_arguments(&mut args, plan.npm_install_network); push_ro_bind(&mut args, Path::new("/usr"), Path::new("/usr")); for (target, destination) in &plan.merged_usr_links { push_option( @@ -1056,6 +1064,16 @@ mod linux { }) } + #[test] + fn namespace_arguments_share_net_only_when_trusted() { + let mut shared = Vec::new(); + push_namespace_arguments(&mut shared, true); + assert!(shared.contains(&OsString::from("--share-net"))); + let mut isolated = Vec::new(); + push_namespace_arguments(&mut isolated, false); + assert!(!isolated.contains(&OsString::from("--share-net"))); + } + #[test] fn pure_builder_constructs_empty_workspace_namespace_and_private_environment() { let launch = build_linux_bwrap_launch(LinuxSandboxPlan { @@ -1064,6 +1082,7 @@ mod linux { cwd: PathBuf::from("/workspace/project/game"), executable: PathBuf::from("/opt/toolchain/bin/tool"), arguments: vec!["check".to_string(), "--flag".to_string()], + npm_install_network: false, target_environment: vec![ ( OsString::from("HOME"), @@ -1188,6 +1207,7 @@ mod linux { &["-c".to_string(), script], &root, &[(OsString::from("PATH"), OsString::from("/usr/bin"))], + None, ) .expect_err("invalid protected path must fail closed"); assert!(error.to_string().contains(".codex")); @@ -1332,25 +1352,49 @@ mod linux { } #[test] - fn npm_install_network_detection_supports_node_launcher() { + fn npm_install_network_detection_requires_the_trusted_node_launcher() { + let node = Path::new("/opt/node/bin/node"); + let npm_cli = Path::new("/opt/node/lib/node_modules/npm/bin/npm-cli.js"); + let trusted = Some((node, npm_cli)); + let install_arguments = vec![ + npm_cli.to_string_lossy().into_owned(), + "install".to_string(), + ]; assert!(command_sandbox_requests_npm_install( - Path::new("/opt/node/bin/node"), + node, + &install_arguments, + trusted, + )); + // 项目里同名的 npm-cli.js 不能再冒充。 + assert!(!command_sandbox_requests_npm_install( + node, &[ - "/opt/node/lib/node_modules/npm/bin/npm-cli.js".to_string(), + "/workspace/game/npm-cli.js".to_string(), "install".to_string(), ], + trusted, )); + // executable 与可信 node 不一致时不放网。 assert!(!command_sandbox_requests_npm_install( - Path::new("/opt/node/bin/node"), + Path::new("/opt/node/bin/other"), + &install_arguments, + trusted, + )); + // 非 install 子命令不放网。 + assert!(!command_sandbox_requests_npm_install( + node, &[ - "/opt/node/lib/node_modules/npm/bin/npm-cli.js".to_string(), + npm_cli.to_string_lossy().into_owned(), "run".to_string(), "build".to_string(), ], + trusted, )); - assert!(command_sandbox_requests_npm_install( + // 没有可信 launcher(例如直接 npm shim)时不放网。 + assert!(!command_sandbox_requests_npm_install( Path::new("/usr/bin/npm"), &["install".to_string()], + None, )); } @@ -1451,6 +1495,7 @@ print("SANDBOX_OK") &["-c".to_string(), script], &root, &environment, + None, ) .expect("prepare real Linux sandbox"); @@ -1509,6 +1554,7 @@ print("SANDBOX_OK") arguments, &root, &environment, + None, ) .expect("prepare node sandbox"); let output = Command::new(&launch.executable) @@ -1578,9 +1624,15 @@ print("SANDBOX_OK") (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 launch = prepare_command_sandbox_launch( + &root, + &node, + arguments, + &root, + &environment, + None, + ) + .expect("prepare nvm sandbox"); let output = Command::new(&launch.executable) .args(&launch.arguments) .current_dir(&launch.cwd) @@ -1635,6 +1687,7 @@ print("SANDBOX_OK") &arguments, &root, &environment, + None, ) .expect("prepare real Linux sandbox"); let target_arguments = arguments.iter().map(OsString::from).collect::>(); diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index 49f713486..6cc46f9b6 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -5,10 +5,10 @@ - 背景:开发构建里 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` 最高匹配 > 默认别名 > 已安装最高版本);活动版本不单独查询(`FNM_MULTISHELL_PATH` / `NVM_BIN`),因为激活时它已在 `PATH` 里,回退链无需重复一份可能失效的副本;只实现文档化比较子集(精确三元组、major、`>=` / `>` / `<=` / `<`、`^`、`~`、`x` / `*`、`lts/*`;`lts/` 需要 codename → 版本行映射,当前按未 pin 回退而不猜);`v22.23.3` 这类带 `v` 的 `.nvmrc` 必须先剥前缀。nvm 的 `alias/default` 常只写主版本号(如 `22`),必须按同一 pin 子集在已安装版本里选最高匹配,不能当成完整三元组 `(22,0,0)`。 -- 决策(沙箱内启动形态):Linux npm 改为 `node `,因为单文件挂载 npm 软链必然丢 `../lib/cli.js`;只读挂载整棵已验证的安装前缀(不是整个 HOME、`FNM_DIR` 或 `NVM_DIR`)。`npm install` 的联网判定跟随真实启动形态(`node` + `npm-cli.js` + `install`),不因包装变化丢 `--share-net`。 +- 决策(沙箱内启动形态与联网放行):Linux npm 改为 `node `,因为单文件挂载 npm 软链必然丢 `../lib/cli.js`;只读挂载整棵已验证的安装前缀(不是整个 HOME、`FNM_DIR` 或 `NVM_DIR`)。`npm install` 的联网放行只认解析层给出的可信 `(node, npm_cli)` 精确匹配(executable 是对应 node、首个参数是对应 `npm-cli.js`、子命令是 `install`),不再按 basename 判定:项目里同名的 `npm-cli.js` 可写,只加「路径必须绝对」也能绕过;拿不到可信对时一律不放网。 - 决策(范围与非目标):托管版本管理器发现只在 `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。 +- 影响范围:`apps/ai-game-creator-shell/src-tauri/src/environment_check.rs`(窄叶校验、托管安装枚举、pin / engines 解析、选择与回退)、`command_sandbox.rs`(Node 工具链挂载收集与合并、`prepare_command_sandbox_launch` 透传可信 `(node, npm_cli)`、`LinuxSandboxPlan::npm_install_network`、`FNM_MULTISHELL_PATH` 清理)、`command_exec.rs`(Linux npm `node_launcher`、`project_command_actual_target`、`prepare_project_command_launch_spec` 把可信 launcher 交给沙箱、非 Node 程序 PATH 前置工具链 bin)、`process_session_bridge.rs`(`ProcessSessionLaunchPlan::from_launch` 改用实际目标)。 +- 验证方式:`environment_check` 25 passed、`command_sandbox` 15 passed、`command_exec` 19 passed、`process_session` 27 passed(均 `--test-threads=1`);`npm_install_network_detection_requires_the_trusted_node_launcher` 覆盖「项目同名 `npm-cli.js` 不放网、无可信 launcher 不放网、executable 不匹配不放网」,`namespace_arguments_share_net_only_when_trusted` 覆盖 `--share-net` 开关,`npm_command_targets_node_plus_npm_cli_on_linux` 断言解析层可信 `(node, npm_cli)` 与实际目标逐字一致;`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 自带白名单不再共享 diff --git a/docs/project-memory/shared-memory/pitfalls.md b/docs/project-memory/shared-memory/pitfalls.md index e3f38e736..2507b6004 100644 --- a/docs/project-memory/shared-memory/pitfalls.md +++ b/docs/project-memory/shared-memory/pitfalls.md @@ -9,8 +9,9 @@ - **根因 2(`--tmpfs /run` 抹掉活动版本)**:fnm 的活动 `PATH` 项是 `/run/user//fnm_multishells//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 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 --version` 均通过;单元用例覆盖 pin / engines / 不支持写法 / 宽叶与逃逸前缀 / 联网判定。 +- **根因 4(联网放行只看 basename 会被同名文件冒充)**:`npm install` 是唯一允许联网的入口,最初只按「首个参数 basename 是 `npm-cli.js` 且第二个参数是 `install`」放行。Linux 上 `command.exec` 不对 `node` 的脚本参数做路径校验,项目根又可写,于是在项目里放一个自写的 `npm-cli.js` 再 `node 项目/npm-cli.js install` 就能拿到 `--share-net`。修法是让联网放行与解析层给出的可信 `(node, npm_cli)` 精确比对,拿不到可信对时一律不放网;只加「路径必须绝对」不够,绝对路径的项目内文件照样绕过。 +- **现行口径**:宿主发现与沙箱挂载共用 `validate_node_installation_prefix`;`.nvmrc` / `.node-version` 权威、`engines.node` 偏好;Linux npm 以 `node ` 启动,`npm install` 联网放行只认解析层给出的可信 `(node, npm_cli)` 精确匹配。契约见技术方案 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 --version` 均通过;单元用例覆盖 pin / engines / 不支持写法 / 宽叶与逃逸前缀 / 可信 launcher 联网放行。 ## 2026-10-07 cargo 目标目录里被"刷新 mtime"的陈旧 shared-contracts 会让编译报源文件里明明存在的字段缺失 diff --git a/docs/technical/【技术方案】AI游戏创作Agent Runtime V1.1-2026-07-12.md b/docs/technical/【技术方案】AI游戏创作Agent Runtime V1.1-2026-07-12.md index 10fc3593c..598d52455 100644 --- a/docs/technical/【技术方案】AI游戏创作Agent Runtime V1.1-2026-07-12.md +++ b/docs/technical/【技术方案】AI游戏创作Agent Runtime V1.1-2026-07-12.md @@ -623,7 +623,7 @@ Runner-kill E2E 不再以 latest task 或单个 process record 推断整体恢 - 版本来源是机器上可枚举的托管安装:fnm 的 `node-versions//installation`(含 `aliases/default` 指向的默认别名)与 nvm 的 `versions/node/`。宿主发现和沙箱只读挂载共用同一套窄叶校验,不允许两处信任口径漂移。 - 解析优先级为:`.nvmrc` / `.node-version` 的权威 pin 命中 > 宿主 `PATH` 能解析出的可用 Node > 版本管理器回退链(`package.json` `engines.node` 偏好中的最高匹配 > 默认别名 > 已安装最高版本)。不单独查询活动版本(`FNM_MULTISHELL_PATH` / `NVM_BIN`):激活时它已经在 `PATH` 里,回退链重复一份可能失效的副本只会带来误导。`.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 install` 的联网判定必须跟随这条真实启动形态,不能因为包装方式变化而丢失联网或反向放开。 +- Linux 上 npm 不再直接执行 npm shim,而是以受信任的 `node ...` 启动;`npm install` 的联网放行必须与解析层给出的可信 `(node, npm_cli)` 精确一致(executable 是对应的 node、首个参数是对应的 `npm-cli.js`、子命令是 `install`),不能只匹配 basename——项目根可写,项目里同名的 `npm-cli.js` 会骗到 `--share-net`;拿不到可信对时一律不放网。 - 沙箱内联环境不继承宿主活动版本管理器的临时变量(如 fnm multishell 路径);版本管理器 CLI(`fnm` / `nvm`)本身不需要在沙箱内可用。 ## V1.12 受控本地 Git 提交 From f046bbe204605a60e022f3cf2c23f704caea62ae Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Wed, 7 Oct 2026 13:38:55 +0800 Subject: [PATCH 05/13] =?UTF-8?q?=E8=B7=B3=E8=BF=87=E7=BC=BA=E5=B0=91?= =?UTF-8?q?=E5=AE=BF=E4=B8=BBNode=E6=97=B6=E7=9A=84npm=E7=9B=AE=E6=A0=87?= =?UTF-8?q?=E5=8D=95=E6=B5=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - npm_command_targets_node_plus_npm_cli_on_linux 先探测 resolve_node_runtime,最小容器无可用宿主 Node 时跳过,避免 cargo test 因缺 Node panic - 探测失败只跳过这一条真机用例,其它解析失败仍照常失败 --- apps/ai-game-creator-shell/src-tauri/src/command_exec.rs | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/apps/ai-game-creator-shell/src-tauri/src/command_exec.rs b/apps/ai-game-creator-shell/src-tauri/src/command_exec.rs index dc406153d..1fb45b6cb 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/command_exec.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/command_exec.rs @@ -2650,6 +2650,11 @@ mod tests { 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(); + // 这条用例走真实 resolve_node_runtime:没有可用宿主 Node 的最小容器里跳过, + // 保持 cargo test 可跑;其它失败仍然照常失败。 + if crate::environment_check::resolve_node_runtime(root.path()).is_err() { + return; + } let spec = resolve_project_command_spec_at( root.path(), "npm", From a2e538e2d1604ab51b2494bd5c675e80ed92e9d8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Wed, 7 Oct 2026 13:39:15 +0800 Subject: [PATCH 06/13] =?UTF-8?q?=E8=AE=A9=20lts=20=E9=80=9A=E9=85=8D?= =?UTF-8?q?=E4=B8=8E=20codename=20=E4=B8=80=E6=A0=B7=E6=8C=89=E6=9C=AA=20p?= =?UTF-8?q?in=20=E5=9B=9E=E9=80=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - version_matches_pin 不再把 lts/* 当作匹配任意版本:它和 lts/ 一样需要 LTS 行映射,未维护时返回 None,交回退链而不是静默选中已安装最高版本(最高版本通常不是 LTS) - 更新单测:lts/* 断言 None,并加入不支持 pin 的回退用例;修正 nvm default 别名注释 - 同步技术方案 V1.11.2 与 decision-log 的比较子集口径 --- .../src-tauri/src/environment_check.rs | 19 +++++++++++-------- .../shared-memory/decision-log.md | 2 +- ...案】AI游戏创作Agent Runtime V1.1-2026-07-12.md | 2 +- 3 files changed, 13 insertions(+), 10 deletions(-) diff --git a/apps/ai-game-creator-shell/src-tauri/src/environment_check.rs b/apps/ai-game-creator-shell/src-tauri/src/environment_check.rs index 5c3f1acc8..3112bfd34 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/environment_check.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/environment_check.rs @@ -562,12 +562,13 @@ fn version_matches_pin(version: (u64, u64, u64), pin: &str) -> Option { // `.nvmrc` 两种写法都常见:`v22.23.3` 与 `22.23.3`。 let pin = pin.strip_prefix('v').unwrap_or(pin); let lower = pin.to_ascii_lowercase(); - if matches!(lower.as_str(), "*" | "x" | "node" | "latest" | "lts/*") { + if matches!(lower.as_str(), "*" | "x" | "node" | "latest") { return Some(true); } if lower.starts_with("lts/") { - // `lts/`(如 `lts/iron` = Node 20)需要 codename → 版本行映射,当前不维护; - // 按“未 pin”回退,绝不能声称匹配任意版本而静默选中已安装的最高版本。 + // `lts/*`(最新 LTS)与 `lts/`(如 `lts/iron` = Node 20)都需要 LTS 行映射, + // 当前不维护;按“未 pin”回退到宿主已激活/默认选择,绝不能声称匹配任意版本而静默选中 + // 已安装的最高版本——最高版本通常不是 LTS。 return None; } if lower.starts_with("iojs") || lower.contains("||") { @@ -674,8 +675,9 @@ fn default_managed_installation<'a>( let Ok(content) = fs::read_to_string(root.join("alias").join("default")) else { continue; }; - // nvm 的 default 别名常写成 `22`、`v22.23.3` 或 `lts/*`,不是完整三元组;按 pin 的 - // 同一子集在已安装版本里选最高匹配,避免 `22` 被当成 (22,0,0) 而永远匹配不到。 + // nvm 的 default 别名常写成 `22` 或 `v22.23.3`,不是完整三元组;按 pin 的同一子集在 + // 已安装版本里选最高匹配,避免 `22` 被当成 (22,0,0) 而永远匹配不到。`lts/*` 之类需要 + // LTS 行映射的别名会落空,由调用方回退到已安装最高版本。 if let PinSelection::Matched(node) = select_pinned_installation(installed, content.trim()) { return Some(node); } @@ -1373,7 +1375,7 @@ mod tests { let fnm_root = tempfile::tempdir().unwrap(); install_fnm_version(fnm_root.path(), "v22.23.3"); let project = tempfile::tempdir().unwrap(); - for pin in ["iojs\n", ">20\n", "<=20\n", "lts/iron\n"] { + for pin in ["iojs\n", ">20\n", "<=20\n", "lts/iron\n", "lts/*\n"] { fs::write(project.path().join(".node-version"), pin).unwrap(); let runtime = resolve_at_with_managed_roots( project.path(), @@ -1397,8 +1399,9 @@ mod tests { assert_eq!(version_matches_pin((20, 11, 0), "^20.0.0"), Some(true)); assert_eq!(version_matches_pin((20, 11, 0), "~20.11.0"), Some(true)); assert_eq!(version_matches_pin((20, 11, 0), ">=20 <23"), Some(true)); - assert_eq!(version_matches_pin((20, 11, 0), "lts/*"), Some(true)); - // codename 未维护映射时必须按未 pin 回退,不能匹配任意版本。 + // `lts/*` 与 codename 都需要 LTS 行映射,未维护时必须按未 pin 回退,不能匹配任意版本。 + assert_eq!(version_matches_pin((20, 11, 0), "lts/*"), None); + assert_eq!(version_matches_pin((22, 23, 3), "lts/*"), None); assert_eq!(version_matches_pin((20, 11, 0), "lts/iron"), None); assert_eq!(version_matches_pin((22, 23, 3), "lts/iron"), None); assert_eq!(version_matches_pin((20, 11, 0), ">=22 || 20"), None); diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index 6cc46f9b6..ffc538818 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -4,7 +4,7 @@ - 背景:开发构建里 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` 最高匹配 > 默认别名 > 已安装最高版本);活动版本不单独查询(`FNM_MULTISHELL_PATH` / `NVM_BIN`),因为激活时它已在 `PATH` 里,回退链无需重复一份可能失效的副本;只实现文档化比较子集(精确三元组、major、`>=` / `>` / `<=` / `<`、`^`、`~`、`x` / `*`、`lts/*`;`lts/` 需要 codename → 版本行映射,当前按未 pin 回退而不猜);`v22.23.3` 这类带 `v` 的 `.nvmrc` 必须先剥前缀。nvm 的 `alias/default` 常只写主版本号(如 `22`),必须按同一 pin 子集在已安装版本里选最高匹配,不能当成完整三元组 `(22,0,0)`。 +- 决策(版本选择):`.nvmrc` / `.node-version` 是权威 pin,能理解但未安装时返回 `node-version-pinned-not-installed` 失败关闭;`package.json` `engines.node` 只是偏好,永不阻塞;不支持的写法(`iojs`、`||`、部分 `>` / `<=`、hyphen range、prerelease)按未 pin 回退。整体顺序为 pin 命中 > PATH 可解析的可用 Node > 版本管理器回退链(`engines` 最高匹配 > 默认别名 > 已安装最高版本);活动版本不单独查询(`FNM_MULTISHELL_PATH` / `NVM_BIN`),因为激活时它已在 `PATH` 里,回退链无需重复一份可能失效的副本;只实现文档化比较子集(精确三元组、major、`>=` / `>` / `<=` / `<`、`^`、`~`、`x` / `*`;`lts/*` 与 `lts/` 都需要 LTS 行映射,当前按未 pin 回退而不猜);`v22.23.3` 这类带 `v` 的 `.nvmrc` 必须先剥前缀。nvm 的 `alias/default` 常只写主版本号(如 `22`),必须按同一 pin 子集在已安装版本里选最高匹配,不能当成完整三元组 `(22,0,0)`。 - 决策(沙箱内启动形态与联网放行):Linux npm 改为 `node `,因为单文件挂载 npm 软链必然丢 `../lib/cli.js`;只读挂载整棵已验证的安装前缀(不是整个 HOME、`FNM_DIR` 或 `NVM_DIR`)。`npm install` 的联网放行只认解析层给出的可信 `(node, npm_cli)` 精确匹配(executable 是对应 node、首个参数是对应 `npm-cli.js`、子命令是 `install`),不再按 basename 判定:项目里同名的 `npm-cli.js` 可写,只加「路径必须绝对」也能绕过;拿不到可信对时一律不放网。 - 决策(范围与非目标):托管版本管理器发现只在 `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 工具链挂载收集与合并、`prepare_command_sandbox_launch` 透传可信 `(node, npm_cli)`、`LinuxSandboxPlan::npm_install_network`、`FNM_MULTISHELL_PATH` 清理)、`command_exec.rs`(Linux npm `node_launcher`、`project_command_actual_target`、`prepare_project_command_launch_spec` 把可信 launcher 交给沙箱、非 Node 程序 PATH 前置工具链 bin)、`process_session_bridge.rs`(`ProcessSessionLaunchPlan::from_launch` 改用实际目标)。 diff --git a/docs/technical/【技术方案】AI游戏创作Agent Runtime V1.1-2026-07-12.md b/docs/technical/【技术方案】AI游戏创作Agent Runtime V1.1-2026-07-12.md index 598d52455..58d0e1f87 100644 --- a/docs/technical/【技术方案】AI游戏创作Agent Runtime V1.1-2026-07-12.md +++ b/docs/technical/【技术方案】AI游戏创作Agent Runtime V1.1-2026-07-12.md @@ -621,7 +621,7 @@ Runner-kill E2E 不再以 latest task 或单个 process record 推断整体恢 开发构建(`debug_assertions`)下,AGC 生成和验证 Web 项目所用的 Node 往往由 fnm / nvm 等版本管理器托管:安装前缀位于用户目录下,活动 `PATH` 指向随 shell 会话变化的临时目录(如 fnm 的 multishell)。V1.11.2 让开发构建的命令沙箱原生解析并只读复用这些托管安装,不再要求用户改系统 Node、把宿主 shim 指到 `/usr/bin`,或把托管目录软链进系统路径。发布构建继续只认随包 bundle,不新增托管版本管理器探测,也不改变 bundle 缺失时的失败口径;Windows 行为不变。 - 版本来源是机器上可枚举的托管安装:fnm 的 `node-versions//installation`(含 `aliases/default` 指向的默认别名)与 nvm 的 `versions/node/`。宿主发现和沙箱只读挂载共用同一套窄叶校验,不允许两处信任口径漂移。 -- 解析优先级为:`.nvmrc` / `.node-version` 的权威 pin 命中 > 宿主 `PATH` 能解析出的可用 Node > 版本管理器回退链(`package.json` `engines.node` 偏好中的最高匹配 > 默认别名 > 已安装最高版本)。不单独查询活动版本(`FNM_MULTISHELL_PATH` / `NVM_BIN`):激活时它已经在 `PATH` 里,回退链重复一份可能失效的副本只会带来误导。`.nvmrc` / `.node-version` 能理解但未安装时必须失败关闭(`node-version-pinned-not-installed`),不得静默回退;`engines.node` 只是偏好,任何情况下都不阻塞。只实现文档化的比较子集(精确三元组、major、`>=` / `>` / `<=` / `<`、`^`、`~`、`x` / `*` 通配、`lts/*`);不支持或无法解析的写法按「未 pin」处理并回退。 +- 解析优先级为:`.nvmrc` / `.node-version` 的权威 pin 命中 > 宿主 `PATH` 能解析出的可用 Node > 版本管理器回退链(`package.json` `engines.node` 偏好中的最高匹配 > 默认别名 > 已安装最高版本)。不单独查询活动版本(`FNM_MULTISHELL_PATH` / `NVM_BIN`):激活时它已经在 `PATH` 里,回退链重复一份可能失效的副本只会带来误导。`.nvmrc` / `.node-version` 能理解但未安装时必须失败关闭(`node-version-pinned-not-installed`),不得静默回退;`engines.node` 只是偏好,任何情况下都不阻塞。只实现文档化的比较子集(精确三元组、major、`>=` / `>` / `<=` / `<`、`^`、`~`、`x` / `*` 通配);`lts/*` 与 `lts/` 都需要 LTS 行映射,当前与其它不支持写法一样按「未 pin」处理并回退,不声称匹配任意版本。 - 只读挂载只允许通过窄叶校验的完整安装前缀(同时含 `bin/node` 与 npm 的 `npm-cli.js`)。HOME、`FNM_DIR` / `NVM_DIR` 根、`aliases` 目录、宽泛用户目录、不完整前缀,以及 canonicalize 后逃逸出受控前缀的 symlink 全部拒绝并失败关闭;不得为了兼容而挂载整个用户 HOME 或版本管理器数据目录。 - Linux 上 npm 不再直接执行 npm shim,而是以受信任的 `node ...` 启动;`npm install` 的联网放行必须与解析层给出的可信 `(node, npm_cli)` 精确一致(executable 是对应的 node、首个参数是对应的 `npm-cli.js`、子命令是 `install`),不能只匹配 basename——项目根可写,项目里同名的 `npm-cli.js` 会骗到 `--share-net`;拿不到可信对时一律不放网。 - 沙箱内联环境不继承宿主活动版本管理器的临时变量(如 fnm multishell 路径);版本管理器 CLI(`fnm` / `nvm`)本身不需要在沙箱内可用。 From 809cea57076116958de7d32963e654feb97753c5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Wed, 7 Oct 2026 14:01:43 +0800 Subject: [PATCH 07/13] =?UTF-8?q?=E8=AE=A9=20.nvmrc=20/=20.node-version=20?= =?UTF-8?q?=E5=8F=AA=E6=94=B9=E5=8F=98=E6=90=9C=E7=B4=A2=E9=A1=BA=E5=BA=8F?= =?UTF-8?q?=E8=80=8C=E4=B8=8D=E9=98=BB=E5=A1=9E?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - resolve_pinned_managed_runtime 命中已安装版本时优先采用;NoMatch 不再返回 node-version-pinned-not-installed,与 Unsupported 一样按未 pin 回退 - fnm / nvm 共用同一条宽松路径;用例覆盖 fnm .nvmrc、fnm .node-version 与 nvm .nvmrc 的命中优先与未安装回退 - PinSelection 补注释说明 NoMatch / Unsupported 都只影响搜索顺序 - 同步 Runtime V1.1、实施计划、decision-log、pitfalls 与里程碑口径,去掉“未安装失败关闭”的旧约定 --- .../src-tauri/src/environment_check.rs | 67 ++++++++++++++----- ...‘】AGC命令沙箱Node版本管理器支持-2026-10-07.md | 4 +- .../shared-memory/decision-log.md | 2 +- docs/project-memory/shared-memory/pitfalls.md | 2 +- ...案】AI游戏创作Agent Runtime V1.1-2026-07-12.md | 2 +- ...¹案】AI游戏创作智能体App实施计划-2026-06-24.md | 2 +- 6 files changed, 57 insertions(+), 22 deletions(-) diff --git a/apps/ai-game-creator-shell/src-tauri/src/environment_check.rs b/apps/ai-game-creator-shell/src-tauri/src/environment_check.rs index 3112bfd34..7d46c0677 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/environment_check.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/environment_check.rs @@ -589,8 +589,11 @@ fn version_matches_pin(version: (u64, u64, u64), pin: &str) -> Option { } enum PinSelection<'a> { + /// pin 被理解且在已安装版本里命中,取其中最高匹配。 Matched(&'a InstalledNodeVersion), + /// pin 被理解但已安装版本里没有命中;调用方按「未 pin」继续回退,不阻塞。 NoMatch, + /// pin 超出当前实现的比较子集;同样按「未 pin」继续回退,不阻塞。 Unsupported, } @@ -698,10 +701,11 @@ fn resolve_pinned_managed_runtime( let Some(pin) = read_project_version_file_pin(root) else { return Ok(None); }; + // `.nvmrc` / `.node-version`(nvm / fnm 都读)只改变搜索顺序,不是硬性要求: + // 命中已安装版本就优先用它,否则继续走 PATH 和回退链,绝不因为“没装”而阻塞。 match select_pinned_installation(&installed, &pin) { PinSelection::Matched(node) => Ok(Some(runtime_from_installed(node, root, path)?)), - PinSelection::NoMatch => Err("node-version-pinned-not-installed".into()), - PinSelection::Unsupported => Ok(None), + PinSelection::NoMatch | PinSelection::Unsupported => Ok(None), } } @@ -1298,11 +1302,13 @@ mod tests { } #[test] - fn version_file_pin_is_authoritative_and_blocks_when_not_installed() { + fn version_file_pin_prefers_the_installed_version_and_falls_back_when_missing() { let fnm_root = tempfile::tempdir().unwrap(); install_fnm_version(fnm_root.path(), "v20.11.0"); install_fnm_version(fnm_root.path(), "v22.23.3"); let project = tempfile::tempdir().unwrap(); + + // 命中已安装版本:.nvmrc 改变搜索顺序,优先选它而不是最高版本。 fs::write(project.path().join(".nvmrc"), "20\n").unwrap(); let runtime = resolve_at_with_managed_roots( project.path(), @@ -1316,19 +1322,48 @@ mod tests { assert_eq!(runtime.source, "fnm"); assert!(runtime.node.to_string_lossy().contains("v20.11.0")); - fs::write(project.path().join(".nvmrc"), "v18.0.0\n").unwrap(); - assert_eq!( - resolve_at_with_managed_roots( - project.path(), - None, - true, - OsStr::new(""), - &[fnm_root.path().to_path_buf()], - &[], - ) - .unwrap_err(), - "node-version-pinned-not-installed" - ); + // fnm 的 .node-version 同样参与搜索顺序。 + fs::remove_file(project.path().join(".nvmrc")).unwrap(); + fs::write(project.path().join(".node-version"), "20\n").unwrap(); + let runtime = resolve_at_with_managed_roots( + project.path(), + None, + true, + OsStr::new(""), + &[fnm_root.path().to_path_buf()], + &[], + ) + .unwrap(); + assert!(runtime.node.to_string_lossy().contains("v20.11.0")); + + // 未安装时不再失败关闭:回退到已安装最高版本。 + fs::write(project.path().join(".node-version"), "v18.0.0\n").unwrap(); + let runtime = resolve_at_with_managed_roots( + project.path(), + None, + true, + OsStr::new(""), + &[fnm_root.path().to_path_buf()], + &[], + ) + .unwrap(); + assert_eq!(runtime.source, "fnm"); + assert!(runtime.node.to_string_lossy().contains("v22.23.3")); + + // nvm 的 .nvmrc 未安装时走同一条宽松回退。 + let nvm_root = tempfile::tempdir().unwrap(); + install_nvm_version(nvm_root.path(), "v20.11.0"); + let runtime = resolve_at_with_managed_roots( + project.path(), + None, + true, + OsStr::new(""), + &[], + &[nvm_root.path().to_path_buf()], + ) + .unwrap(); + assert_eq!(runtime.source, "nvm"); + assert!(runtime.node.to_string_lossy().contains("v20.11.0")); } #[test] diff --git a/docs/project-memory/plans/【里程碑】AGC命令沙箱Node版本管理器支持-2026-10-07.md b/docs/project-memory/plans/【里程碑】AGC命令沙箱Node版本管理器支持-2026-10-07.md index 451a38ff6..93cc9e577 100644 --- a/docs/project-memory/plans/【里程碑】AGC命令沙箱Node版本管理器支持-2026-10-07.md +++ b/docs/project-memory/plans/【里程碑】AGC命令沙箱Node版本管理器支持-2026-10-07.md @@ -9,7 +9,7 @@ ## 目标 -开发构建的 Linux 命令沙箱能原生解析并复用宿主 fnm / nvm 托管的 Node 安装:宿主发现与沙箱只读挂载使用同一套窄叶校验;`.nvmrc` / `.node-version` pin 权威、`engines.node` 仅为偏好;npm 以受信任 `node ` 形态启动且不丢失 `npm install` 的联网判定。不再要求用户改系统 Node、把宿主 shim 指向 `/usr/bin`,或把托管目录软链进系统路径。 +开发构建的 Linux 命令沙箱能原生解析并复用宿主 fnm / nvm 托管的 Node 安装:宿主发现与沙箱只读挂载使用同一套窄叶校验;`.nvmrc` / `.node-version`(nvm / fnm 都读)命中已安装版本时优先、未安装时继续回退,`engines.node` 仅为偏好;npm 以受信任 `node ` 形态启动且不丢失 `npm install` 的联网判定。不再要求用户改系统 Node、把宿主 shim 指向 `/usr/bin`,或把托管目录软链进系统路径。 ## 范围 @@ -35,7 +35,7 @@ ## 验收标准 - [x] 开发构建在没有 `/usr/bin` shim 的情况下从 fnm / nvm 找到 Node,并在沙箱内运行出真实 `node --version` 与 `npm --version`。 -- [x] `.nvmrc` / `.node-version` 指定的版本未安装时命令失败关闭,且不回退到其它已安装版本;`engines.node` 不匹配时不阻塞。 +- [x] `.nvmrc` / `.node-version`(nvm / fnm 都读)指定的版本未安装时命令不阻塞,继续回退到 PATH / `engines` 偏好 / 默认别名 / 已安装最高版本;`engines.node` 不匹配时同样不阻塞。 - [x] 不支持的 pin 写法(如 `iojs`、`>20`、`<=20`)按未 pin 处理并回退,而不是误判为「已理解但未命中」。 - [x] 只有通过窄叶校验的完整安装前缀会被只读挂载;HOME、管理器根、`aliases`、宽泛目录、不完整前缀和逃逸 symlink 全部失败关闭。 - [x] npm 在 Linux 上以受信任 `node ...` 启动,`npm install` 仍被判定为联网命令,普通 `npm run` 仍离线。 diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index ffc538818..c0a1fee55 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -4,7 +4,7 @@ - 背景:开发构建里 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` 最高匹配 > 默认别名 > 已安装最高版本);活动版本不单独查询(`FNM_MULTISHELL_PATH` / `NVM_BIN`),因为激活时它已在 `PATH` 里,回退链无需重复一份可能失效的副本;只实现文档化比较子集(精确三元组、major、`>=` / `>` / `<=` / `<`、`^`、`~`、`x` / `*`;`lts/*` 与 `lts/` 都需要 LTS 行映射,当前按未 pin 回退而不猜);`v22.23.3` 这类带 `v` 的 `.nvmrc` 必须先剥前缀。nvm 的 `alias/default` 常只写主版本号(如 `22`),必须按同一 pin 子集在已安装版本里选最高匹配,不能当成完整三元组 `(22,0,0)`。 +- 决策(版本选择):`.nvmrc` / `.node-version`(nvm / fnm 都读)只改变搜索顺序、不是硬性要求:命中已安装版本就优先用它,能理解但未安装时按未 pin 继续回退,不再返回 `node-version-pinned-not-installed`;`package.json` `engines.node` 同样是偏好,永不阻塞;不支持的写法(`iojs`、`||`、部分 `>` / `<=`、hyphen range、prerelease)按未 pin 回退。整体顺序为 pin 命中 > PATH 可解析的可用 Node > 版本管理器回退链(`engines` 最高匹配 > 默认别名 > 已安装最高版本);活动版本不单独查询(`FNM_MULTISHELL_PATH` / `NVM_BIN`),因为激活时它已在 `PATH` 里,回退链无需重复一份可能失效的副本;只实现文档化比较子集(精确三元组、major、`>=` / `>` / `<=` / `<`、`^`、`~`、`x` / `*`;`lts/*` 与 `lts/` 都需要 LTS 行映射,当前按未 pin 回退而不猜);`v22.23.3` 这类带 `v` 的 `.nvmrc` 必须先剥前缀。nvm 的 `alias/default` 常只写主版本号(如 `22`),必须按同一 pin 子集在已安装版本里选最高匹配,不能当成完整三元组 `(22,0,0)`。 - 决策(沙箱内启动形态与联网放行):Linux npm 改为 `node `,因为单文件挂载 npm 软链必然丢 `../lib/cli.js`;只读挂载整棵已验证的安装前缀(不是整个 HOME、`FNM_DIR` 或 `NVM_DIR`)。`npm install` 的联网放行只认解析层给出的可信 `(node, npm_cli)` 精确匹配(executable 是对应 node、首个参数是对应 `npm-cli.js`、子命令是 `install`),不再按 basename 判定:项目里同名的 `npm-cli.js` 可写,只加「路径必须绝对」也能绕过;拿不到可信对时一律不放网。 - 决策(范围与非目标):托管版本管理器发现只在 `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 工具链挂载收集与合并、`prepare_command_sandbox_launch` 透传可信 `(node, npm_cli)`、`LinuxSandboxPlan::npm_install_network`、`FNM_MULTISHELL_PATH` 清理)、`command_exec.rs`(Linux npm `node_launcher`、`project_command_actual_target`、`prepare_project_command_launch_spec` 把可信 launcher 交给沙箱、非 Node 程序 PATH 前置工具链 bin)、`process_session_bridge.rs`(`ProcessSessionLaunchPlan::from_launch` 改用实际目标)。 diff --git a/docs/project-memory/shared-memory/pitfalls.md b/docs/project-memory/shared-memory/pitfalls.md index 2507b6004..1e5830820 100644 --- a/docs/project-memory/shared-memory/pitfalls.md +++ b/docs/project-memory/shared-memory/pitfalls.md @@ -10,7 +10,7 @@ - **根因 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` 相同的比较子集在已安装版本里选最高匹配。 - **根因 4(联网放行只看 basename 会被同名文件冒充)**:`npm install` 是唯一允许联网的入口,最初只按「首个参数 basename 是 `npm-cli.js` 且第二个参数是 `install`」放行。Linux 上 `command.exec` 不对 `node` 的脚本参数做路径校验,项目根又可写,于是在项目里放一个自写的 `npm-cli.js` 再 `node 项目/npm-cli.js install` 就能拿到 `--share-net`。修法是让联网放行与解析层给出的可信 `(node, npm_cli)` 精确比对,拿不到可信对时一律不放网;只加「路径必须绝对」不够,绝对路径的项目内文件照样绕过。 -- **现行口径**:宿主发现与沙箱挂载共用 `validate_node_installation_prefix`;`.nvmrc` / `.node-version` 权威、`engines.node` 偏好;Linux npm 以 `node ` 启动,`npm install` 联网放行只认解析层给出的可信 `(node, npm_cli)` 精确匹配。契约见技术方案 V1.11.2。 +- **现行口径**:宿主发现与沙箱挂载共用 `validate_node_installation_prefix`;`.nvmrc` / `.node-version`(nvm / fnm 都读)命中已安装版本时优先、未安装时继续回退,`engines.node` 同为偏好;Linux npm 以 `node ` 启动,`npm install` 联网放行只认解析层给出的可信 `(node, npm_cli)` 精确匹配。契约见技术方案 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 --version` 均通过;单元用例覆盖 pin / engines / 不支持写法 / 宽叶与逃逸前缀 / 可信 launcher 联网放行。 ## 2026-10-07 cargo 目标目录里被"刷新 mtime"的陈旧 shared-contracts 会让编译报源文件里明明存在的字段缺失 diff --git a/docs/technical/【技术方案】AI游戏创作Agent Runtime V1.1-2026-07-12.md b/docs/technical/【技术方案】AI游戏创作Agent Runtime V1.1-2026-07-12.md index 58d0e1f87..044cca4c8 100644 --- a/docs/technical/【技术方案】AI游戏创作Agent Runtime V1.1-2026-07-12.md +++ b/docs/technical/【技术方案】AI游戏创作Agent Runtime V1.1-2026-07-12.md @@ -621,7 +621,7 @@ Runner-kill E2E 不再以 latest task 或单个 process record 推断整体恢 开发构建(`debug_assertions`)下,AGC 生成和验证 Web 项目所用的 Node 往往由 fnm / nvm 等版本管理器托管:安装前缀位于用户目录下,活动 `PATH` 指向随 shell 会话变化的临时目录(如 fnm 的 multishell)。V1.11.2 让开发构建的命令沙箱原生解析并只读复用这些托管安装,不再要求用户改系统 Node、把宿主 shim 指到 `/usr/bin`,或把托管目录软链进系统路径。发布构建继续只认随包 bundle,不新增托管版本管理器探测,也不改变 bundle 缺失时的失败口径;Windows 行为不变。 - 版本来源是机器上可枚举的托管安装:fnm 的 `node-versions//installation`(含 `aliases/default` 指向的默认别名)与 nvm 的 `versions/node/`。宿主发现和沙箱只读挂载共用同一套窄叶校验,不允许两处信任口径漂移。 -- 解析优先级为:`.nvmrc` / `.node-version` 的权威 pin 命中 > 宿主 `PATH` 能解析出的可用 Node > 版本管理器回退链(`package.json` `engines.node` 偏好中的最高匹配 > 默认别名 > 已安装最高版本)。不单独查询活动版本(`FNM_MULTISHELL_PATH` / `NVM_BIN`):激活时它已经在 `PATH` 里,回退链重复一份可能失效的副本只会带来误导。`.nvmrc` / `.node-version` 能理解但未安装时必须失败关闭(`node-version-pinned-not-installed`),不得静默回退;`engines.node` 只是偏好,任何情况下都不阻塞。只实现文档化的比较子集(精确三元组、major、`>=` / `>` / `<=` / `<`、`^`、`~`、`x` / `*` 通配);`lts/*` 与 `lts/` 都需要 LTS 行映射,当前与其它不支持写法一样按「未 pin」处理并回退,不声称匹配任意版本。 +- 解析优先级为:`.nvmrc` / `.node-version`(nvm / fnm 都读)命中已安装版本 > 宿主 `PATH` 能解析出的可用 Node > 版本管理器回退链(`package.json` `engines.node` 偏好中的最高匹配 > 默认别名 > 已安装最高版本)。不单独查询活动版本(`FNM_MULTISHELL_PATH` / `NVM_BIN`):激活时它已经在 `PATH` 里,回退链重复一份可能失效的副本只会带来误导。`.nvmrc` / `.node-version` 只改变搜索顺序,不是硬性要求:能理解但未安装时按「未 pin」继续回退,不返回 `node-version-pinned-not-installed`、不阻塞命令;`engines.node` 同样只是偏好,任何情况下都不阻塞。只实现文档化的比较子集(精确三元组、major、`>=` / `>` / `<=` / `<`、`^`、`~`、`x` / `*` 通配);`lts/*` 与 `lts/` 都需要 LTS 行映射,当前与其它不支持写法一样按「未 pin」处理并回退,不声称匹配任意版本。 - 只读挂载只允许通过窄叶校验的完整安装前缀(同时含 `bin/node` 与 npm 的 `npm-cli.js`)。HOME、`FNM_DIR` / `NVM_DIR` 根、`aliases` 目录、宽泛用户目录、不完整前缀,以及 canonicalize 后逃逸出受控前缀的 symlink 全部拒绝并失败关闭;不得为了兼容而挂载整个用户 HOME 或版本管理器数据目录。 - Linux 上 npm 不再直接执行 npm shim,而是以受信任的 `node ...` 启动;`npm install` 的联网放行必须与解析层给出的可信 `(node, npm_cli)` 精确一致(executable 是对应的 node、首个参数是对应的 `npm-cli.js`、子命令是 `install`),不能只匹配 basename——项目根可写,项目里同名的 `npm-cli.js` 会骗到 `--share-net`;拿不到可信对时一律不放网。 - 沙箱内联环境不继承宿主活动版本管理器的临时变量(如 fnm multishell 路径);版本管理器 CLI(`fnm` / `nvm`)本身不需要在沙箱内可用。 diff --git a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md index 349e3344c..3d02ad1c2 100644 --- a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md +++ b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md @@ -853,7 +853,7 @@ 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 install` 的联网判定跟随该真实形态;sandbox 环境剔除 fnm multishell 变量。发布构建仍只认随包 bundle,Windows 不变。定向 Rust 测试与真实 bwrap 内 fnm v22 的 `node` / npm 运行已通过;真实 nvm 端到端待补。 +2026-10-07 V1.11.2 切片:Linux 命令沙箱原生支持 fnm / nvm 托管的 Node。开发构建(`debug_assertions`)按 `.nvmrc` / `.node-version`(nvm / fnm 都读)命中、`package.json` `engines.node` 偏好、版本管理器默认别名和已安装最高版本的顺序选择托管安装;`.nvmrc` / `.node-version` 未安装时继续回退、不阻塞,`engines.node` 和无法解析的写法同样不阻塞。宿主发现与沙箱只读挂载共用同一窄叶校验,只挂载完整安装前缀(含 `bin/node` 与 npm 的 `npm-cli.js`),拒绝 HOME、管理器根、`aliases`、宽泛目录、不完整前缀和逃逸 symlink。Linux npm 以 `node ` 启动,`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,恢复不得重放提交。 From d706b656432a9dd2ebc7d1deba9b2ccc7a81a989 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Wed, 7 Oct 2026 14:17:49 +0800 Subject: [PATCH 08/13] =?UTF-8?q?=E7=BB=99=E7=BC=BA=E5=AE=BF=E4=B8=BBNode?= =?UTF-8?q?=E7=9A=84npm=E7=9B=AE=E6=A0=87=E5=8D=95=E6=B5=8B=E5=8A=A0?= =?UTF-8?q?=E5=8F=AF=E8=A7=81=E8=B7=B3=E8=BF=87=E6=8F=90=E7=A4=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - npm_command_targets_node_plus_npm_cli_on_linux 跳过时 eprintln 输出原因(含 resolve_node_runtime 错误),配 --nocapture / --show-output 可与真实通过区分 --- apps/ai-game-creator-shell/src-tauri/src/command_exec.rs | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/apps/ai-game-creator-shell/src-tauri/src/command_exec.rs b/apps/ai-game-creator-shell/src-tauri/src/command_exec.rs index d648e10d8..d5a9582b5 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/command_exec.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/command_exec.rs @@ -2664,8 +2664,11 @@ mod tests { let root = tempfile::tempdir().unwrap(); std::fs::create_dir_all(root.path().join("game")).unwrap(); // 这条用例走真实 resolve_node_runtime:没有可用宿主 Node 的最小容器里跳过, - // 保持 cargo test 可跑;其它失败仍然照常失败。 - if crate::environment_check::resolve_node_runtime(root.path()).is_err() { + // 保持 cargo test 可跑;跳过时打印可见提示(配 --nocapture / --show-output 看得到)。 + if let Err(error) = crate::environment_check::resolve_node_runtime(root.path()) { + eprintln!( + "skipping npm_command_targets_node_plus_npm_cli_on_linux: no host node runtime({error})" + ); return; } let spec = resolve_project_command_spec_at( From c31b214444635e3fd20f6b29cb24f7e04aa3c81a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Wed, 7 Oct 2026 14:26:54 +0800 Subject: [PATCH 09/13] =?UTF-8?q?=E8=AE=A9=20Linux=20npm=20argv=20?= =?UTF-8?q?=E5=85=A8=E7=A8=8B=E4=BF=9D=E7=95=99=20OsString=20=E4=B8=8D?= =?UTF-8?q?=E5=86=8D=E7=BB=8F=20lossy=20=E5=BE=80=E8=BF=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - project_command_actual_target 返回 Vec,npm_cli 以 as_os_str 原样放入 argv[0],非 UTF-8 路径不再被 U+FFFD 改写 - prepare_command_sandbox_launch / prepare_linux_command_sandbox_launch / LinuxSandboxPlan 的 arguments 改为 OsString,bwrap 直接透传 - command_sandbox_requests_npm_install 按 OsStr 逐字节比对可信 npm_cli,避免误拒合法的 npm install 联网 - process_session_bridge 直接对 OsString 取字节,去掉一次 String 中转 - 用例补齐非 UTF-8 的 npm_cli 必须命中;真机测试同步改用 OsString --- .../src-tauri/src/command_exec.rs | 36 +++++--- .../src-tauri/src/command_sandbox.rs | 86 ++++++++++++------- .../src-tauri/src/process_session_bridge.rs | 2 +- 3 files changed, 78 insertions(+), 46 deletions(-) diff --git a/apps/ai-game-creator-shell/src-tauri/src/command_exec.rs b/apps/ai-game-creator-shell/src-tauri/src/command_exec.rs index d5a9582b5..01f2d1638 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/command_exec.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/command_exec.rs @@ -961,19 +961,24 @@ fn resolve_project_command_executable( } /// Linux 上 npm 以 `node ` 启动;其余情况保持 `executable + args`。 -pub(crate) fn project_command_actual_target(spec: &ProjectCommandSpec) -> (PathBuf, Vec) { +pub(crate) fn project_command_actual_target(spec: &ProjectCommandSpec) -> (PathBuf, Vec) { #[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()); + // 保留原始 OsStr 字节:`npm_cli` 若含非 UTF-8,`to_string_lossy` 会变成 U+FFFD, + // 既改坏实际执行的脚本路径,也让沙箱的可信 npm_cli 比对失配。 + arguments.push(npm_cli.as_os_str().to_owned()); + arguments.extend(spec.arguments.iter().map(OsString::from)); return (node.clone(), arguments); } } ( spec.executable.clone(), - project_command_actual_arguments(spec), + project_command_actual_arguments(spec) + .into_iter() + .map(OsString::from) + .collect(), ) } @@ -2685,8 +2690,14 @@ mod tests { Some("node"), "{executable:?}" ); - assert!(arguments[0].ends_with("npm-cli.js"), "{arguments:?}"); - assert_eq!(&arguments[1..], ["run".to_string(), "build".to_string()]); + assert!( + arguments[0].to_string_lossy().ends_with("npm-cli.js"), + "{arguments:?}" + ); + assert_eq!( + &arguments[1..], + [OsString::from("run"), OsString::from("build")] + ); // 沙箱联网判定要求目标与解析层给出的可信 (node, npm_cli) 精确一致。 let (trusted_node, trusted_npm_cli) = spec @@ -2694,15 +2705,16 @@ mod tests { .as_ref() .expect("Linux npm spec must carry the trusted node launcher"); assert_eq!(trusted_node, &executable); - assert_eq!( - trusted_npm_cli.to_string_lossy().as_ref(), - arguments[0].as_str() - ); + // 逐字节相等:argv[0] 不再经 to_string_lossy 往返。 + assert_eq!(trusted_npm_cli.as_os_str(), arguments[0].as_os_str()); 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"); + assert!( + arguments[0].to_string_lossy().ends_with("npm-cli.js"), + "{arguments:?}" + ); + assert_eq!(arguments[1], OsString::from("install")); } #[cfg(target_os = "linux")] diff --git a/apps/ai-game-creator-shell/src-tauri/src/command_sandbox.rs b/apps/ai-game-creator-shell/src-tauri/src/command_sandbox.rs index 8a1d0e63e..7678a5dea 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/command_sandbox.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/command_sandbox.rs @@ -117,7 +117,7 @@ pub(crate) fn command_sandbox_platform_metadata() -> CommandSandboxMetadata { pub(crate) fn prepare_command_sandbox_launch( root: &Path, executable: &Path, - arguments: &[String], + arguments: &[OsString], cwd: &Path, environment: &[(OsString, OsString)], trusted_npm_install: Option<(&Path, &Path)>, @@ -212,7 +212,7 @@ mod linux { root: PathBuf, cwd: PathBuf, executable: PathBuf, - arguments: Vec, + arguments: Vec, npm_install_network: bool, target_environment: Vec<(OsString, OsString)>, merged_usr_links: Vec<(OsString, PathBuf)>, @@ -302,7 +302,7 @@ mod linux { pub(super) fn prepare_linux_command_sandbox_launch( root: &Path, executable: &Path, - arguments: &[String], + arguments: &[OsString], cwd: &Path, environment: &[(OsString, OsString)], trusted_npm_install: Option<(&Path, &Path)>, @@ -567,7 +567,7 @@ mod linux { /// 给出的可信 `(node, npm_cli)` 精确比对;拿不到可信对时一律不放网。 fn command_sandbox_requests_npm_install( executable: &Path, - arguments: &[String], + arguments: &[OsString], trusted_npm_install: Option<(&Path, &Path)>, ) -> bool { let Some((trusted_node, trusted_npm_cli)) = trusted_npm_install else { @@ -579,7 +579,7 @@ mod linux { .is_some_and(|argument| Path::new(argument) == trusted_npm_cli) && arguments .get(1) - .is_some_and(|argument| argument == "install") + .is_some_and(|argument| argument.as_os_str() == OsStr::new("install")) } /// fnm / nvm / 系统 / 随包 Node 的安装前缀必须整棵只读挂进沙箱:只挂单个 `node` 或 `npm` @@ -784,7 +784,7 @@ mod linux { push_option(&mut args, "--chdir", [plan.cwd.as_os_str()]); args.push(OsString::from("--")); args.push(plan.executable.as_os_str().to_owned()); - args.extend(plan.arguments.iter().map(OsString::from)); + args.extend(plan.arguments.iter().cloned()); CommandSandboxLaunch { executable: plan.bwrap, @@ -1081,7 +1081,7 @@ mod linux { root: PathBuf::from("/workspace/project"), cwd: PathBuf::from("/workspace/project/game"), executable: PathBuf::from("/opt/toolchain/bin/tool"), - arguments: vec!["check".to_string(), "--flag".to_string()], + arguments: vec![OsString::from("check"), OsString::from("--flag")], npm_install_network: false, target_environment: vec![ ( @@ -1204,7 +1204,7 @@ mod linux { let error = prepare_command_sandbox_launch( &root, Path::new("/usr/bin/python3"), - &["-c".to_string(), script], + &[OsString::from("-c"), OsString::from(script)], &root, &[(OsString::from("PATH"), OsString::from("/usr/bin"))], None, @@ -1356,10 +1356,7 @@ mod linux { let node = Path::new("/opt/node/bin/node"); let npm_cli = Path::new("/opt/node/lib/node_modules/npm/bin/npm-cli.js"); let trusted = Some((node, npm_cli)); - let install_arguments = vec![ - npm_cli.to_string_lossy().into_owned(), - "install".to_string(), - ]; + let install_arguments = vec![npm_cli.as_os_str().to_owned(), OsString::from("install")]; assert!(command_sandbox_requests_npm_install( node, &install_arguments, @@ -1369,8 +1366,8 @@ mod linux { assert!(!command_sandbox_requests_npm_install( node, &[ - "/workspace/game/npm-cli.js".to_string(), - "install".to_string(), + OsString::from("/workspace/game/npm-cli.js"), + OsString::from("install"), ], trusted, )); @@ -1384,18 +1381,35 @@ mod linux { assert!(!command_sandbox_requests_npm_install( node, &[ - npm_cli.to_string_lossy().into_owned(), - "run".to_string(), - "build".to_string(), + npm_cli.as_os_str().to_owned(), + OsString::from("run"), + OsString::from("build"), ], trusted, )); // 没有可信 launcher(例如直接 npm shim)时不放网。 assert!(!command_sandbox_requests_npm_install( Path::new("/usr/bin/npm"), - &["install".to_string()], + &[OsString::from("install")], None, )); + // 非 UTF-8 的 npm_cli:argv 保留原始字节时必须逐字节命中 + // (旧实现经 to_string_lossy 会变成 U+FFFD,从而误拒合法的 npm install)。 + { + use std::os::unix::ffi::OsStringExt; + let raw_npm_cli = PathBuf::from(OsString::from_vec( + b"/opt/node/lib/node_modules/npm/bin/n\xffpm-cli.js".to_vec(), + )); + let raw_arguments = vec![ + raw_npm_cli.as_os_str().to_owned(), + OsString::from("install"), + ]; + assert!(command_sandbox_requests_npm_install( + node, + &raw_arguments, + Some((node, raw_npm_cli.as_path())), + )); + } } struct TempTree(PathBuf); @@ -1492,7 +1506,7 @@ print("SANDBOX_OK") let launch = prepare_command_sandbox_launch( &root, Path::new("/usr/bin/python3"), - &["-c".to_string(), script], + &[OsString::from("-c"), OsString::from(script)], &root, &environment, None, @@ -1547,7 +1561,7 @@ print("SANDBOX_OK") (OsString::from("PATH"), runtime.safe_path.clone()), (OsString::from("HOME"), OsString::from("/host/home")), ]; - let run = |executable: &Path, arguments: &[String]| { + let run = |executable: &Path, arguments: &[OsString]| { let launch = prepare_command_sandbox_launch( &root, executable, @@ -1575,8 +1589,8 @@ print("SANDBOX_OK") let version = run( &runtime.node, &[ - "-e".to_string(), - "process.stdout.write(process.version)".to_string(), + OsString::from("-e"), + OsString::from("process.stdout.write(process.version)"), ], ); assert!( @@ -1584,8 +1598,13 @@ print("SANDBOX_OK") "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()]); + let npm_version = run( + &runtime.node, + &[ + runtime.npm_cli.as_os_str().to_owned(), + OsString::from("--version"), + ], + ); assert!(!npm_version.is_empty(), "npm --version returned nothing"); } @@ -1623,7 +1642,7 @@ print("SANDBOX_OK") (OsString::from("PATH"), prefix.join("bin").into_os_string()), (OsString::from("HOME"), OsString::from("/host/home")), ]; - let run = |arguments: &[String]| { + let run = |arguments: &[OsString]| { let launch = prepare_command_sandbox_launch( &root, &node, @@ -1649,17 +1668,14 @@ print("SANDBOX_OK") }; let version = run(&[ - "-e".to_string(), - "process.stdout.write(process.version)".to_string(), + OsString::from("-e"), + OsString::from("process.stdout.write(process.version)"), ]); assert!( version.starts_with('v'), "unexpected node version: {version}" ); - let npm_version = run(&[ - npm_cli.to_string_lossy().into_owned(), - "--version".to_string(), - ]); + let npm_version = run(&[npm_cli.as_os_str().to_owned(), OsString::from("--version")]); assert!(!npm_version.is_empty(), "npm --version returned nothing"); } @@ -1679,7 +1695,11 @@ print("SANDBOX_OK") "from pathlib import Path; import os; assert os.readlink('/proc/self/fd/0') == '/dev/null'; assert all(not Path(f'/proc/self/fd/{{fd}}').exists() for fd in (3, 4, 5, 6)); Path({:?}).write_text('COMMITTED')", marker.to_string_lossy() ); - let arguments = vec!["-c".to_string(), script, "--".to_string()]; + let arguments = vec![ + OsString::from("-c"), + OsString::from(script), + OsString::from("--"), + ]; let environment = vec![(OsString::from("PATH"), OsString::from("/usr/bin"))]; let launch = prepare_linux_command_sandbox_launch( &root, @@ -1690,7 +1710,7 @@ print("SANDBOX_OK") None, ) .expect("prepare real Linux sandbox"); - let target_arguments = arguments.iter().map(OsString::from).collect::>(); + let target_arguments = arguments.clone(); let gate = LaunchGate::new_for_sandbox_stdin(Path::new("/usr/bin/python3"), &target_arguments) .expect("create real Linux sandbox gate"); diff --git a/apps/ai-game-creator-shell/src-tauri/src/process_session_bridge.rs b/apps/ai-game-creator-shell/src-tauri/src/process_session_bridge.rs index 040ed3d36..c3be2b6e6 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/process_session_bridge.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/process_session_bridge.rs @@ -73,7 +73,7 @@ mod linux { target_executable: target_executable.as_os_str().as_bytes().to_vec(), target_arguments: target_arguments .into_iter() - .map(|argument| OsString::from(argument).into_vec()) + .map(|argument| argument.into_vec()) .collect(), }) } From d7ddf5fbc1bf09e17a78719df0e7c5b300a66d83 Mon Sep 17 00:00:00 2001 From: Linghong Date: Wed, 7 Oct 2026 09:20:04 +0000 Subject: [PATCH 10/13] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=20CI=20=E9=A2=86?= =?UTF-8?q?=E5=8F=96=E5=86=B2=E7=AA=81=E5=AF=BC=E8=87=B4=E7=BD=91=E5=85=B3?= =?UTF-8?q?=E6=B0=B8=E4=B9=85=E9=94=81=E5=AE=9A?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 识别 Gitea 已回滚的任务分配冲突,允许 runner 正常重试。 保留未知响应与未完成领取的保护,增加脱敏原因日志。 补充冲突重试及错误边界测试,同步部署文档与共享记忆。 --- deploy/container/README.md | 2 + .../shared-memory/development-workflow.md | 2 + scripts/gitea-runner-fetch-gate.py | 43 +++++++++++++++-- scripts/test_gitea_cache_gate.py | 47 +++++++++++++++++++ 4 files changed, 89 insertions(+), 5 deletions(-) diff --git a/deploy/container/README.md b/deploy/container/README.md index ee7cbeaa0..5bf1fecb8 100644 --- a/deploy/container/README.md +++ b/deploy/container/README.md @@ -186,6 +186,8 @@ timer 在上次执行结束后约 5 分钟再次检查,文件锁防止人工 入口遇到“已发送 FetchTask,但上游响应未完整结束”会持久化 `uncertain` 并拒绝继续领取/自动切换;不会因为客户端的 5 秒超时就认定服务端事务已回滚。其它 RPC 和任务上报仍继续转发。维护者需先核实 Gitea 在途领取事务与该 runner 的任务全部收敛,在维护窗口停止入口,核实并修复它的 `tasks.json` 任务账本及 `uncertain` / `inflight` 标记后再启动并恢复领取。网关按 Gitea 1.26.4 / Runner 2.0.0 的 Connect Protobuf 协议,在最终日志确认及 Runner 执行结束后的最终任务上报确认后才清账;取消响应不等于进程停止,任务 ID 不按超时自动删除。禁止自动删这些标记绕过屏障。停用自动维护先 `systemctl disable --now genarrative-ci-cache.timer`;不要为了停 timer 停止运行中的 CI 容器。已接入的领取入口继续运行,不影响普通 CI。 +Gitea 1.26.4 的 `FetchTask` 若完整返回 HTTP 500、未压缩的 Connect JSON `code=unknown`,且 message 精确为 `rpc error: code = Internal desc = pick task: CreateTaskForRunner: update run <正整数>: run has changed`,网关原样返回错误并允许 runner 下次轮询,不写入 `uncertain`。已核对上游 [CreateTaskForRunner](https://github.com/go-gitea/gitea/blob/v1.26.4/models/actions/task.go) 和 [PickTask](https://github.com/go-gitea/gitea/blob/v1.26.4/services/actions/task.go):此并发更新错误发生在分配事务提交前并回滚。该例外不覆盖其它 5xx、未知格式、响应截断或网络断连,也不会清除既有保护锁、维护暂停和活跃任务账本。网关将已回滚冲突和进入保护状态的原因分类写入容器日志,不输出 RPC 正文或凭据;旧版本留下的保护锁仍须核实后人工恢复。 + 人工 bootstrap 预热容器上限为 4 核、12 GiB,移除 capabilities,不挂宿主目录/socket,也不注入 Git/OSS/Jenkins 凭据。源码通过 `git archive` 复制,当前工作区、ignored 文件和 `.git` 不进入容器。最终从原镜像重新组装,仅复制 `/opt/genarrative-ci/rust-cache` 的 sccache、对象和来源元数据,不提交含源码/target 的预热容器;镜像本身的下载缓存与工具链校验保持原样。 快照由固定 Image ID 分发,每个 job 仅修改容器自己的写时复制层,缓存上限 4 GiB,只有 master push 在结束前回传新增对象;PR 不扫描基线、不打包、不回传。`ci-rust-cache.sh prepare` 清空继承的 `SCCACHE_*` 远程配置,使用独立配置和 Unix socket;旧镜像、工具链不匹配或限时 wrapper 探测失败时使用直接 rustc,正式编译启用 sccache 的 server IO 错误回退。真实编译/测试失败保留非零退出码。`report` 输出命中统计并停止本 job daemon,分片日志输出独立编译耗时。sccache 0.18.0 的只读模式在 miss 后仍打包再拒绝写入,不能用它宣称零 miss 开销;普通 CI 继续只向容器层写入。 diff --git a/docs/project-memory/shared-memory/development-workflow.md b/docs/project-memory/shared-memory/development-workflow.md index c4e72194e..bd74d29c4 100644 --- a/docs/project-memory/shared-memory/development-workflow.md +++ b/docs/project-memory/shared-memory/development-workflow.md @@ -112,6 +112,8 @@ Gitea 基础镜像通过专用 `genarrative-ci-images` Buildx builder 持久复 Gitea Rust 缓存自动维护由宿主 `genarrative-ci-cache.timer` 收集同一 master push run 六个 Rust job 的原生 V4 缓存产物,不重复执行 Cargo 预热。只传本轮新 key,命中对象只传使用时间;宿主与真实来源镜像对象合并、去重、按新近使用时间裁剪到 4 GiB,从无对象缓存基础镜像重新组装。源 run 不要求全绿,但取消、缺组、旧 attempt、未完成上传或混用来源镜像不得采用。网关暂停新 FetchTask、在途领取结束、持久化任务账本清空且内层活动容器为空才切换,不打断运行中的 CI。首次接入/升级网关需空闲窗口;Token 只需普通仓库 `write:repository`,不查管理员 API。候选装载后清理已收集 artifact,遗留项保留 7 天;真实 master CI 验证后才清理旧镜像,保留当前、一个回滚版、基础镜像及容器引用。部署入口见 `deploy/container/README.md`,合并代码不等于服务启用。 +领取网关只对已核对 Gitea 1.26.4 事务回滚的精确 `CreateTaskForRunner: update run : run has changed` Connect 错误允许 runner 重试,避免普通并发更新冲突永久阻断 CI;完整响应格式约束见 `deploy/container/README.md`。其它未知领取结果继续持久化保护,不自动清除 `uncertain`;恢复既有锁前需核实任务与在途请求收敛。容器日志只记录固定原因分类,不打印请求、响应或认证信息。 + 修改 Gitea workflow 的 job 显示名称、ID 或缓存导出组时,必须同步维护器的 `JOBS` / `RUST_JOB_IDS`;`test_gitea_cache_maintenance.py` 直接对照实际 workflow 检查全集和导出映射,避免自动刷新或镜像验收因名单漂移长期等待。维护器 `Api.request` 的 `method` 是必填关键字参数,GET 也必须显式指定,不根据 body 推断请求方法。 Gitea 缓存部署必须区分网络:runner 的 RPC 走 `gitea-runner-fetch-gate:8080`;内层 job 的 checkout/上传走映射到 `172.30.0.3` 的 `http://genarrative-station/git`;宿主专用 clone 走 `http://127.0.0.1:3003`。不要把 runner 可达的 `gitea:3000` 配给 job。内层 Docker 使用 `10.240.0.0/16`、每 job `/24` 的默认地址池,避开外层 `172.30/172.31` 网段;恢复领取前必须在真实 job 网络里验证 checkout 与 Gitea API,不能只验证 FetchTask。具体配置与遗留空网络处理见 `deploy/container/README.md`。 diff --git a/scripts/gitea-runner-fetch-gate.py b/scripts/gitea-runner-fetch-gate.py index 7f8346e65..49de66af8 100644 --- a/scripts/gitea-runner-fetch-gate.py +++ b/scripts/gitea-runner-fetch-gate.py @@ -8,7 +8,9 @@ import io import json import os from pathlib import Path +import re import socketserver +import sys import threading import time import urllib.parse @@ -22,6 +24,30 @@ HOP_HEADERS = { RPC_PREFIX = "/api/actions/runner.v1.RunnerService/" +def fetch_conflict_rolled_back(status, body, headers): + """仅识别 Gitea 1.26.4 在任务分配事务提交前返回的 run 更新冲突。""" + # PickTask/WithTx -> CreateTaskForRunner -> UpdateRunJob -> UpdateRun。 + # 该错误会回滚任务分配;其它 5xx、截断响应和未知协议仍须阻断领取。 + if (status != 500 or len(body) > 4096 + or headers.get("Content-Encoding", "identity").lower() != "identity" + or headers.get("Content-Type", "").split(";", 1)[0] != "application/json"): + return False + try: + error = json.loads(body) + except (ValueError, UnicodeError): + return False + return (isinstance(error, dict) and error.get("code") == "unknown" + and isinstance(error.get("message"), str) + and re.fullmatch( + r"rpc error: code = Internal desc = pick task: CreateTaskForRunner: " + r"update run [1-9][0-9]*: run has changed", error["message"]) is not None) + + +def diagnostic(message): + # 仅传固定分类,不输出上游正文、URL 或 RPC 凭据。 + print(f"[runner-fetch-gate] {message}", file=sys.stderr, flush=True) + + def protobuf_fields(data): """只解码已核对的 actions-proto-go v0.4.1 字段,不引入 protobuf 运行时。""" fields = {} @@ -153,8 +179,10 @@ class Gate: os.replace(temporary, self.directory / "tasks.json") self.sync_directory() - def fail_closed(self): + def fail_closed(self, reason): with self.lock: + if not self.uncertain: + diagnostic(f"uncertain: {reason}; operator recovery required") self.uncertain = self.paused = True self.mark("uncertain") @@ -238,6 +266,8 @@ class Gate: with self.lock: self.inflight -= 1 if not completed: + if not self.uncertain: + diagnostic("uncertain: incomplete FetchTask response; operator recovery required") self.uncertain = self.paused = True self.mark("uncertain") if self.inflight == 0 and not self.uncertain: @@ -369,11 +399,14 @@ class Proxy(http.server.BaseHTTPRequestHandler): completed = True if is_fetch: try: - if oversized or response.status != 200: + if not oversized and fetch_conflict_rolled_back(response.status, result, response.headers): + diagnostic("FetchTask transaction rolled back: run update conflict; runner may retry") + elif oversized or response.status != 200: raise ValueError("unknown FetchTask result") - self.server.gate.assigned(rpc_fields(bytes(result), response.headers)) + else: + self.server.gate.assigned(rpc_fields(bytes(result), response.headers)) except (ValueError, TypeError, OSError, EOFError, zlib.error): - self.server.gate.fail_closed() + self.server.gate.fail_closed("unrecognized FetchTask response or assignment tracking failure") self.server.gate.leave(True) is_fetch = False if oversized: @@ -398,7 +431,7 @@ class Proxy(http.server.BaseHTTPRequestHandler): self.server.gate.reported(method, rpc_fields(request, self.headers), rpc_fields(response, headers)) except (ValueError, TypeError, OSError, EOFError, zlib.error): - self.server.gate.fail_closed() + self.server.gate.fail_closed("task report tracking failure") class Control(socketserver.StreamRequestHandler): diff --git a/scripts/test_gitea_cache_gate.py b/scripts/test_gitea_cache_gate.py index ddc3a4a2c..45a572acf 100644 --- a/scripts/test_gitea_cache_gate.py +++ b/scripts/test_gitea_cache_gate.py @@ -326,6 +326,53 @@ class GateTests(unittest.TestCase): self.assertTrue(self.gate.control("status")["uncertain"]) self.assertTrue(MODULE.Gate(self.temp.name).control("status")["uncertain"]) + def test_rolled_back_run_conflict_allows_next_fetch_and_preserves_tasks(self): + self.fetch_task() + body = json.dumps({"code": "unknown", "message": + "rpc error: code = Internal desc = pick task: CreateTaskForRunner: " + "update run 3673: run has changed"}).encode() + self.upstream.responses[FETCH] = body + self.upstream.statuses[FETCH] = 500 + self.upstream.content_type = "application/json" + self.assertEqual(self.request(FETCH), (500, body)) + self.assertEqual(core_state(self.gate.control("status")), + {"paused": False, "inflight": 0, "uncertain": False}) + self.assertEqual(self.gate.control("status")["task_ids"], ["42"]) + self.assertFalse(MODULE.Gate(self.temp.name).control("status")["uncertain"]) + self.assertFalse((Path(self.temp.name) / "inflight").exists()) + self.upstream.statuses[FETCH] = 200 + self.upstream.content_type = "application/proto" + self.upstream.responses[FETCH] = field(1, field(1, 43)) + self.assertEqual(self.request(FETCH)[0], 200) + self.assertEqual(self.gate.control("status")["task_ids"], ["42", "43"]) + + def test_unrecognized_fetch_error_still_latches_uncertainty(self): + self.upstream.release.set() + self.upstream.content_type = "application/json" + self.upstream.statuses[FETCH] = 500 + self.upstream.responses[FETCH] = json.dumps({"code": "unknown", "message": + "rpc error: code = Internal desc = pick task: commit failed"}).encode() + self.assertEqual(self.request(FETCH)[0], 500) + self.assertTrue(self.gate.control("status")["uncertain"]) + self.assertEqual(self.request(FETCH)[0], 503) + + def test_conflict_exception_requires_exact_error_envelope(self): + error = {"code": "unknown", "message": + "rpc error: code = Internal desc = pick task: CreateTaskForRunner: " + "update run 3673: run has changed"} + headers = {"Content-Type": "application/json"} + body = json.dumps(error).encode() + for status, payload, response_headers in [ + (502, body, headers), (200, body, headers), + (500, body, {"Content-Type": "text/html"}), + (500, body, {**headers, "Content-Encoding": "gzip"}), + (500, body[:-1], headers), (500, b'[]', headers), + (500, json.dumps({**error, "code": "internal"}).encode(), headers), + (500, json.dumps({**error, "message": error["message"] + " extra"}).encode(), headers), + ]: + with self.subTest(status=status, payload=payload, headers=response_headers): + self.assertFalse(MODULE.fetch_conflict_rolled_back(status, payload, response_headers)) + def test_task_report_without_observed_assignment_requires_idle_bootstrap(self): self.assertEqual(self.request(UPDATE, state(42))[0], 200) self.wait_for(lambda: self.gate.control("status")["uncertain"]) From 27e385785fd9e47decd03cb6971e77cb139c1127 Mon Sep 17 00:00:00 2001 From: Linghong Date: Wed, 7 Oct 2026 19:35:20 +0800 Subject: [PATCH 11/13] =?UTF-8?q?=E4=BF=AE=E6=AD=A3=E7=BE=8E=E6=9C=AF?= =?UTF-8?q?=E5=8C=85=20API=20=E6=8C=87=E5=8D=97=E3=80=81AGC=20=E8=B0=83?= =?UTF-8?q?=E7=94=A8=E4=B8=8E=E5=88=87=E7=89=87=E7=94=A8=E9=80=94=E5=88=A4?= =?UTF-8?q?=E5=AE=9A=20(#628)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit AGC 美术包此前把内容、风格和排布要求放在未被提示词消费的元数据中,并要求恰好四张切片、按返回顺序分配用途。本 PR 完成以下三项修正: - [x] API 文档:修正整个 `generationInputs` 的定位、保存与读取方式、接口保留/重建规则及已知消费者。任意元数据不会自动进入提示词,服务端行为和校验约束保持不变。 - [x] AGC 请求:美术包将原 brief 和内容、风格、排布要求写入实际消费的 `iconDescriptions`;普通图标入口继续原文单项透传。参考图、比例和尺寸使用正式参数,同步工具说明与随包 skill。 - [x] 客户端切片处理:美术包和直接生图工具不再暴露或发送 `sliceCount`,取消固定四片和按序赋义;按实际产物保存、登记和返回。切片目录按源图集隔离,文件名使用中性序号,普通清单指向真实总图。 四类素材需求只表达内容覆盖,不能证明四个连通区域或固定语义顺序。两个工具返回总图、完整切片路径及资源身份,预览逐项标注路径,未内嵌的图片列出路径供 Agent 查看。Agent 看图识别实体、状态和用途后再接入或处理;有效总图零切片时仍交付并保留告警。 本轮切片改造仅限客户端:服务端 API、请求侧 sliceCount 契约、切分算法和计费行为不变。不新增为数量不符找回、补切或凑数的工作流。保留文件完整性、平台身份、资源预算、事务与重生成恢复;兼容历史四用途产物和旧数量参数账本,旧请求正文、幂等键及 operation 不改写,候选身份不唯一时要求对账。完成结果快照补存切分声明,避免重放丢失响应字段。 验证: - API 文档阶段:MCP 25 项、External editor API 30 项测试及 OpenAPI 约束等价检查通过。 - 本轮:317 项定向 Rust 测试通过(生成 118、Direct 运行时 93、工具桥 44、MCP 32、账本 6、提示词 24)。覆盖新请求完全省略字段、0/2/4/6 片、目录隔离、完整资源投影、动态事务回滚、旧请求和旧输出槽恢复;已受理任务恢复不新增生成 POST。 - cargo check、rustfmt、skill-pack:check、文档索引、变更文件编码及 git diff --check 通过。 - 未调用真实付费 Provider,未做客户端视觉试玩;PR 保持草稿,等待视觉及产品验收。 Closes #525 Reviewed-on: https://git.genarrative.world/git/GenarrativeAI/Genarrative/pulls/628 Co-authored-by: Linghong Co-committed-by: Linghong --- .../genarrative-external-editor-api/SKILL.md | 1 + .../references/capability-routing.md | 4 +- .../references/requests-and-outputs.md | 38 +- .../prompts/runtime/texts/direct-tools.json | 9 +- .../prompts/runtime/texts/execution.json | 2 +- .../prompts/runtime/texts/media.json | 4 +- .../prompts/runtime/texts/native-tools.json | 3 +- .../prompts/runtime/texts/runtime.json | 2 +- .../resources/agc-skills/manifest.json | 4 +- .../agc-skills/taonier-art-assets/SKILL.md | 23 +- .../references/platform-art-contract.md | 12 +- .../src-tauri/src/agent/art_manifest.rs | 150 +- .../agent/direct_runtime/art_package_tests.rs | 507 ++++++ .../src-tauri/src/agent/direct_runtime/mod.rs | 482 +++--- .../src-tauri/src/agent/direct_tool_bridge.rs | 215 +-- .../src-tauri/src/agent/direct_tools_mcp.rs | 17 +- .../src/agent/generation/canvas_generation.rs | 1366 +++++++++++++---- .../generation/external_generation_state.rs | 42 +- .../src/agent/tool/generate_image/error.rs | 16 +- .../src-tauri/src/commands.rs | 2 - .../genarrative-external-v1.openapi.json | 31 +- docs/project-memory/shared-memory/pitfalls.md | 25 +- ...¹案】AI游戏创作智能体App实施计划-2026-06-24.md | 28 +- scripts/check-pingora-route-parity.mjs | 16 +- 24 files changed, 2265 insertions(+), 734 deletions(-) create mode 100644 apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime/art_package_tests.rs diff --git a/.codex/skills/genarrative-external-editor-api/SKILL.md b/.codex/skills/genarrative-external-editor-api/SKILL.md index c23c63777..d57252df5 100644 --- a/.codex/skills/genarrative-external-editor-api/SKILL.md +++ b/.codex/skills/genarrative-external-editor-api/SKILL.md @@ -16,6 +16,7 @@ Connect to `https://www.genarrative.world/api/external/v1/mcp` using Streamable - Upload local references using `prepare_asset_upload`: request a ticket, transfer the file from the client, then confirm the object. Confirmation does not create a canvas layer or a project/library record. Use the reference type accepted by the target tool; some operations require a registered resource or asset ID rather than an object key. - Generation is paid and asynchronous. Keep one stable `idempotencyKey` per logical generation and retain the returned `operationId`. Call `check_generation` according to `pollAfterMs`; consume `result` only after `completed`, and report the safe error on `failed`. A polling timeout does not justify another generation. - Read actual artifacts and warnings before claiming the requested deliverable is complete. Use project/library reads for complete persisted records, and `find_assets` with `action=get_download_url` for temporary media access. +- Treat `generationInputs` as generation context and provenance metadata, subject to each endpoint's preservation and rebuilding rules. Arbitrary fields, including `artSpec`, do not automatically enter the provider prompt or override request parameters. Put generation requirements in the endpoint's explicit inputs; see [Generation Inputs Metadata](references/requests-and-outputs.md#generation-inputs-metadata) for persistence, reads, and known consumers. - Keep API Keys and temporary upload/download credentials out of chat, repository files, and logs. Business calls operate within the API Key's owner and scopes. ## Documentation Navigation diff --git a/.codex/skills/genarrative-external-editor-api/references/capability-routing.md b/.codex/skills/genarrative-external-editor-api/references/capability-routing.md index 945474c68..2d6da819a 100644 --- a/.codex/skills/genarrative-external-editor-api/references/capability-routing.md +++ b/.codex/skills/genarrative-external-editor-api/references/capability-routing.md @@ -20,7 +20,7 @@ For generation, pass `projectId` with `canvasCompletion` when the result should ## Art Spec Routing -For a series of related art requests, an optional reusable spec can carry the shared requirements: +For a series of related art requests, an optional caller-defined spec can record the shared requirements. `artSpec` is an organizational convention inside `generationInputs`, not a server-defined generation parameter schema: ```json { @@ -35,7 +35,7 @@ For a series of related art requests, an optional reusable spec can carry the sh } ``` -Infer what is already clear and ask only for missing fields that block the selected endpoint. Reuse the current spec unless the user changes style, subject family, palette, format, or constraints. Store structured context under `generationInputs.artSpec` where supported and summarize it in the prompt when useful. +Infer what is already clear and ask only for missing fields that block the selected endpoint. Reuse the current spec unless the user changes style, subject family, palette, format, or constraints. Where the endpoint preserves custom metadata, `generationInputs.artSpec` can retain this context for later retrieval. To affect generation, always translate the relevant requirements into the endpoint's explicit inputs: image `prompt`, scene `sceneContent` / `stylePreset` / `customStyle`, or spritesheet `iconDescriptions`, plus the actual size and reference parameters. Neither `artSpec.references` nor `generationInputs.references` supplies reference media by itself. Scene and sound-effect generation rebuild their metadata and do not preserve an arbitrary `artSpec`; keep a caller-side copy when needed. See [Generation Inputs Metadata](requests-and-outputs.md#generation-inputs-metadata) for the rules applying to the entire `generationInputs` field. ## Intent Map diff --git a/.codex/skills/genarrative-external-editor-api/references/requests-and-outputs.md b/.codex/skills/genarrative-external-editor-api/references/requests-and-outputs.md index 10e41f319..37647c6f6 100644 --- a/.codex/skills/genarrative-external-editor-api/references/requests-and-outputs.md +++ b/.codex/skills/genarrative-external-editor-api/references/requests-and-outputs.md @@ -9,6 +9,7 @@ Use this reference to build generation payloads, carry canvas/library context, p - [Polling State Machine](#polling-state-machine) - [Canvas and Asset-Library Completion](#canvas-and-asset-library-completion) - [Saving Existing Canvas Layout](#saving-existing-canvas-layout) +- [Generation Inputs Metadata](#generation-inputs-metadata) - [Art Spec and Image Request](#art-spec-and-image-request) - [Local Reference Requests](#local-reference-requests) - [Compact Completed Result](#compact-completed-result) @@ -135,7 +136,7 @@ Background removal preserves the source image dimensions. For normal canvas plac Character animation accepts `assetFolderId` and `assetLabel` and persists the generated sequence. Consume the returned animation artifacts and persisted identities; do not synthesize a duplicate animation asset from the first frame. Use complete project/library records when complete persisted state is needed. -For the lower-level asset/resource creation endpoints, `generationInputs` is replayable request context rather than a media-runtime container. When `assetKind` is `character-animation`, the server rejects legacy runtime keys including `characterAnimation`, `frames`, `previewVideoPath`, `frameCount`, `fps`, and `durationSeconds`; send the formal sequence through `imageSequenceFrames` and `imageSequenceDurationMs`. Internal processing audit keys such as `screenColorHex`, `mattingProvider`, and `mattingModel` are removed before persistence. +For the lower-level asset/resource creation endpoints, `generationInputs` follows the metadata and media-runtime boundaries described in [Generation Inputs Metadata](#generation-inputs-metadata). ## Saving Existing Canvas Layout @@ -146,15 +147,46 @@ For the lower-level asset/resource creation endpoints, `generationInputs` is rep `edit_canvas/register_resource` registers existing media but does not create a canvas layer. `organize_asset_library/create_asset` creates metadata but does not upload or generate media. For generated media placement, prefer the generation tool's supported `canvasCompletion`; inspect returned identities before registering anything again. +## Generation Inputs Metadata + +`generationInputs` is optional JSON generation context: an input snapshot, provenance, and supported application metadata. An object is the useful shape for named fields; accepting `JsonValue` does not promise lossless storage of every JSON value. The selected endpoint may sanitize, augment, or rebuild it. Arbitrary metadata is not automatically included in the provider prompt, used as generation parameters, or applied to a later request. Put requirements into the endpoint's explicit inputs, such as `prompt`, `iconDescriptions`, `sceneContent`, style/size options, and its actual reference-media fields. + +When an operation persists project resources or library assets, their accepted metadata is saved with those records. Retrieve it through `GET /api/external/v1/editor/projects/{projectId}` (`project.resources[].generationInputs`) or `GET /api/external/v1/editor/assets/library` (`library.assets[].generationInputs`), subject to owner/scopes and record existence. MCP equivalents are `find_assets/get_project_resources` and `find_assets/list_library`. A compact generation result is not a complete metadata read. Metadata is not embedded in the image bytes, and uploading an image does not restore a previous record's metadata. + +| Operation | Current handling of `generationInputs` | +| --- | --- | +| Create a project resource or library asset | Preserve accepted metadata after removing client-supplied `references` and internal audit keys. This registers a record; it does not execute a generation recipe. | +| Generate an image | Preserve custom object fields on the provider's original image record after sanitization; rebuild `references` from actual authorized reference inputs. Character transparency processing may create a separate derived record. | +| Generate a scene | Rebuild V2 `version`, `action=scene.generate`, `fields`, and `references` from normalized scene parameters. Only the exact client marker `source=ai-game-creator-client` is retained additionally; arbitrary custom fields such as `artSpec` are discarded. | +| Edit an image | Preserve accepted custom fields and rebuild source/auxiliary `references` from `sourceReferenceId` and the actual reference inputs. | +| Remove a background | Preserve accepted custom fields and rebuild `references` from the actual source. Processing audit metadata remains internal. This metadata does not configure the removal operation. | +| Generate an icon spritesheet or extract UI assets | Preserve accepted custom fields on the provider's original image record. Transparent sheets and slices have separate processing-stage/source metadata and do not automatically inherit all custom fields. Follow the returned source references to read the original context. | +| Generate a character animation | Preserve accepted generation context on the final sequence record; formal frames and sequence duration are separate media fields, not runtime data inside `generationInputs`. | +| Generate a video | Preserve accepted context; object metadata can also receive an added/updated duration display field from normalized request parameters. | +| Generate a sound effect | Rebuild `fields`, empty `references`, and `soundEffect` metadata from the actual generation. Only `source`, `conversationId`, and `toolCallMessageId` are copied from a caller-supplied object; arbitrary fields such as `artSpec` are discarded. | +| Generate background music | Preserve accepted context after sanitization; generation parameters come from the explicit request fields. | + +Preservation does not mean every field is inert. Known consumers include: + +- The canvas reads `fields` / `references` for input display. Recognized V2 `version`, `action`, and stable field/reference IDs support restoring supported generation panels; arbitrary metadata does not guarantee a UI display or a “modify” action. +- Icon spritesheet generation reads the saved reference spec's `fields` entry titled `游戏类型` to select genre-specific prompt text. This does not cause arbitrary fields in the current request to be interpreted as prompts. +- Integrated clients use recognized `source` markers for queue/idempotency namespaces and result projections. These are application markers, not authentication or model instructions. + +Reference metadata does not grant access or select reference media. Image operations rebuild it from actual inputs and authorized records; direct resource/asset creation drops caller-supplied references. Supply the documented `referenceId`, `sourceReferenceId`, or media-reference fields. Do not assume other operations provide the same provenance rebuilding. + +Persisted metadata is bounded to 64 KiB of serialized JSON and cannot contain inline media Data URLs. Top-level internal audit fields `screenColorHex`, `mattingProvider`, and `mattingModel` are stripped from client metadata and owner-facing reads; the server can store its own internal audit values. When `assetKind=character-animation`, legacy runtime keys such as `characterAnimation`, `frames`, `previewVideoPath`, `frameCount`, `fps`, and `durationSeconds` are rejected; use `imageSequenceFrames` and `imageSequenceDurationMs` for formal sequence data. Omitting metadata or sending null does not replace required request parameters; empty objects may normalize to null. + +For reuse, read the saved record, recover the relevant requirements, and explicitly construct the next request. Keep the original complete request and idempotency key for retries: stored metadata, especially derived-asset metadata, is not a complete replayable HTTP payload. + ## Art Spec and Image Request Game scenes have a dedicated structured route: `POST /api/external/v1/editor/scenes/generations` with `sceneContent` and `stylePreset` (`customStyle` required when `stylePreset` is `custom`). The server assembles the full provider prompt; a caller-assembled `prompt` is not accepted. `kind: "scene"` and `assetKind: "scene"` remain invalid on generic image generation and return HTTP `400` before any generation job is queued. -When maintaining a reusable art spec, carry it in `generationInputs.artSpec` and reflect important constraints in the prompt. This is an example with both canvas and library destinations, not a requirement for every generation: +`generationInputs.artSpec` is an optional caller-defined metadata convention with no automatic prompt or parameter effect. In the generic image request below, the prompt repeats the desired style, palette, composition, and exclusions, while `aspectRatio` and `imageSize` set the actual format. The saved spec can help a caller construct later requests. This example uses both canvas and library destinations; neither the spec nor both destinations are required for every generation. Do not copy this metadata expectation to the scene route, which rebuilds its own context. ```json { - "prompt": "一张横版幻想森林背景,适合游戏主视觉,无文字", + "prompt": "一张横版幻想森林背景,适合游戏主视觉,手绘游戏概念图风格,翡翠绿与金色光斑,中心留出角色站位,无文字、无 UI 按钮", "aspectRatio": "16:9", "imageSize": "1K", "projectId": "", diff --git a/apps/ai-game-creator-shell/src-tauri/prompts/runtime/texts/direct-tools.json b/apps/ai-game-creator-shell/src-tauri/prompts/runtime/texts/direct-tools.json index b51fa82bd..17a012862 100644 --- a/apps/ai-game-creator-shell/src-tauri/prompts/runtime/texts/direct-tools.json +++ b/apps/ai-game-creator-shell/src-tauri/prompts/runtime/texts/direct-tools.json @@ -11,18 +11,17 @@ "agc_update_plan.description": "更新当前回合的进度计划,字段与 update_plan 相同:可选 explanation,以及 plan 中的 step/status(pending、in_progress、completed)。它可与其它独立工具并行;同一计划的连续更新按依赖顺序提交。计划完成只表示进度,不代替宿主交付验收。", "agc_write_file.parameters.path": "当前项目根下的相对路径,例如 game/index.html、assets/manifest.json 或 data/gameplay-spec.md", "agc_write_file.parameters.content": "仅填写目标文件的完整原始 UTF-8 正文", - "taonier_prepare_game_art.description": "创建或恢复当前 AGC 项目的陶泥儿标准游戏美术包。默认复用有效美术包;根据当前对话需要选择 regenerate 重新生成。授权使用 AGC 客户端当前登录会话;遇到 401/403 时报告客户端登录或权限状态异常并停止。", - "taonier_prepare_game_art.parameters.brief": "面向当前游戏的简洁视觉需求", + "taonier_prepare_game_art.description": "创建或恢复当前 AGC 项目的陶泥儿标准游戏美术包,返回总图、实际切片与路径标注预览;需看图识别用途,内容要求不保证切片数量或顺序。默认复用有效美术包;根据当前对话需要选择 regenerate 重新生成。授权使用 AGC 客户端当前登录会话;遇到 401/403 时报告客户端登录或权限状态异常并停止。", + "taonier_prepare_game_art.parameters.brief": "面向当前游戏的简洁视觉需求,不超过 200 字符。写明主题、风格、玩家主体及状态、目标或收集物、障碍或场景元素、反馈特效等具体需要的素材。需求明确时列出各项素材的数量、状态,并要求独立排布、留出切分间距;数量未确定时不编造。工具会补齐沿用规范图和素材独立排布的通用要求;数量是生成目标,内容类别和数量均不保证实际切片数量或返回顺序,须看图确认用途", "taonier_prepare_game_art.parameters.mode": "缺省安全复用有效美术包;Codex 仅在当前对话需要换一套或重新生成时使用 regenerate", "agc_generate_image.description": "生成一张新图片:普通插画、角色立绘、统一视觉规范图、游戏 UI 设计图或透明游戏素材图集。仅在用户明确要求生成新图时调用。", - "agc_generate_image.parameters.prompt": "完整图片描述;普通图片、角色、规范图、UI 设计图或透明图集均可。kind=icon-spritesheet 时,去除首尾空白后的描述须为 1 到 200 个 Unicode 字符,保留内部换行并作为单条 iconDescriptions 原样提交;超限拒绝,不截断、不拆条,客户端不追加生图指令", + "agc_generate_image.parameters.prompt": "完整图片描述;普通图片、角色、规范图、UI 设计图或透明图集均可。kind=icon-spritesheet 时,需求明确则列出各项素材的数量、状态,并要求独立排布、留出切分间距;数量未确定时不编造。数量是生成目标,不保证实际切片数量或返回顺序,须看图确认用途。去除首尾空白后的图集描述须为 1 到 200 个 Unicode 字符,保留内部换行并作为单条 iconDescriptions 原样提交;超限拒绝,不截断、不拆条,客户端不追加生图指令", "agc_generate_image.parameters.kind": "image=普通新图(保留生成原图),character=角色图(纯色底生成后自动抠图,产出透明背景立绘,prompt 只描述角色主体),icon-spec=统一视觉规范图,ui-design=完整 UI 设计图,icon-spritesheet=透明游戏素材图集(纯色底生成后自动抠图并切片,项目须已有 icon-spec 规范图),publication-material=发布宣传图", "agc_generate_image.parameters.assetName": "本地素材的人类可读显示名称", "agc_generate_image.parameters.outputPath": "可选项目相对输出路径,必须位于 assets/ 且不能覆盖已有文件", - "agc_generate_image.parameters.sliceMode": "仅适用于 kind=icon-spritesheet,且必填:需求明确要求等分网格、固定槽位或指定行列数时传 grid,并用 gridX/gridY 传入需求中的行列数;自由排布、数量不定或只要求一张图集时传 connected-components,需要约束素材张数时用 sliceCount。", + "agc_generate_image.parameters.sliceMode": "仅适用于 kind=icon-spritesheet,且必填:需求明确要求等分网格、固定槽位或指定行列数时传 grid,并用 gridX/gridY 传入需求中的行列数;自由排布、数量不定或只要求一张图集时传 connected-components,实际切片数量由图像决定,内容需求不保证数量或用途顺序;查看返回图片后识别用途。", "agc_generate_image.parameters.gridX": "grid 模式横向网格数量,只能与 sliceMode=grid 同时提供", "agc_generate_image.parameters.gridY": "grid 模式纵向网格数量,只能与 sliceMode=grid 同时提供", - "agc_generate_image.parameters.sliceCount": "只与 kind=icon-spritesheet 且 sliceMode=connected-components 同时提供,用于约束目标素材张数;省略时按图像内容自动识别", "agc_generate_image.parameters.screenColor": "抠图纯色背景,仅用于 kind=character(角色形象)和 kind=icon-spritesheet(图标素材)。生成时把主体置于该纯色背景上,回图后据此抠除背景。取值为 auto 或下列色板 hex 之一,传值只填 hex 本身:#CFEFFF(浅雾蓝)、#B0C2E0(浅钢蓝)、#FFD6C2(暖浅桃色)、#E6D8FF(淡薰衣草紫)、#F4D8E8(浅粉灰)、#7FB3FF(中度天蓝)、#FFF2A8(浅柠黄)、#CFFFE1(淡薄荷绿)、#D8DEE8(浅中性灰)、#D8D2E8(淡灰紫)、#A8F7F0(高对比浅青)、#A0BBA0(灰竹绿);auto 时由服务端自动选色。手动指定时选择与主体颜色明显不同的背景色", "agc_edit_image.description": "修改一张已登记图片:换装、改色、换背景或局部重绘。sourceLocalAssetId 必须使用 agc_list_registered_assets 返回的当前项目图片 localAssetId。", "agc_edit_image.parameters.sourceLocalAssetId": "当前项目已登记的图片 localAssetId", diff --git a/apps/ai-game-creator-shell/src-tauri/prompts/runtime/texts/execution.json b/apps/ai-game-creator-shell/src-tauri/prompts/runtime/texts/execution.json index b9fd08ea2..44238a227 100644 --- a/apps/ai-game-creator-shell/src-tauri/prompts/runtime/texts/execution.json +++ b/apps/ai-game-creator-shell/src-tauri/prompts/runtime/texts/execution.json @@ -6,7 +6,7 @@ "playtest.tetris": "完成合同要求 tetris-v1 交互试玩。game/index.html 必须持续更新