合并主分支并修复 Runner GUI owner 重挂
合并最新 master 的 Runner 登记模型与托管 MCP 变更 登记真实事件接收端口和令牌并按 bootId 幂等重放 严格校验 owner 与事件接收端双重确认并保留失败重试 补齐跨 boot、同 boot、失败确认、AppData 隔离和 CLI 无副作用测试 保留双方 pitfalls 条目并同步技术方案与项目记忆
This commit is contained in:
@@ -11,6 +11,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};
|
||||
|
||||
@@ -19,6 +20,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<ExternalAgentRunnerGuiOwnerRegistration>,
|
||||
}
|
||||
|
||||
struct ExternalAgentRunnerGuiOwnerRegistration {
|
||||
generation: u64,
|
||||
config_dir: PathBuf,
|
||||
params: ExternalAgentRunnerRequestParams,
|
||||
attached_boot_id: Option<String>,
|
||||
}
|
||||
|
||||
static EXTERNAL_AGENT_RUNNER_GUI_OWNER_ATTACHMENT_STATE: OnceLock<
|
||||
Mutex<ExternalAgentRunnerGuiOwnerAttachmentState>,
|
||||
> = OnceLock::new();
|
||||
|
||||
fn external_agent_runner_gui_owner_attachment_state(
|
||||
) -> &'static Mutex<ExternalAgentRunnerGuiOwnerAttachmentState> {
|
||||
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<ExternalAgentRunnerGuiOwnerAttachmentState>,
|
||||
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<F>(
|
||||
state: &Mutex<ExternalAgentRunnerGuiOwnerAttachmentState>,
|
||||
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| {
|
||||
@@ -919,18 +990,24 @@ 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 {
|
||||
event_sink_port: Some(event_sink.port),
|
||||
event_sink_token: Some(event_sink.token.clone()),
|
||||
..ExternalAgentRunnerRequestParams::default()
|
||||
},
|
||||
)?;
|
||||
);
|
||||
ensure_external_agent_runner(&config_dir).map(|_| ())
|
||||
}
|
||||
|
||||
pub(super) fn validate_external_agent_runner_gui_owner_attachment_result(
|
||||
result: &Value,
|
||||
) -> Result<(), String> {
|
||||
if result.get("attached").and_then(Value::as_bool) == Some(true)
|
||||
&& result.get("eventSinkAttached").and_then(Value::as_bool) == Some(true)
|
||||
{
|
||||
@@ -940,6 +1017,26 @@ pub(crate) fn attach_external_agent_runner_gui_owner(
|
||||
}
|
||||
}
|
||||
|
||||
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)?;
|
||||
validate_external_agent_runner_gui_owner_attachment_result(&result)
|
||||
}
|
||||
|
||||
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<bool, String> {
|
||||
@@ -1035,6 +1132,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);
|
||||
}
|
||||
}
|
||||
@@ -1068,6 +1168,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) => {
|
||||
|
||||
@@ -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,292 @@ 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 event_sink_port = 31_317;
|
||||
let event_sink_token = "a".repeat(64);
|
||||
let params = ExternalAgentRunnerRequestParams {
|
||||
event_sink_port: Some(event_sink_port),
|
||||
event_sink_token: Some(event_sink_token.clone()),
|
||||
..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
|
||||
.event_sink_port
|
||||
.expect("registered sink port is retained"),
|
||||
params
|
||||
.event_sink_token
|
||||
.expect("registered sink token is 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
|
||||
.event_sink_port
|
||||
.expect("registered sink port is replayed"),
|
||||
params
|
||||
.event_sink_token
|
||||
.expect("registered sink token is replayed"),
|
||||
));
|
||||
Ok(())
|
||||
},
|
||||
)
|
||||
.expect("replacement boot reattaches");
|
||||
|
||||
assert_eq!(
|
||||
calls.into_inner(),
|
||||
vec![
|
||||
(
|
||||
"gui-owner-boot-a".to_string(),
|
||||
event_sink_port,
|
||||
event_sink_token.clone(),
|
||||
),
|
||||
(
|
||||
"gui-owner-boot-b".to_string(),
|
||||
event_sink_port,
|
||||
event_sink_token,
|
||||
),
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[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_missing_event_sink_confirmation_retries_same_boot() {
|
||||
let state = Mutex::new(ExternalAgentRunnerGuiOwnerAttachmentState::default());
|
||||
let config_dir = PathBuf::from("missing-sink-confirmation-appdata");
|
||||
register_external_agent_runner_gui_owner_attachment(
|
||||
&state,
|
||||
&config_dir,
|
||||
ExternalAgentRunnerRequestParams {
|
||||
event_sink_port: Some(31_322),
|
||||
event_sink_token: Some("c".repeat(64)),
|
||||
..ExternalAgentRunnerRequestParams::default()
|
||||
},
|
||||
);
|
||||
let endpoint = test_endpoint(
|
||||
"missing-sink-confirmation-runner-token",
|
||||
"missing-sink-confirmation-boot",
|
||||
31_322,
|
||||
);
|
||||
let attempts = std::cell::Cell::new(0_u32);
|
||||
|
||||
attach_registered_external_agent_runner_gui_owner_if_needed_with(
|
||||
&state,
|
||||
&config_dir,
|
||||
&endpoint,
|
||||
|_, _| {
|
||||
attempts.set(attempts.get() + 1);
|
||||
validate_external_agent_runner_gui_owner_attachment_result(&json!({
|
||||
"attached": true
|
||||
}))
|
||||
},
|
||||
)
|
||||
.expect_err("missing eventSinkAttached must fail");
|
||||
attach_registered_external_agent_runner_gui_owner_if_needed_with(
|
||||
&state,
|
||||
&config_dir,
|
||||
&endpoint,
|
||||
|_, _| {
|
||||
attempts.set(attempts.get() + 1);
|
||||
validate_external_agent_runner_gui_owner_attachment_result(&json!({
|
||||
"attached": true,
|
||||
"eventSinkAttached": true
|
||||
}))
|
||||
},
|
||||
)
|
||||
.expect("same boot retries after missing event sink confirmation");
|
||||
assert_eq!(attempts.get(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gui_owner_registration_false_event_sink_confirmation_retries_same_boot() {
|
||||
let state = Mutex::new(ExternalAgentRunnerGuiOwnerAttachmentState::default());
|
||||
let config_dir = PathBuf::from("false-sink-confirmation-appdata");
|
||||
register_external_agent_runner_gui_owner_attachment(
|
||||
&state,
|
||||
&config_dir,
|
||||
ExternalAgentRunnerRequestParams {
|
||||
event_sink_port: Some(31_323),
|
||||
event_sink_token: Some("d".repeat(64)),
|
||||
..ExternalAgentRunnerRequestParams::default()
|
||||
},
|
||||
);
|
||||
let endpoint = test_endpoint(
|
||||
"false-sink-confirmation-runner-token",
|
||||
"false-sink-confirmation-boot",
|
||||
31_323,
|
||||
);
|
||||
let attempts = std::cell::Cell::new(0_u32);
|
||||
|
||||
attach_registered_external_agent_runner_gui_owner_if_needed_with(
|
||||
&state,
|
||||
&config_dir,
|
||||
&endpoint,
|
||||
|_, _| {
|
||||
attempts.set(attempts.get() + 1);
|
||||
validate_external_agent_runner_gui_owner_attachment_result(&json!({
|
||||
"attached": true,
|
||||
"eventSinkAttached": false
|
||||
}))
|
||||
},
|
||||
)
|
||||
.expect_err("false eventSinkAttached must fail");
|
||||
attach_registered_external_agent_runner_gui_owner_if_needed_with(
|
||||
&state,
|
||||
&config_dir,
|
||||
&endpoint,
|
||||
|_, _| {
|
||||
attempts.set(attempts.get() + 1);
|
||||
validate_external_agent_runner_gui_owner_attachment_result(&json!({
|
||||
"attached": true,
|
||||
"eventSinkAttached": true
|
||||
}))
|
||||
},
|
||||
)
|
||||
.expect("same boot retries after false event sink confirmation");
|
||||
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");
|
||||
let event_sink_token = "e".repeat(64);
|
||||
register_external_agent_runner_gui_owner_attachment(
|
||||
&state,
|
||||
®istered_config_dir,
|
||||
ExternalAgentRunnerRequestParams {
|
||||
event_sink_port: Some(31_324),
|
||||
event_sink_token: Some(event_sink_token.clone()),
|
||||
..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,
|
||||
|_, params| {
|
||||
calls.set(calls.get() + 1);
|
||||
assert_eq!(params.event_sink_port, Some(31_324));
|
||||
assert_eq!(
|
||||
params.event_sink_token.as_deref(),
|
||||
Some(event_sink_token.as_str())
|
||||
);
|
||||
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();
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -4158,3 +4158,18 @@
|
||||
- 原因:脚本用 `Buffer.readUInt32BE(0)` 读取文件头,却只比较 `0xfeedfacf` 等正序数值;arm64 常见头字节是 `cf fa ed fe`,读取结果为 `0xcffaedfe`。
|
||||
- 处理:文件头白名单同时覆盖 32/64 位与 fat Mach-O 的正序和反字节序合法魔数,并由桌面配置门禁同时反查 staging 脚本和根级产物检查,不能改成只按扩展名或构建退出码判断。
|
||||
- 验证:在 macOS 上构建真实 desktop-shell release,运行 `npm run desktop-shell:stage-release-binary`,再由 `npm run check:native-shells` 校验 staged 产物。
|
||||
|
||||
## 托管 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`。
|
||||
|
||||
## GUI owner 锁不能替代逐 boot 的事件接收端登记(2026-08-05)
|
||||
|
||||
- 现象:GUI 首次启动后 manifest 事件转发正常,但 Runner 被替换为新 boot 后只剩 owner 锁和 endpoint 可用,后台更新不再到达 GUI;或者 attach 响应只确认 owner,客户端却误记当前 boot 已完整登记,后续 ensure 不再重试。
|
||||
- 原因:把 OS owner 生命周期约束与进程内事件 sink attachment 混成同一状态,或在 `ensure_external_agent_runner` 之外执行一次性 attach;测试若用 actionId 等无关字段代替真实 sink port/token,也无法证明新 boot 重放的是可用接收端。
|
||||
- 处理:GUI 按规范化 AppData 私有登记真实 sink port/token,`ensure_external_agent_runner` 的 endpoint 复用和新 Runner 就绪两条成功路径都按 `bootId` 重放。同 boot 成功后幂等,新 boot 必须重挂;RPC、`attached` 或 `eventSinkAttached` 任一失败或缺失都不得记录成功 boot,并允许同 boot 后续重试。不同 AppData 不共享登记,未登记 CLI 不触发 attach;sink token 不进入日志、错误或公共状态。
|
||||
- 验证:分别覆盖真实 port/token 跨 boot 原样重放、同 boot 幂等、新 boot 重挂、普通 attach 失败、`eventSinkAttached` 缺失与 false 后同 boot 重试、AppData 隔离和未登记 CLI 零副作用。
|
||||
|
||||
@@ -273,7 +273,8 @@ 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-08-05 GUI owner attachment 确认补充:登记参数必须保存 GUI manifest 事件接收端的真实 `event_sink_port` 与 `event_sink_token`,不得借用 actionId 等无关字段作为测试替身。每次 attach RPC 只有同时返回 `attached=true` 与 `eventSinkAttached=true` 才能把当前 `bootId` 标记为已登记;`eventSinkAttached` 缺失、为 false 或普通 RPC 失败都保持当前 boot 待重试。sink token 只留在私有进程内登记和 RPC 参数中,不进入日志、错误文本或公共状态。
|
||||
- 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/<agentId>.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 或取消都不恢复任务。
|
||||
|
||||
@@ -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 暴露下列稳定文本资源:
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user