From 427fbca10073afe683dbb6c8dbb5869355aaa014 Mon Sep 17 00:00:00 2001 From: kdletters Date: Wed, 5 Aug 2026 15:14:40 +0800 Subject: [PATCH 1/2] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=20Runner=20GUI=20owner?= =?UTF-8?q?=20=E8=B7=A8=E9=87=8D=E5=90=AF=E7=99=BB=E8=AE=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 保存 GUI owner attach 参数并按 AppData 与 bootId 重放 在 Runner endpoint 返回前失败关闭未完成的 owner 登记 补充幂等、重试、配置隔离测试和生命周期文档 --- .../src-tauri/src/runner/client.rs | 105 ++++++++++- .../src-tauri/src/runner/tests.rs | 165 ++++++++++++++++++ .../shared-memory/decision-log.md | 2 +- ...案】AI游戏创作智能体App实施计划-2026-06-24.md | 2 +- 4 files changed, 267 insertions(+), 7 deletions(-) diff --git a/apps/ai-game-creator-shell/src-tauri/src/runner/client.rs b/apps/ai-game-creator-shell/src-tauri/src/runner/client.rs index 69c7da1c9..155d49037 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/runner/client.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/runner/client.rs @@ -8,6 +8,7 @@ use std::io::{self, BufRead, BufReader, Read, Write}; use std::net::{Ipv4Addr, SocketAddrV4, TcpStream}; use std::path::{Path, PathBuf}; use std::process::{Child, Command, Stdio}; +use std::sync::{Mutex, OnceLock}; use std::thread; use std::time::{Duration, Instant}; @@ -16,6 +17,76 @@ const AGENT_RUNNER_LOG_INPUT_LINE_MAX_BYTES: usize = 8 * 1024; const AGENT_RUNNER_LOG_OUTPUT_MAX_CHARS: usize = 1_024; const AGENT_RUNNER_CLIENT_EXIT_TIMEOUT: Duration = Duration::from_secs(15); +#[derive(Default)] +pub(super) struct ExternalAgentRunnerGuiOwnerAttachmentState { + generation: u64, + registration: Option, +} + +struct ExternalAgentRunnerGuiOwnerRegistration { + generation: u64, + config_dir: PathBuf, + params: ExternalAgentRunnerRequestParams, + attached_boot_id: Option, +} + +static EXTERNAL_AGENT_RUNNER_GUI_OWNER_ATTACHMENT_STATE: OnceLock< + Mutex, +> = OnceLock::new(); + +fn external_agent_runner_gui_owner_attachment_state( +) -> &'static Mutex { + EXTERNAL_AGENT_RUNNER_GUI_OWNER_ATTACHMENT_STATE + .get_or_init(|| Mutex::new(ExternalAgentRunnerGuiOwnerAttachmentState::default())) +} + +pub(super) fn register_external_agent_runner_gui_owner_attachment( + state: &Mutex, + config_dir: &Path, + params: ExternalAgentRunnerRequestParams, +) { + let mut state = lock_unpoisoned(state); + state.generation = state.generation.wrapping_add(1); + let generation = state.generation; + state.registration = Some(ExternalAgentRunnerGuiOwnerRegistration { + generation, + config_dir: config_dir.to_path_buf(), + params, + attached_boot_id: None, + }); +} + +pub(super) fn attach_registered_external_agent_runner_gui_owner_if_needed_with( + state: &Mutex, + config_dir: &Path, + endpoint: &ExternalAgentRunnerEndpoint, + attach: F, +) -> Result<(), String> +where + F: FnOnce(&ExternalAgentRunnerEndpoint, ExternalAgentRunnerRequestParams) -> Result<(), String>, +{ + let Some((generation, params)) = ({ + let state = lock_unpoisoned(state); + state.registration.as_ref().and_then(|registration| { + (registration.config_dir == config_dir + && registration.attached_boot_id.as_deref() != Some(endpoint.boot_id.as_str())) + .then(|| (registration.generation, registration.params.clone())) + }) + }) else { + return Ok(()); + }; + + attach(endpoint, params)?; + + let mut state = lock_unpoisoned(state); + if let Some(registration) = state.registration.as_mut() { + if registration.generation == generation && registration.config_dir == config_dir { + registration.attached_boot_id = Some(endpoint.boot_id.clone()); + } + } + Ok(()) +} + fn redact_url_queries(line: &str) -> String { line.split_whitespace() .map(|token| { @@ -914,14 +985,22 @@ pub(crate) fn shutdown_external_agent_runner() -> Result<(), String> { pub(crate) fn attach_external_agent_runner_gui_owner() -> Result<(), String> { EXTERNAL_AGENT_RUNNER_GUI_OWNER_REQUIRED_CLIENT .store(true, std::sync::atomic::Ordering::Release); + let _configure = lock_unpoisoned(external_agent_runner_configure_lock()); let config_dir = external_agent_runner_config_dir() .ok_or_else(|| "外部 Agent Runner 尚未配置 AppData;请显式传入 --config-dir".to_string())?; - let endpoint = ensure_external_agent_runner(&config_dir)?; - let result = send_external_agent_runner_request( - &endpoint, - "runner.attach_gui_owner", + register_external_agent_runner_gui_owner_attachment( + external_agent_runner_gui_owner_attachment_state(), + &config_dir, ExternalAgentRunnerRequestParams::default(), - )?; + ); + ensure_external_agent_runner(&config_dir).map(|_| ()) +} + +fn attach_external_agent_runner_gui_owner_at( + endpoint: &ExternalAgentRunnerEndpoint, + params: ExternalAgentRunnerRequestParams, +) -> Result<(), String> { + let result = send_external_agent_runner_request(endpoint, "runner.attach_gui_owner", params)?; if result.get("attached").and_then(Value::as_bool) == Some(true) { Ok(()) } else { @@ -929,6 +1008,18 @@ pub(crate) fn attach_external_agent_runner_gui_owner() -> Result<(), String> { } } +fn attach_registered_external_agent_runner_gui_owner_if_needed( + config_dir: &Path, + endpoint: &ExternalAgentRunnerEndpoint, +) -> Result<(), String> { + attach_registered_external_agent_runner_gui_owner_if_needed_with( + external_agent_runner_gui_owner_attachment_state(), + config_dir, + endpoint, + attach_external_agent_runner_gui_owner_at, + ) +} + pub(super) fn shutdown_external_agent_runner_for_client_exit_at( config_dir: &Path, ) -> Result { @@ -1024,6 +1115,9 @@ pub(super) fn ensure_external_agent_runner( match external_agent_runner_endpoint_reuse_decision(&endpoint, &executable_fingerprint) { ExternalAgentRunnerReuseDecision::Reuse => { if ping_external_agent_runner(&endpoint).is_ok() { + attach_registered_external_agent_runner_gui_owner_if_needed( + config_dir, &endpoint, + )?; return Ok(endpoint); } } @@ -1057,6 +1151,7 @@ pub(super) fn ensure_external_agent_runner( let _ = launched.child.wait(); }) .map_err(|error| format!("启动 Agent Runner 子进程回收线程失败:{error}"))?; + attach_registered_external_agent_runner_gui_owner_if_needed(config_dir, &endpoint)?; Ok(endpoint) } Err(error) => { diff --git a/apps/ai-game-creator-shell/src-tauri/src/runner/tests.rs b/apps/ai-game-creator-shell/src-tauri/src/runner/tests.rs index 98edcf5f0..e4fc14bf5 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/runner/tests.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/runner/tests.rs @@ -10,6 +10,7 @@ use std::io::{self, Cursor}; use std::net::{Ipv4Addr, SocketAddrV4, TcpListener}; use std::path::{Path, PathBuf}; use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::Mutex; use std::time::{Duration, Instant}; static TEST_DIRECTORY_COUNTER: AtomicU64 = AtomicU64::new(0); @@ -551,6 +552,170 @@ fn runner_endpoint_rejects_hard_links() { assert!(error.contains("硬链接")); } +#[test] +fn gui_owner_registration_replays_once_for_each_runner_boot() { + let state = Mutex::new(ExternalAgentRunnerGuiOwnerAttachmentState::default()); + let config_dir = PathBuf::from("registered-gui-appdata"); + let params = ExternalAgentRunnerRequestParams { + action_id: Some("registered-owner-params".to_string()), + ..ExternalAgentRunnerRequestParams::default() + }; + register_external_agent_runner_gui_owner_attachment(&state, &config_dir, params); + + let calls = std::cell::RefCell::new(Vec::new()); + let endpoint_a = test_endpoint( + "gui-owner-replay-token-gui-owner-replay-token", + "gui-owner-boot-a", + 31318, + ); + attach_registered_external_agent_runner_gui_owner_if_needed_with( + &state, + &config_dir, + &endpoint_a, + |endpoint, params| { + calls.borrow_mut().push(( + endpoint.boot_id.clone(), + params.action_id.expect("registered params are retained"), + )); + Ok(()) + }, + ) + .expect("first boot attaches"); + attach_registered_external_agent_runner_gui_owner_if_needed_with( + &state, + &config_dir, + &endpoint_a, + |_, _| panic!("same boot must not attach twice"), + ) + .expect("same boot is idempotent"); + + let endpoint_b = test_endpoint( + "gui-owner-replay-token-gui-owner-replay-token", + "gui-owner-boot-b", + 31319, + ); + attach_registered_external_agent_runner_gui_owner_if_needed_with( + &state, + &config_dir, + &endpoint_b, + |endpoint, params| { + calls.borrow_mut().push(( + endpoint.boot_id.clone(), + params.action_id.expect("registered params are replayed"), + )); + Ok(()) + }, + ) + .expect("replacement boot reattaches"); + + assert_eq!( + calls.into_inner(), + vec![ + ( + "gui-owner-boot-a".to_string(), + "registered-owner-params".to_string() + ), + ( + "gui-owner-boot-b".to_string(), + "registered-owner-params".to_string() + ), + ] + ); +} + +#[test] +fn gui_owner_registration_failed_replay_remains_pending_for_same_boot() { + let state = Mutex::new(ExternalAgentRunnerGuiOwnerAttachmentState::default()); + let config_dir = PathBuf::from("retry-gui-appdata"); + register_external_agent_runner_gui_owner_attachment( + &state, + &config_dir, + ExternalAgentRunnerRequestParams::default(), + ); + let endpoint = test_endpoint( + "gui-owner-retry-token-gui-owner-retry-token", + "gui-owner-retry-boot", + 31320, + ); + let attempts = std::cell::Cell::new(0_u32); + + let error = attach_registered_external_agent_runner_gui_owner_if_needed_with( + &state, + &config_dir, + &endpoint, + |_, _| { + attempts.set(attempts.get() + 1); + Err("injected attach failure".to_string()) + }, + ) + .expect_err("failed attach must remain pending"); + assert_eq!(error, "injected attach failure"); + attach_registered_external_agent_runner_gui_owner_if_needed_with( + &state, + &config_dir, + &endpoint, + |_, _| { + attempts.set(attempts.get() + 1); + Ok(()) + }, + ) + .expect("same boot retries after failure"); + attach_registered_external_agent_runner_gui_owner_if_needed_with( + &state, + &config_dir, + &endpoint, + |_, _| panic!("successful retry must mark the boot attached"), + ) + .expect("successful retry is idempotent"); + assert_eq!(attempts.get(), 2); +} + +#[test] +fn gui_owner_registration_does_not_cross_config_dirs() { + let state = Mutex::new(ExternalAgentRunnerGuiOwnerAttachmentState::default()); + let registered_config_dir = PathBuf::from("registered-gui-appdata"); + let other_config_dir = PathBuf::from("other-gui-appdata"); + register_external_agent_runner_gui_owner_attachment( + &state, + ®istered_config_dir, + ExternalAgentRunnerRequestParams::default(), + ); + let endpoint = test_endpoint( + "gui-owner-config-token-gui-owner-config-token", + "gui-owner-config-boot", + 31321, + ); + attach_registered_external_agent_runner_gui_owner_if_needed_with( + &state, + &other_config_dir, + &endpoint, + |_, _| panic!("GUI owner registration must stay bound to its AppData"), + ) + .expect("other AppData remains unattached"); + + let calls = std::cell::Cell::new(0_u32); + attach_registered_external_agent_runner_gui_owner_if_needed_with( + &state, + ®istered_config_dir, + &endpoint, + |_, _| { + calls.set(calls.get() + 1); + Ok(()) + }, + ) + .expect("registered AppData attaches"); + assert_eq!(calls.get(), 1); + + let unregistered = Mutex::new(ExternalAgentRunnerGuiOwnerAttachmentState::default()); + attach_registered_external_agent_runner_gui_owner_if_needed_with( + &unregistered, + ®istered_config_dir, + &endpoint, + |_, _| panic!("CLI state without GUI registration must not attach"), + ) + .expect("unregistered CLI state remains unchanged"); +} + #[test] fn gui_owner_lock_allows_only_one_frontend_process_per_appdata() { let directory = unique_test_directory(); diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index 2dc6e04a9..fab8fa0d3 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -5100,7 +5100,7 @@ - 决策:新增 `command.start / command.poll / command.stdin / command.terminate` 四个模型工具,专门承载受控前台持久进程。start / stdin / terminate 默认 `confirm`,poll 默认 `auto`。`command.start` 复用 `command.exec` 的固定 program、逐项 argv、项目 cwd、白名单解析、安全 PATH、隔离环境和参数拒绝,不接受 shell、环境注入、用户 executable、管道、重定向或 daemonize / detach;Runner 直接持有固定 `120x30` PTY、child handle、stdin writer 和输出泵,同项目最多 4 个、同 Agent instance 最多 2 个 running session。V1.2 对 PTY / 后台进程的排除只适用于一次性 `command.exec`。 - 决策:`processId` 是 start action create-once 的 opaque Runtime 身份,完整绑定 project、Agent instance、task、session、run、start action、action / command fingerprint 和 Runner boot;它不是 OS PID。poll / stdin / terminate 每次都从活 registry 和 durable record 交叉复核 owning 身份,跨 Agent、动态 sibling、run 或项目一律失败关闭,不能把知道 ID 当成授权。 -- 决策:2026-07-27 起,独立 Runner 归 Tauri GUI 生命周期所有,同一 AppData 通过 OS GUI owner 锁只允许一个前端进程持有 Runner。GUI 启动子进程显式携带 `--gui-owner-required`,Runner 若在启动检查前发现 owner 已释放就直接失败,不能退化成 CLI-owned Runner;就绪后仍必须调用 `runner.attach_gui_owner`。Runner 由独立 watchdog 线程每 100ms 探测 owner 锁,不依赖服务端主循环;owner 丢失后先标记 draining / forced shutdown 并让服务端在 1.5 秒共享 deadline 内中断 Provider、回收 process session,若主循环或排空链路卡死则 watchdog 在 1.75 秒后复核 bootId、清理 endpoint 并由 Runner 自身进程硬退出。正常最终 `RunEvent::Exit` 仍同步请求专用 `runner.shutdown`;GUI panic、SIGKILL 或构建中途失败不再只依赖退出回调。`runner.shutdown` 不得复用版本切换用的 `runner.shutdown_if_idle`,也不得以 busy 为由继续留在后台。GUI 侧使用专用短连接 / I/O 超时;endpoint 缺失或读取失败不能单独证明 Runner 已退出,必须结合实例锁释放,失败日志只输出脱敏阶段分类。GUI 客户端兜底在 Linux 通过同一 pidfd 校验 / 发信号,Windows 绑定同一进程 handle;macOS 没有等价稳定句柄,客户端不得按裸 PID 强杀,由跨平台 Runner 自身 watchdog 承担主循环卡死的最终兜底。旧 endpoint 缺 start identity 时,只有 GUI owner 路径且认证 ping 同时精确匹配 PID 和 bootId,才允许一次性迁移 busy 旧 Runner;普通 CLI 仍必须被 busy 阻断,不能按相同二进制猜测强杀。客户端强制终止后必须先取得同一 Runner 实例锁,再在锁内复核 bootId 并清理 endpoint;Unix endpoint 必须是当前用户持有的 0600 单硬链接普通文件。退出不得把任务伪造为 completed、不得重放工具副作用;未完成 run 保留既有 durable 状态,下一次启动按 reconciliation / recovery 合同处理。单个 WebView/子窗口关闭不触发 Runner shutdown,普通 CLI 退出也保持原行为,显式 `--runner-shutdown-if-idle` 仍只用于安全关闭空闲 Runner。Runner 重启只做 reconciliation:旧 boot 已进入 prepared / launching / running / terminating 且没有可信 terminal record 的会话进入 `needs-reconciliation`,不得重放 start 或 stdin,不得重发 terminate,也不得按持久化 PID 重连或接管 PTY;可信终态只补 observation / audit / receipt。首版连旧 boot 的 prepared 也保守核对,不自动推断为安全重试。 +- 决策:2026-07-27 起,独立 Runner 归 Tauri GUI 生命周期所有,同一 AppData 通过 OS GUI owner 锁只允许一个前端进程持有 Runner。GUI 启动子进程显式携带 `--gui-owner-required`,Runner 若在启动检查前发现 owner 已释放就直接失败,不能退化成 CLI-owned Runner;就绪后仍必须调用 `runner.attach_gui_owner`。2026-08-05 补充:GUI 客户端把完整 attach 参数按规范化 AppData 保存为进程内登记,并在 `ensure_external_agent_runner` 复用或新启 endpoint 的成功出口按 `bootId` 重放;同一登记 generation 在同一 boot 上只发送一次,新 boot 必须在后续 Runtime 写请求取得 endpoint 前完成登记。只有 Runner 明确确认 attached 后才能记录成功 boot,失败时本次 ensure 失败且后续同 boot 继续重试;登记 mutex 只做快照和成功提交,网络请求期间不持有,锁序固定为 configure lock 后 registration mutex。普通 CLI 没有 GUI 登记,不得因启动、写入或只读 status 产生 attach 副作用。OS owner/watchdog 已建立不代表事件 sink 等进程内附加能力已恢复。Runner 由独立 watchdog 线程每 100ms 探测 owner 锁,不依赖服务端主循环;owner 丢失后先标记 draining / forced shutdown 并让服务端在 1.5 秒共享 deadline 内中断 Provider、回收 process session,若主循环或排空链路卡死则 watchdog 在 1.75 秒后复核 bootId、清理 endpoint 并由 Runner 自身进程硬退出。正常最终 `RunEvent::Exit` 仍同步请求专用 `runner.shutdown`;GUI panic、SIGKILL 或构建中途失败不再只依赖退出回调。`runner.shutdown` 不得复用版本切换用的 `runner.shutdown_if_idle`,也不得以 busy 为由继续留在后台。GUI 侧使用专用短连接 / I/O 超时;endpoint 缺失或读取失败不能单独证明 Runner 已退出,必须结合实例锁释放,失败日志只输出脱敏阶段分类。GUI 客户端兜底在 Linux 通过同一 pidfd 校验 / 发信号,Windows 绑定同一进程 handle;macOS 没有等价稳定句柄,客户端不得按裸 PID 强杀,由跨平台 Runner 自身 watchdog 承担主循环卡死的最终兜底。旧 endpoint 缺 start identity 时,只有 GUI owner 路径且认证 ping 同时精确匹配 PID 和 bootId,才允许一次性迁移 busy 旧 Runner;普通 CLI 仍必须被 busy 阻断,不能按相同二进制猜测强杀。客户端强制终止后必须先取得同一 Runner 实例锁,再在锁内复核 bootId 并清理 endpoint;Unix endpoint 必须是当前用户持有的 0600 单硬链接普通文件。退出不得把任务伪造为 completed、不得重放工具副作用;未完成 run 保留既有 durable 状态,下一次启动按 reconciliation / recovery 合同处理。单个 WebView/子窗口关闭不触发 Runner shutdown,普通 CLI 退出也保持原行为,显式 `--runner-shutdown-if-idle` 仍只用于安全关闭空闲 Runner。Runner 重启只做 reconciliation:旧 boot 已进入 prepared / launching / running / terminating 且没有可信 terminal record 的会话进入 `needs-reconciliation`,不得重放 start 或 stdin,不得重发 terminate,也不得按持久化 PID 重连或接管 PTY;可信终态只补 observation / audit / receipt。首版连旧 boot 的 prepared 也保守核对,不自动推断为安全重试。 - 决策:`command.poll` 使用绑定 processId 的 opaque cursor,并以 `maxChars / waitMs` 分页读取保留逻辑行边界的清洗后私有 PTY transcript;默认 / 最大返回 8,000 / 16,000 字符,最长等待 30 秒,同一 action/cursor 恢复必须稳定。后台输出泵独立等待 child 并排空尾部,单会话清洗后输出上限为 256 KiB,超限终止并落 `output-limit-exceeded`。输出正文只进入 owning Agent 的私有 transcript、observation 和 context bundle,task/event/Agent DB/receipt/action history/activity/output/UI snapshot/report 只保存 cursor、字节数、SHA-256、截断和退出元数据。`command.stdin` 单次最终 UTF-8 bytes 上限 8 KiB,支持 `appendNewline / eof`,是不可重放副作用;公共确认与审计只留 `processId / bytesWritten / contentSha256 / stdinOpen / eof`,不得保存 data、摘要、前后缀或可逆编码。 - 决策:owning run 存在 launching / running / terminating 或未解决 reconciliation 会话时,final reply、finalization journal 和 completed 投影全部阻断。`runner.shutdown_if_idle` 同时检查活 registry、输出泵、终止任务和 durable unresolved record;取消 run 也必须先完成进程收束,不能留下会话后把 Runner 判 idle。 - 决策:terminate 必须携带最后一次 poll cursor,并返回同一 cursor 的零消费状态元数据;后续 poll 不得从 0 重读或跳过尾部。Unix 固定为 graceful request + 完整固定宽限等待、随后只 force kill 同组残留、再 wait / reap / drain PTY;Windows 首版使用 Job force terminate + wait / reap,不宣称已有等价 graceful console event。只发送信号不算完成;signal / Job / wait / reap 或终态审计无法确认都进入 reconciliation。重复 terminate 只幂等返回已知终态,不能按 PID 再杀一次。 diff --git a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md index f1ee0e0c4..3e08b0951 100644 --- a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md +++ b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md @@ -271,7 +271,7 @@ Agent Runtime 负责: - 2026-07-10 补充:后台任务工具箱已加入 `agent.run_status`。Agent 可在 loop 中读取自己、目标 Agent 或一组 Agent 的 Runtime 状态摘要,判断同伴是否正在运行、最近任务和最近工具动作;Runtime 复用 `agent.run_status` 项目权限策略,策略要求确认或拒绝时不读取状态,observation 不返回 `.agent/runtime/*` 文件绝对路径。 - 2026-07-10 补充:后台任务工具箱已加入 `agent.delegate`。Agent 可在 loop 中把明确任务投递到另一个 Agent 的独立后台队列,复用目标 Agent 原有锁和 pending drain 语义;同一目标 Agent 串行,不同目标 Agent 可并行。该工具受 `agent.delegate` 策略保护,策略要求确认或拒绝时不会写目标对话、不会启动目标后台任务,也不会写 `agent.runtime.agent.delegate` 审计记录。 - 2026-07-10 补充:`agent.delegate` 已形成可恢复的父子任务闭环。`delegationId` 由 durable pending action 的 `actionId` 派生,子任务记录会保存 `parentAgentId / parentRunId / delegationId`,终态记录额外保存经过统一凭据清洗和安全截断的 `terminalDetail`;同一委派的提交和回执分别受 delegation 级 OS 文件锁保护,同一目标 Agent 的 runId 分配与 pending 追加还受任务账本 OS 锁保护。子任务进入 `completed / failed / cancelled / budget-exhausted` 任一终态时,Runtime 按 `delegationId` 幂等生成且至多生成一次 `agent.delegate.result` 回执,失败、排队或活跃取消、预算耗尽都必须回传,不能只覆盖成功。回执会向父 Agent 既有队列追加固定 runId、`source=agent-delegate-receipt` 的续跑任务,把完整的已清洗 `terminalDetail` 交回父 run,不再只保留 80 字符 UI 摘要;回执 prompt 明确禁止重复同一委派,排队期间不提前写入父会话,真正开始执行时才幂等落盘,用户消息或回执消息落盘失败时不会进入 LLM。回执任务保留父 run 关联,并在真正开始或恢复前再次检查父 run 状态,关联缺失或父 run 不存在时失败关闭;该续跑仍受父 Agent 原有 FIFO、per-Agent OS 锁、权限确认、取消、恢复和 `needs-reconciliation` 屏障约束,不直接重入父 run、不插队、不新增独立 worker;父 run 已取消或普通失败时只保留 suppressed receipt 审计,不自动复活,父 Session 归档与切换会被未结束委派阻止,极端归档竞态下回执回落到父 Agent 当前可写 Session。恢复先恢复 pending action / reconciliation 屏障,再扫描“子任务终态已落盘但回执未提交”的窗口并补齐缺失回执;`needs-reconciliation` 本身不回执,只有人工核对后最终取消才回传 `cancelled`。 -- 历史记录(已由 V1.1 独立 Runner 替代):Runtime 最初通过 `resume_game_creator_agent_runtime_tasks` 把本地 JSONL 队列重接到当前 App 进程。当前恢复入口仍保留权限、任务顺序和 `agent.runtime.background_task.recovered` 审计语义,但实际由独立 Runner 接管原 run / session;已发出的上游 LLM 请求仍不能从网络中间点续传。2026-07-27 起,Runner 归 Tauri GUI 生命周期所有,同一 AppData 只允许一个 GUI owner。GUI 启动子进程会显式声明 `--gui-owner-required` 并在就绪后 attach owner;Runner 若在启动检查前已发现 owner 释放则直接失败,不得退化成 CLI-owned Runner。Runner 使用独立 watchdog 线程每 100ms 监控 owner OS 锁,不依赖服务端主循环继续推进;owner 丢失后先触发 1.5 秒共享 deadline 的 draining、Provider 中断和 process session 回收,若主循环或排空链路卡死则在 1.75 秒后由 Runner 自身进程安全硬退出并清理匹配 bootId 的 endpoint。因此正常最终退出、panic、SIGKILL 和 setup 中途失败都不会再因 busy 或主循环卡死而残留后台进程。endpoint 缺失 / 读取失败必须结合 Runner 实例锁判断;GUI 客户端强制兜底在 Linux 使用 pidfd、Windows 使用稳定进程 handle。macOS 没有等价稳定句柄,客户端不得在 start identity 检查后按裸 PID 强杀,而由跨平台 Runner 自身 watchdog 提供硬退出兜底。旧 endpoint 缺 start identity 时,只有认证 ping 精确匹配 PID + bootId 才允许迁移 busy 旧 Runner。未完成任务保持 durable 状态并在下一次启动走 reconciliation / recovery,不能伪造 completed 或重放副作用。关闭单个 WebView / 子窗口和普通 CLI 退出不触发该行为,版本切换与人工命令仍可使用只关闭空闲实例的 `runner.shutdown_if_idle`。 +- 历史记录(已由 V1.1 独立 Runner 替代):Runtime 最初通过 `resume_game_creator_agent_runtime_tasks` 把本地 JSONL 队列重接到当前 App 进程。当前恢复入口仍保留权限、任务顺序和 `agent.runtime.background_task.recovered` 审计语义,但实际由独立 Runner 接管原 run / session;已发出的上游 LLM 请求仍不能从网络中间点续传。2026-07-27 起,Runner 归 Tauri GUI 生命周期所有,同一 AppData 只允许一个 GUI owner。GUI 启动子进程会显式声明 `--gui-owner-required` 并在就绪后 attach owner;Runner 若在启动检查前已发现 owner 释放则直接失败,不得退化成 CLI-owned Runner。Runner 使用独立 watchdog 线程每 100ms 监控 owner OS 锁,不依赖服务端主循环继续推进;owner 丢失后先触发 1.5 秒共享 deadline 的 draining、Provider 中断和 process session 回收,若主循环或排空链路卡死则在 1.75 秒后由 Runner 自身进程安全硬退出并清理匹配 bootId 的 endpoint。GUI 客户端还必须把完整 `runner.attach_gui_owner` 参数作为绑定规范化 AppData 的进程内登记保存;`ensure_external_agent_runner` 无论复用既有 endpoint 还是启动新 Runner,都要在把 endpoint 交给 Runtime 写请求前按新 `bootId` 补登记。同一登记 generation 在同一 boot 上幂等,补登记失败不得记录成功 boot 且本次 `ensure` 失败关闭;未建立 GUI 登记的普通 CLI 不执行该重放。OS owner 锁与 watchdog 已成立只代表进程受 GUI 生命周期约束,不能替代事件 sink 等进程内附加能力的逐 boot 恢复。因此正常最终退出、panic、SIGKILL 和 setup 中途失败都不会再因 busy 或主循环卡死而残留后台进程。endpoint 缺失 / 读取失败必须结合 Runner 实例锁判断;GUI 客户端强制兜底在 Linux 使用 pidfd、Windows 使用稳定进程 handle。macOS 没有等价稳定句柄,客户端不得在 start identity 检查后按裸 PID 强杀,而由跨平台 Runner 自身 watchdog 提供硬退出兜底。旧 endpoint 缺 start identity 时,只有认证 ping 精确匹配 PID + bootId 才允许迁移 busy 旧 Runner。未完成任务保持 durable 状态并在下一次启动走 reconciliation / recovery,不能伪造 completed 或重放副作用。关闭单个 WebView / 子窗口和普通 CLI 退出不触发该行为,版本切换与人工命令仍可使用只关闭空闲实例的 `runner.shutdown_if_idle`。 - 2026-07-10 补充,2026-07-16 由 V1.28 澄清:后台 planning 与预算内 final reply 使用专用最小上下文,只预置 Agent 身份、sessionId、runId、执行模式和工具策略;Agent 私有记忆、项目记忆、黑板、对话、资产、项目索引与文件正文只能经对应工具通过权限 gate 后作为 observation 进入下一轮。只有开发窗口的专业 Agent 前台直调可使用对应角色上下文;正式用户前台现已统一进入 `project-supervisor`。长黑板、记忆和对话按尾部截断,确保最新结论与最新定向消息优先保留。 - 2026-07-10 补充,2026-07-16 由 V1.28 澄清:同一 Agent 的开发前台直调、流式调试和后台任务统一使用 `.agent/runtime/locks/.lock` OS 文件锁。开发前台不再在整个 LLM 请求期间占用项目级写锁;同 Agent 后台任务在开发前台运行时只入队,前台成功或失败后把当前 Agent 锁直接移交给 drain,不重新抢锁,也不允许 drain 启动异常把已经完成的调试结果改判为失败。正式用户 GUI 不通过该入口直聊专业 Agent;不同 Agent 继续并行,真实项目写工具只在副作用执行期间短暂申请项目写锁。 - 2026-07-10 补充,2026-08-01 更新:默认 `agent.resume=confirm` 时,客户端自动恢复命令先做只读 recovery preflight。全新项目和已完全终态且没有 task / retry / handoff / finalization / pending action / reconciliation 等 durable recovery work 的项目直接返回空结果,不显示虚假的 `agent.resume` 确认条。确实存在可恢复工作时,自动命令只做 auto gate 并返回待确认错误;主工作区和独立开发 Agent 聊天窗口显示 `agent.resume` 确认条,确认对象绑定发起时的项目路径,切换项目会取消旧确认,异步返回后也不得把旧项目 Runtime 合并到新项目 UI。开发者确认后调用独立 `confirm_resume_game_creator_agent_runtime_tasks`,该命令仍执行 deny-only 权限检查后才接回 durable queue。临时调用失败不锁死项目路径,允许后续刷新重试;明确 deny 或取消都不恢复任务。 From 0cc257ce6d80d44209321ebb8797d47c21fe7ba4 Mon Sep 17 00:00:00 2001 From: kdletters Date: Wed, 5 Aug 2026 15:23:26 +0800 Subject: [PATCH 2/2] =?UTF-8?q?=E5=85=81=E8=AE=B8=E5=BC=80=E5=8F=91?= =?UTF-8?q?=E7=8E=AF=E5=A2=83=E8=AE=BF=E9=97=AE=E6=89=98=E7=AE=A1MCP?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 将dev公开域名加入MCP Host与Origin白名单 新增正负例锁定DNS rebinding防护 同步外部API文档和项目排障经验 --- docs/project-memory/shared-memory/pitfalls.md | 8 ++++ ...构】外部OpenAPI与APIKey接入方案-2026-06-19.md | 2 + .../crates/api-server/src/external_mcp.rs | 45 ++++++++++++++++++- 3 files changed, 54 insertions(+), 1 deletion(-) diff --git a/docs/project-memory/shared-memory/pitfalls.md b/docs/project-memory/shared-memory/pitfalls.md index aa64acfac..86144242b 100644 --- a/docs/project-memory/shared-memory/pitfalls.md +++ b/docs/project-memory/shared-memory/pitfalls.md @@ -4098,3 +4098,11 @@ - 风险:Provider 工具约束不是本地安全边界;特别是 `writes + readOnlyHint=true` 自动放行的工具,schema 外字段可能改变外部副作用而不进入预期确认路径。 - 处理:使用完整 JSON Schema validator 校验原始 catalog schema,不手写 required/type 子集;native parser、fingerprint enrichment 与实际 MCP 调用边界复用同一校验器。enrichment 错误必须映射回 classified `arguments-schema` repair,不能以普通字符串直接终止 run;执行点重验用于阻断升级前已经落盘的 schema 外 pending。关闭网络和文件 `$ref` 解析,schema 无法安全编译时不广告或不执行。`serde` 类型错误会包含实际字符串值,catalog miss 也会包含模型提交的 server/tool,因此这两类错误同样只能返回稳定类别,不能拼接原始错误、参数值或 schema 内容。 - 验证:覆盖 required、additionalProperties、type、enum、本地 `$defs/$ref`、HTTP/file 外部引用、无效 schema、错误脱敏,证明 legacy wrapper 在注入 fingerprint 前进入 repair,并证明带旧有效 fingerprint 的历史 pending 在实际调用前仍被 schema 拒绝。 + +## 托管 MCP 新增公开域名时不能只更新网关路由(2026-08-05) + +- 现象:`https://dev.genarrative.world/api/external/v1/mcp` 的 manifest、OpenAPI 和 Bearer 鉴权都正常,但鉴权后的 `initialize` 返回 `403 FORBIDDEN`;通过 SSH 隧道访问同一 api-server 的 loopback 地址却可以正常列出 tools/resources。 +- 原因:`rmcp` Streamable HTTP transport 自带 DNS rebinding 防护。公网网关已经接入 dev 域名,但 `external_mcp::service()` 的 `allowed_hosts` / `allowed_origins` 仍只登记正式域名和 localhost,因此请求在 MCP 协议处理前被 transport 拒绝。 +- 处理:新增公开 MCP 环境时,同批登记对应 Host 与 HTTPS Origin;不要通过客户端伪造 `Host`、关闭防护或改走内部 SpacetimeDB MCP 规避。allowlist 变更属于 api-server 发布内容,必须随正常 API release 部署到目标环境。 +- 验证:自动测试使用真实公开 Host/Origin 执行 `initialize`;部署后再从公网域名完成带 Key 的 `initialize`、`tools/list`、`resources/list`、Skill resource 读取和至少一个只读业务 tool 调用。loopback 成功只能证明 MCP 实现和 Key 可用,不能替代公网 Host 验收。 +- 关联:`server-rs/crates/api-server/src/external_mcp.rs`、`docs/【后端架构】外部OpenAPI与APIKey接入方案-2026-06-19.md`。 diff --git a/docs/【后端架构】外部OpenAPI与APIKey接入方案-2026-06-19.md b/docs/【后端架构】外部OpenAPI与APIKey接入方案-2026-06-19.md index fbac43ea3..876b2e86b 100644 --- a/docs/【后端架构】外部OpenAPI与APIKey接入方案-2026-06-19.md +++ b/docs/【后端架构】外部OpenAPI与APIKey接入方案-2026-06-19.md @@ -77,6 +77,8 @@ provider 原图已保存但透明背景处理最终失败时,worker 保留原 `/api/external/v1/mcp` 是 Genarrative 托管的远程端点,Agent 只需配置 URL 和现有 API Key,不安装本地 MCP server。首版兼容 MCP `2025-11-25` initialize 生命周期,使用 JSON-RPC 2.0 和 Streamable HTTP,支持 `initialize`、`notifications/initialized`、`ping`、`tools/list`、`tools/call`、`resources/list`、`resources/read`。服务端使用无协议 session 的 JSON direct 模式,不依赖 sticky session,也不把 `Mcp-Session-Id` 作为业务身份。 +MCP transport 的 DNS rebinding 防护必须同时允许正式入口 `www.genarrative.world` / `genarrative.world`、开发入口 `dev.genarrative.world` 和本机开发入口;对应 HTTPS Origin 也必须与公开环境同步登记。新增公开环境域名时,必须在发布前使用该域名的真实 `Host` 和 `Origin` 执行 `initialize` 回归,不能只用 `localhost` 单测证明端点可用。 + MCP tools 从同一份 OpenAPI operation 自动形成 snake_case 名称,并在进程内复用 External REST router,因此鉴权、scope、owner、入参、幂等、计费和结果查询契约只有一份。生成 tools 把 `idempotencyKey` 显式放进参数,因为 MCP transport 的 Authorization 头不能代替逐次业务幂等键。工具结果使用 `structuredContent`;业务失败使用 `isError=true` 的结构化安全错误,协议不可路由时才返回 JSON-RPC error。 MCP 暴露下列稳定文本资源: diff --git a/server-rs/crates/api-server/src/external_mcp.rs b/server-rs/crates/api-server/src/external_mcp.rs index a7f9d60fe..5f9a056b9 100644 --- a/server-rs/crates/api-server/src/external_mcp.rs +++ b/server-rs/crates/api-server/src/external_mcp.rs @@ -83,6 +83,7 @@ pub(crate) fn service() -> GenarrativeExternalMcpService { .with_allowed_hosts([ "www.genarrative.world", "genarrative.world", + "dev.genarrative.world", "localhost", "127.0.0.1", "::1", @@ -90,6 +91,7 @@ pub(crate) fn service() -> GenarrativeExternalMcpService { .with_allowed_origins([ "https://www.genarrative.world", "https://genarrative.world", + "https://dev.genarrative.world", "http://localhost:3000", "http://127.0.0.1:3000", ]); @@ -603,7 +605,7 @@ mod tests { use axum::{ http::{ StatusCode, - header::{ACCEPT, HOST}, + header::{ACCEPT, HOST, ORIGIN}, }, middleware, }; @@ -759,6 +761,47 @@ mod tests { ); } + #[tokio::test] + async fn streamable_http_accepts_dev_host_and_origin_without_weakening_guards() { + for (host, origin, expected_status) in [ + ("dev.genarrative.world", None, StatusCode::OK), + ( + "dev.genarrative.world", + Some("https://dev.genarrative.world"), + StatusCode::OK, + ), + ("untrusted.example", None, StatusCode::FORBIDDEN), + ( + "dev.genarrative.world", + Some("https://untrusted.example"), + StatusCode::FORBIDDEN, + ), + ] { + let mut request = Request::builder() + .method(Method::POST) + .uri("/api/external/v1/mcp") + .header(HOST, host) + .header(CONTENT_TYPE, "application/json") + .header(ACCEPT, "application/json, text/event-stream"); + if let Some(origin) = origin { + request = request.header(ORIGIN, origin); + } + let request = request + .body(Body::from( + r#"{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-11-25","capabilities":{},"clientInfo":{"name":"dev-host-test-agent","version":"1.0"}}}"#, + )) + .expect("development initialize request should build"); + + let response = service() + .oneshot(request) + .await + .expect("MCP service should be infallible"); + + assert_eq!(response.status(), expected_status, "host={host}"); + assert!(response.headers().get("mcp-session-id").is_none()); + } + } + #[tokio::test] async fn streamable_http_initialize_is_stateless_json() { let request = Request::builder()