From 0a65ea7c7e596dbf793d1335ea632a6f18a1796e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=99=B4=E5=A4=A9?= Date: Mon, 15 Jun 2026 16:20:35 +0800 Subject: [PATCH] =?UTF-8?q?fix(security):=20=E6=94=B6=E7=B4=A7=E8=B7=AF?= =?UTF-8?q?=E5=BE=84=E4=B8=8E=20Gateway=20=E6=B8=85=E7=90=86=E7=AD=96?= =?UTF-8?q?=E7=95=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 22 ++++ package-lock.json | 4 +- package.json | 2 +- scripts/dev-api.js | 10 +- src-tauri/Cargo.lock | 2 +- src-tauri/Cargo.toml | 2 +- src-tauri/src/commands/config.rs | 61 ++++++++- src-tauri/src/commands/hermes.rs | 111 ++++++++++++---- src-tauri/src/commands/pairing.rs | 64 ++++++++- src-tauri/src/commands/service.rs | 145 +++++++++++++++++++-- src-tauri/tauri.conf.json | 2 +- tests/calibration-source-policy.test.js | 34 +++++ tests/gateway-linux-cleanup-policy.test.js | 25 ++++ 13 files changed, 424 insertions(+), 60 deletions(-) create mode 100644 tests/calibration-source-policy.test.js create mode 100644 tests/gateway-linux-cleanup-policy.test.js diff --git a/CHANGELOG.md b/CHANGELOG.md index 0b58d7a..ed3a132 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,28 @@ 格式遵循 [Keep a Changelog](https://keepachangelog.com/zh-CN/1.1.0/), 版本号遵循 [语义化版本](https://semver.org/lang/zh-CN/)。 +## [0.18.5] - 2026-06-15 + +### 修复 (Fixes) + +- **Hermes 文件管理路径安全** — 文件读写/列表入口拒绝 `..` 路径穿越,并通过 canonical path 校验新文件父目录,避免访问 `~/.hermes` 之外的路径 +- **配对 token 撤销后不再复活** — 自动配对升级遇到已撤销的 operator token 时会生成新 token,不再复用带 `revokedAtMs` 的旧 token +- **Linux Gateway 清理更保守** — 清理端口占用时会先确认进程命令行属于 `openclaw gateway`,并在 `/health` 连续无响应后才终止,避免误杀同端口其他服务或健康 Gateway +- **校准不再恢复用户已删配置** — 继承模式优先使用当前非空配置,只有当前配置为空时才从 `.bak` 回退,避免旧备份把用户主动删除的 provider/channel 恢复回来 + +### 测试与验证 (Testing) + +- 已通过 `node --test tests/calibration-source-policy.test.js tests/gateway-linux-cleanup-policy.test.js tests/patch-gateway-origins.test.js tests/hermes-web-config.test.js` +- 已通过 `npm run build` +- 已通过 `node -e "import('./scripts/dev-api.js').then(()=>console.log('dev-api import ok'))"` +- 已通过 `cd src-tauri && cargo fmt --check` +- 已通过 `cd src-tauri && cargo check` +- 已通过 `cd src-tauri && cargo test normalize_existing_pairing_rotates_revoked_operator_token --lib` +- 已通过 `cd src-tauri && cargo test select_calibration_source --lib` +- 已通过 `cd src-tauri && cargo test hermes_fs_path --lib` +- 已通过 `cd src-tauri && cargo clippy --all-targets -- -D warnings` +- 已通过 `git diff --check` + ## [0.18.4] - 2026-06-15 ### 兼容性 (Compatibility) diff --git a/package-lock.json b/package-lock.json index 31d24c5..2d49794 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "clawpanel", - "version": "0.18.4", + "version": "0.18.5", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "clawpanel", - "version": "0.18.4", + "version": "0.18.5", "license": "AGPL-3.0", "dependencies": { "@tauri-apps/api": "^2.5.0", diff --git a/package.json b/package.json index 171966f..7dbd60d 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "clawpanel", - "version": "0.18.4", + "version": "0.18.5", "private": true, "description": "ClawPanel - OpenClaw 可视化管理面板,基于 Tauri v2 的跨平台桌面应用", "type": "module", diff --git a/scripts/dev-api.js b/scripts/dev-api.js index d1dc334..a25c593 100644 --- a/scripts/dev-api.js +++ b/scripts/dev-api.js @@ -2441,11 +2441,13 @@ function calibrationRichnessScore(config) { return score } -function selectCalibrationSource(current, backup) { +export function selectCalibrationSource(current, backup) { if (current && backup) { - return calibrationRichnessScore(backup) > calibrationRichnessScore(current) - ? ['backup', backup] - : ['current', current] + const currentScore = calibrationRichnessScore(current) + if (currentScore === 0 && calibrationRichnessScore(backup) > 0) { + return ['backup', backup] + } + return ['current', current] } if (current) return ['current', current] if (backup) return ['backup', backup] diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 92f76c3..2422bb9 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -366,7 +366,7 @@ dependencies = [ [[package]] name = "clawpanel" -version = "0.18.4" +version = "0.18.5" dependencies = [ "base64 0.22.1", "chrono", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index aefca6d..4a6732d 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "clawpanel" -version = "0.18.4" +version = "0.18.5" edition = "2021" description = "ClawPanel - OpenClaw 可视化管理面板" authors = ["qingchencloud"] diff --git a/src-tauri/src/commands/config.rs b/src-tauri/src/commands/config.rs index 4738ff5..b5e4ffc 100644 --- a/src-tauri/src/commands/config.rs +++ b/src-tauri/src/commands/config.rs @@ -1278,12 +1278,13 @@ fn select_calibration_source(current: Option, backup: Option) -> ( match (current, backup) { (Some(current), Some(backup)) => { let current_score = calibration_richness_score(¤t); - let backup_score = calibration_richness_score(&backup); - if backup_score > current_score { - ("backup".into(), backup) - } else { - ("current".into(), current) + if current_score == 0 { + let backup_score = calibration_richness_score(&backup); + if backup_score > 0 { + return ("backup".into(), backup); + } } + ("current".into(), current) } (Some(current), None) => ("current".into(), current), (None, Some(backup)) => ("backup".into(), backup), @@ -7601,11 +7602,13 @@ pub fn invalidate_path_cache() -> Result<(), String> { #[cfg(test)] mod write_openclaw_config_merge_tests { use super::apply_reset_inheritance; + use super::calibration_richness_score; use super::merge_configs_preserving_fields; use super::node_version_satisfies_requirement; use super::openclaw_version_requires_node_22_19; #[cfg(target_os = "windows")] use super::resolve_openclaw_cli_input_path; + use super::select_calibration_source; use super::standalone_bundled_node_bin; use serde_json::json; use std::path::PathBuf; @@ -7709,6 +7712,54 @@ mod write_openclaw_config_merge_tests { assert!(origins.iter().any(|v| v == "tauri://localhost")); } + #[test] + fn select_calibration_source_prefers_current_over_richer_backup() { + let current = json!({ + "models": { "providers": {} }, + "gateway": { + "auth": { "mode": "token", "token": "secret-current" }, + } + }); + let backup = json!({ + "models": { + "providers": { + "old": { "type": "openai", "apiKey": "old" } + } + }, + "agents": { + "defaults": { "workspace": "/tmp/work" }, + "list": [{ "id": "old-agent" }] + }, + "channels": { "telegram": { "enabled": true } }, + "gateway": { + "auth": { "mode": "token", "token": "secret-backup" }, + "controlUi": { "allowedOrigins": ["http://localhost:3000"] } + } + }); + + assert!(calibration_richness_score(&backup) > calibration_richness_score(¤t)); + let (source, seed) = select_calibration_source(Some(current.clone()), Some(backup)); + + assert_eq!(source, "current"); + assert_eq!(seed, current); + } + + #[test] + fn select_calibration_source_uses_backup_when_current_empty() { + let backup = json!({ + "models": { + "providers": { + "old": { "type": "openai", "apiKey": "old" } + } + } + }); + + let (source, seed) = select_calibration_source(Some(json!({})), Some(backup.clone())); + + assert_eq!(source, "backup"); + assert_eq!(seed, backup); + } + #[cfg(target_os = "windows")] #[test] fn windows_cli_input_rejects_extensionless_openclaw_shim() { diff --git a/src-tauri/src/commands/hermes.rs b/src-tauri/src/commands/hermes.rs index b9f085e..685f8c8 100644 --- a/src-tauri/src/commands/hermes.rs +++ b/src-tauri/src/commands/hermes.rs @@ -17261,33 +17261,56 @@ const FS_MAX_LIST_ENTRIES: usize = 2000; // 单次最多返回 2000 条 /// 返回安全的绝对路径,或 Err。 fn validate_hermes_fs_path(rel_path: &str) -> Result { let root = hermes_home(); - // 空 = 根目录 - let target = if rel_path.is_empty() { - root.clone() + validate_hermes_fs_path_under(&root, rel_path) +} + +fn validate_hermes_fs_path_under( + root: &std::path::Path, + rel_path: &str, +) -> Result { + let canonical_root = root + .canonicalize() + .map_err(|e| format!("Hermes 目录不存在: {e}"))?; + if rel_path.trim().is_empty() { + return Ok(canonical_root); + } + + let p = std::path::Path::new(rel_path); + if p.components() + .any(|component| matches!(component, std::path::Component::ParentDir)) + { + return Err("路径不能包含 ..".into()); + } + + let target = if p.is_absolute() { + p.to_path_buf() } else { - // 拒绝绝对路径输入(必须相对于 hermes_home) - let p = std::path::Path::new(rel_path); - if p.is_absolute() { - // 允许绝对路径,但必须以 root 开头(用 starts_with 检查) - let canonical_root = root.canonicalize().unwrap_or(root.clone()); - let canonical_target = p.canonicalize().unwrap_or_else(|_| p.to_path_buf()); - if !canonical_target.starts_with(&canonical_root) { - return Err(format!("路径必须在 {} 子树内", root.to_string_lossy())); - } - canonical_target - } else { - // 相对路径:拼到 root 下,再 canonicalize 防 .. - let joined = root.join(p); - // 父目录必须存在才能 canonicalize;对不存在的新文件 fallback 到 joined - let canon = joined.canonicalize().unwrap_or(joined.clone()); - let canonical_root = root.canonicalize().unwrap_or(root.clone()); - if !canon.starts_with(&canonical_root) { - return Err(format!("路径不能跳出 {} 目录", root.to_string_lossy())); - } - canon - } + canonical_root.join(p) }; - Ok(target) + let canonical_target = if target.exists() { + target + .canonicalize() + .map_err(|e| format!("解析路径失败: {e}"))? + } else { + let parent = target + .parent() + .ok_or_else(|| "路径缺少父目录".to_string())?; + let canonical_parent = parent + .canonicalize() + .map_err(|e| format!("父目录不存在或不可访问: {e}"))?; + let Some(name) = target.file_name() else { + return Err("路径缺少文件名".into()); + }; + canonical_parent.join(name) + }; + + if !canonical_target.starts_with(&canonical_root) { + return Err(format!( + "路径不能跳出 {} 目录", + canonical_root.to_string_lossy() + )); + } + Ok(canonical_target) } #[tauri::command] @@ -17439,6 +17462,44 @@ pub async fn hermes_fs_write(path: String, content: String) -> Result std::path::PathBuf { + std::env::temp_dir().join(format!( + "{prefix}-{}-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos() + )) + } + + #[test] + fn rejects_parent_dir_segments() { + let root = unique_temp_dir("hermes-fs-path"); + std::fs::create_dir_all(&root).unwrap(); + + let err = validate_hermes_fs_path_under(&root, "../outside.txt").unwrap_err(); + + let _ = std::fs::remove_dir_all(&root); + assert!(err.contains("..")); + } + + #[test] + fn allows_new_file_under_root() { + let root = unique_temp_dir("hermes-fs-path-new"); + std::fs::create_dir_all(root.join("notes")).unwrap(); + + let target = validate_hermes_fs_path_under(&root, "notes/new.md").unwrap(); + + let _ = std::fs::remove_dir_all(&root); + assert!(target.ends_with(std::path::Path::new("notes").join("new.md"))); + } +} + // ============================================================================ // Unit tests for the pure YAML helpers (no filesystem I/O). // ============================================================================ diff --git a/src-tauri/src/commands/pairing.rs b/src-tauri/src/commands/pairing.rs index 23d82b0..c604467 100644 --- a/src-tauri/src/commands/pairing.rs +++ b/src-tauri/src/commands/pairing.rs @@ -112,6 +112,14 @@ fn operator_token_is_usable(value: Option<&serde_json::Value>) -> bool { .all(|scope| scopes.iter().any(|existing| existing == scope)) } +fn operator_token_is_revoked(value: Option<&serde_json::Value>) -> bool { + value + .and_then(|v| v.as_object()) + .and_then(|obj| obj.get("revokedAtMs")) + .map(|v| !v.is_null()) + .unwrap_or(false) +} + fn ensure_operator_token( obj: &mut serde_json::Map, now_ms: u64, @@ -129,12 +137,16 @@ fn ensure_operator_token( } let existing = tokens_obj.get(CONTROL_UI_ROLE).and_then(|v| v.as_object()); - let token = existing - .and_then(|entry| entry.get("token")) - .and_then(|v| v.as_str()) - .filter(|token| !token.trim().is_empty()) - .map(|token| token.to_string()) - .unwrap_or_else(generate_pairing_token); + let token = if operator_token_is_revoked(tokens_obj.get(CONTROL_UI_ROLE)) { + generate_pairing_token() + } else { + existing + .and_then(|entry| entry.get("token")) + .and_then(|v| v.as_str()) + .filter(|token| !token.trim().is_empty()) + .map(|token| token.to_string()) + .unwrap_or_else(generate_pairing_token) + }; let created_at_ms = existing .and_then(|entry| entry.get("createdAtMs")) .and_then(|v| v.as_u64()) @@ -511,4 +523,44 @@ mod tests { assert!(!changed); assert_eq!(entry["approvedAtMs"], serde_json::json!(1)); } + + #[test] + fn normalize_existing_pairing_rotates_revoked_operator_token() { + let mut entry = serde_json::json!({ + "deviceId": "device-1", + "publicKey": "key", + "platform": "windows", + "deviceFamily": "desktop", + "clientId": "openclaw-control-ui", + "clientMode": "ui", + "role": "operator", + "roles": ["operator"], + "scopes": scope_values(), + "approvedScopes": scope_values(), + "tokens": { + "operator": { + "token": "revoked-token", + "role": "operator", + "scopes": scope_values(), + "createdAtMs": 1, + "revokedAtMs": 999 + } + }, + "createdAtMs": 1, + "approvedAtMs": 1 + }); + + let changed = normalize_control_ui_pairing(&mut entry, "device-1", "key", "windows", 1234); + + assert!(changed); + let token = entry["tokens"]["operator"]["token"] + .as_str() + .expect("token"); + assert_ne!(token, "revoked-token"); + assert!(entry["tokens"]["operator"].get("revokedAtMs").is_none()); + assert_eq!( + entry["tokens"]["operator"]["rotatedAtMs"], + serde_json::json!(1234) + ); + } } diff --git a/src-tauri/src/commands/service.rs b/src-tauri/src/commands/service.rs index bf01785..74588ce 100644 --- a/src-tauri/src/commands/service.rs +++ b/src-tauri/src/commands/service.rs @@ -2000,6 +2000,101 @@ mod platform { None } + fn find_listening_pids(port: u16) -> Vec { + let mut pids = Vec::new(); + let filter = format!("sport = :{port}"); + if let Ok(output) = std::process::Command::new("ss") + .args(["-ltnp", &filter]) + .output() + { + let text = String::from_utf8_lossy(&output.stdout); + for segment in text.split("pid=").skip(1) { + let pid_text: String = segment.chars().take_while(|c| c.is_ascii_digit()).collect(); + if let Ok(pid) = pid_text.parse::() { + if pid > 0 && !pids.contains(&pid) { + pids.push(pid); + } + } + } + } + + if pids.is_empty() { + if let Ok(output) = std::process::Command::new("lsof") + .args(["-i", &format!("TCP:{port}"), "-sTCP:LISTEN", "-t"]) + .output() + { + let text = String::from_utf8_lossy(&output.stdout); + for line in text.lines() { + if let Ok(pid) = line.trim().parse::() { + if pid > 0 && !pids.contains(&pid) { + pids.push(pid); + } + } + } + } + } + pids + } + + fn read_process_command_line(pid: u32) -> Option { + let raw = std::fs::read(format!("/proc/{pid}/cmdline")).ok()?; + let text = raw + .split(|b| *b == 0) + .filter(|part| !part.is_empty()) + .map(|part| String::from_utf8_lossy(part).to_string()) + .collect::>() + .join(" "); + if text.trim().is_empty() { + None + } else { + Some(text) + } + } + + fn is_gateway_command_line(cmdline: &str) -> bool { + let lower = cmdline.to_ascii_lowercase(); + lower.contains("openclaw") && lower.contains("gateway") + } + + fn is_gateway_port_responsive(port: u16) -> bool { + use std::io::{Read, Write}; + let addr = format!("127.0.0.1:{port}"); + let Ok(socket_addr) = addr.parse() else { + return false; + }; + let Ok(mut stream) = + std::net::TcpStream::connect_timeout(&socket_addr, Duration::from_secs(3)) + else { + return false; + }; + let _ = stream.set_read_timeout(Some(Duration::from_secs(3))); + let _ = stream.set_write_timeout(Some(Duration::from_secs(2))); + let req = format!("GET /health HTTP/1.0\r\nHost: 127.0.0.1:{port}\r\n\r\n"); + if stream.write_all(req.as_bytes()).is_err() { + return false; + } + let mut buf = [0u8; 256]; + match stream.read(&mut buf) { + Ok(n) if n > 0 => { + let resp = String::from_utf8_lossy(&buf[..n]); + resp.contains("200") || resp.contains("OK") + } + _ => false, + } + } + + fn is_gateway_port_responsive_with_retry(port: u16, retries: u32, interval: Duration) -> bool { + for attempt in 0..retries { + if attempt > 0 { + std::thread::sleep(interval); + } + if is_gateway_port_responsive(port) { + return true; + } + } + false + } + /// 跨平台统一检测:TCP 连端口 #[allow(dead_code)] pub async fn check_service_status(_uid: u32, _label: &str) -> (bool, Option) { @@ -2023,23 +2118,45 @@ mod platform { } } - /// 清理残留的 Gateway 进程(Linux 版:通过 fuser 查端口占用进程并 kill) + /// 清理残留的 Gateway 进程。 + /// + /// 只处理命令行确认是 openclaw gateway 且 /health 连续无响应的进程,避免把同端口的 + /// 其他服务或仍在启动中的健康 Gateway 误杀。 fn cleanup_zombie_gateway_processes() { let port = crate::commands::gateway_listen_port(); - // 尝试用 fuser 找到端口占用进程 - if let Ok(output) = std::process::Command::new("fuser") - .args([&format!("{port}/tcp")]) - .output() - { - let pids = String::from_utf8_lossy(&output.stdout); - for pid_str in pids.split_whitespace() { - if let Ok(pid) = pid_str.trim().parse::() { - let _ = std::process::Command::new("kill") - .args(["-9", &pid.to_string()]) - .output(); - eprintln!("[cleanup_zombie] killed PID {pid} on port {port}"); - } + let pids = find_listening_pids(port); + if pids.is_empty() { + return; + } + + let responsive = + is_gateway_port_responsive_with_retry(port, 3, std::time::Duration::from_millis(800)); + for pid in pids { + let Some(cmdline) = read_process_command_line(pid) else { + super::guardian_log(&format!( + "跳过清理端口 {port} 上的 PID {pid}:无法读取命令行" + )); + continue; + }; + if !is_gateway_command_line(&cmdline) { + super::guardian_log(&format!( + "跳过清理端口 {port} 上的 PID {pid}:不是 openclaw gateway" + )); + continue; } + if responsive { + super::guardian_log(&format!( + "检测到健康的 Gateway 进程 (PID {pid}):/health 正常响应,跳过清理" + )); + continue; + } + + let _ = std::process::Command::new("kill") + .args(["-9", &pid.to_string()]) + .output(); + super::guardian_log(&format!( + "检测到僵尸 Gateway 进程 (PID {pid}):端口 {port} 占用但 /health 连续无响应,已强制终止" + )); } } diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 9cda4cb..9740365 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://raw.githubusercontent.com/tauri-apps/tauri/dev/crates/tauri-config-schema/schema.json", "productName": "ClawPanel", - "version": "0.18.4", + "version": "0.18.5", "identifier": "ai.openclaw.clawpanel", "build": { "frontendDist": "../dist", diff --git a/tests/calibration-source-policy.test.js b/tests/calibration-source-policy.test.js new file mode 100644 index 0000000..db32e84 --- /dev/null +++ b/tests/calibration-source-policy.test.js @@ -0,0 +1,34 @@ +import test from 'node:test' +import assert from 'node:assert/strict' + +import { selectCalibrationSource } from '../scripts/dev-api.js' + +test('calibration source keeps non-empty current config even when backup is richer', () => { + const current = { + models: { providers: {} }, + gateway: { auth: { mode: 'token', token: 'current-secret' } }, + } + const backup = { + models: { providers: { old: { type: 'openai', apiKey: 'old' } } }, + agents: { defaults: { workspace: '/tmp/work' }, list: [{ id: 'old-agent' }] }, + channels: { telegram: { enabled: true } }, + gateway: { + auth: { mode: 'token', token: 'backup-secret' }, + controlUi: { allowedOrigins: ['http://localhost:3000'] }, + }, + } + + const [source, seed] = selectCalibrationSource(current, backup) + + assert.equal(source, 'current') + assert.equal(seed, current) +}) + +test('calibration source falls back to backup only when current is empty', () => { + const backup = { models: { providers: { old: { type: 'openai', apiKey: 'old' } } } } + + const [source, seed] = selectCalibrationSource({}, backup) + + assert.equal(source, 'backup') + assert.equal(seed, backup) +}) diff --git a/tests/gateway-linux-cleanup-policy.test.js b/tests/gateway-linux-cleanup-policy.test.js new file mode 100644 index 0000000..2aa47a7 --- /dev/null +++ b/tests/gateway-linux-cleanup-policy.test.js @@ -0,0 +1,25 @@ +import test from 'node:test' +import assert from 'node:assert/strict' +import { readFileSync } from 'node:fs' + +const service = readFileSync(new URL('../src-tauri/src/commands/service.rs', import.meta.url), 'utf8') + +test('Linux Gateway cleanup verifies process identity before killing listeners', () => { + const linuxStart = service.indexOf('#[cfg(target_os = "linux")]') + const fallbackStart = service.indexOf('#[cfg(target_os = "windows")]\npub fn invalidate_cli_detection_cache', linuxStart) + const linux = linuxStart >= 0 && fallbackStart > linuxStart + ? service.slice(linuxStart, fallbackStart) + : '' + const cleanupStart = linux.indexOf('fn cleanup_zombie_gateway_processes()') + const cleanupEnd = linux.indexOf('async fn gateway_command', cleanupStart) + const cleanup = cleanupStart >= 0 && cleanupEnd > cleanupStart + ? linux.slice(cleanupStart, cleanupEnd) + : '' + + assert.ok(cleanup, 'Linux cleanup function must exist') + assert.doesNotMatch(cleanup, /Command::new\("fuser"\)/) + assert.match(cleanup, /read_process_command_line\(pid\)/) + assert.match(cleanup, /is_gateway_command_line\(&cmdline\)/) + assert.match(cleanup, /is_gateway_port_responsive_with_retry/) + assert.match(cleanup, /Command::new\("kill"\)/) +})