fix(sandbox): 容器不可用自动降级 + clean_expired 并发回归测试

P1-1 降级机制:
  - sandbox 新增 DEGRADED 全局标志,ensure_container 失败时置位
  - 新增 ensure_ready_or_degrade: exec 前检测容器运行状态,
    不在则重建,重建失败置降级标志并返回 false
  - executor 调用 ensure_ready_or_degrade,降级时回退本地执行
    (审批已在入口完成,安全边界不受影响)
  - spawn_child 保留 spawn 级失败兜底(docker 二进制缺失等)
  - 解决: IAS_DOCKER_SANDBOX=1 但 docker 异常时工具不再持续 SpawnFailed

P1-2 并发回归测试:
  - approval 新增 create_with_ttl 测试入口(cfg(test))
  - clean_expired_only_removes_expired_entries: 验证只删过期条目
  - clean_expired_safe_when_no_pending: 空 pending 不 panic
  - clean_expired_skips_entries_already_removed_by_reply:
    回归保护——clean_expired 第一阶段收集 hash 后、第二阶段删除前,
    条目被 handle_reply 提前移除时,按 hash 查找应安全跳过不误删

P2-5 容器崩溃恢复:
  - ensure_ready_or_degrade 统一处理容器崩溃重建
  - recover_if_crashed 保留供 spawn 级失败兜底调用

P2-6 build_exec_cmd 用 spec.tool 替代 path 文件名提取,消除隐式耦合

验证: 28 测试通过(原25+3),clippy 22<基线23 无新增警告,
端到端验证容器重建/降级/本地三种路径均正确。
This commit is contained in:
2026-06-22 17:46:48 +08:00
parent 142efb070f
commit a2f88b9b01
4 changed files with 221 additions and 21 deletions
+90
View File
@@ -129,6 +129,33 @@ impl ApprovalManager {
Ok((code_str, rx))
}
/// 测试专用:创建指定 TTL(秒)的审批请求
#[cfg(test)]
async fn create_with_ttl(
&self,
user_id: &str,
skill_name: &str,
ttl_secs: u64,
) -> Result<(String, oneshot::Receiver<ApprovalDecision>), String> {
let code: u32 = rand::rng().random_range(100000..999999);
let code_str = code.to_string();
let code_hash = format!("{:x}", Sha256::digest(code_str.as_bytes()));
let (tx, rx) = oneshot::channel();
let mut map = self.pending.lock().await;
map.entry(user_id.to_string())
.or_default()
.push(PendingEntry {
code_hash,
skill_name: skill_name.to_string(),
attempts_left: 3,
expires_at: Instant::now() + std::time::Duration::from_secs(ttl_secs),
sender: tx,
});
Ok((code_str, rx))
}
/// 处理用户回复(由消息循环调用)
/// 返回 (技能名, 决策);匹配失败返回 None
pub async fn handle_reply(
@@ -241,3 +268,66 @@ impl ApprovalManager {
map.retain(|_, v| !v.is_empty());
}
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn clean_expired_only_removes_expired_entries() {
// 无 DB,纯内存
let mgr = ApprovalManager::new(None);
// 两条审批:一条立即过期(ttl=0),一条 300s 不过期
let (_expired_code, expired_rx) = mgr.create_with_ttl("u1", "tool_a", 0).await.unwrap();
let (live_code, live_rx) = mgr.create_with_ttl("u1", "tool_b", 300).await.unwrap();
// 立即过期的条目 expires_at 已成过去,sleep 确保时间推进
tokio::time::sleep(std::time::Duration::from_millis(10)).await;
mgr.clean_expired().await;
// 过期条目应收到 Expired
assert_eq!(expired_rx.await.unwrap(), ApprovalDecision::Expired);
// 未过期条目不应被误删:handle_reply 用正确确认码应能匹配
let (name, decision) = mgr.handle_reply("u1", &live_code).await.unwrap();
assert_eq!(name, "tool_b");
assert_eq!(decision, ApprovalDecision::Approved);
// live_rx 应收到 Approved(未被 clean_expired 误标记)
assert_eq!(live_rx.await.unwrap(), ApprovalDecision::Approved);
}
#[tokio::test]
async fn clean_expired_safe_when_no_pending() {
// 空 pending 不应 panic
let mgr = ApprovalManager::new(None);
mgr.clean_expired().await;
// 重复调用也安全
mgr.clean_expired().await;
}
#[tokio::test]
async fn clean_expired_skips_entries_already_removed_by_reply() {
// 回归保护:clean_expired 第一阶段收集过期 hash 后、第二阶段删除前,
// 若该条目已被 handle_reply 提前处理,clean_expired 应安全跳过,不误删其他条目。
// (原 bug:按快照 index 删除会导致误删;现按 code_hash 删除应安全跳过)
let mgr = ApprovalManager::new(None);
// 两条同用户审批,都设为立即过期
let (_code_a, rx_a) = mgr.create_with_ttl("u1", "tool_a", 0).await.unwrap();
let (_code_b, rx_b) = mgr.create_with_ttl("u1", "tool_b", 0).await.unwrap();
tokio::time::sleep(std::time::Duration::from_millis(10)).await;
// 先用 handle_reply “0” 取消最早的条目(tool_a),模拟并发回复
let (name, decision) = mgr.handle_reply("u1", "0").await.unwrap();
assert_eq!(name, "tool_a");
assert_eq!(decision, ApprovalDecision::Rejected);
assert_eq!(rx_a.await.unwrap(), ApprovalDecision::Rejected);
// 再 clean_expiredtool_a 已被 remove,按 hash 查找应跳过;
// tool_b 仍在且过期,应被正确标记 Expired(不会被误当成 tool_a 跳过)
mgr.clean_expired().await;
assert_eq!(rx_b.await.unwrap(), ApprovalDecision::Expired);
}
}