use std::net::{IpAddr, Ipv4Addr, SocketAddr};
use std::sync::Arc;
use axum::Router;
use axum::body::Body;
use axum::extract::connect_info::MockConnectInfo;
use axum::http::{Method, Request};
use serde_json::{Value, json};
use tower::ServiceExt;
use trusty_common::uds::server::{
CODE_INVALID_PARAMS, CODE_METHOD_NOT_FOUND, RpcResponse, RpcRouter,
};
use super::control::CallerTrust;
use super::outcome::{CODE_FORBIDDEN, CODE_NOT_FOUND, status_to_rpc_code};
use crate::core::trusty_tools_config::WorkspaceRootEnv;
use crate::daemon::api;
use crate::daemon::rpc::managed;
use crate::daemon::state::DaemonState;
use crate::session_manager::ManagedError;
async fn test_state() -> (Arc<DaemonState>, tempfile::TempDir) {
let dir = tempfile::tempdir().expect("temp dir");
let state = DaemonState::with_root_isolated_managed(dir.path().to_path_buf()).await;
(Arc::new(state), dir)
}
async fn test_state_no_spawn() -> (Arc<DaemonState>, tempfile::TempDir) {
let dir = tempfile::tempdir().expect("temp dir");
std::fs::write(
dir.path().join("config.toml"),
"[control_plane]\nmax_concurrent_sessions = 0\n",
)
.expect("write config");
let state = DaemonState::with_root_isolated_managed(dir.path().to_path_buf()).await;
(Arc::new(state), dir)
}
fn http_router(state: Arc<DaemonState>) -> Router {
api::router(state).layer(MockConnectInfo(SocketAddr::new(
IpAddr::V4(Ipv4Addr::LOCALHOST),
0,
)))
}
fn rpc_router(state: &Arc<DaemonState>) -> RpcRouter {
managed::register(RpcRouter::new(), state)
}
struct HttpAnswer {
status: u16,
text: String,
json: Option<Value>,
}
async fn http_call(router: Router, method: Method, uri: &str, body: Option<Value>) -> HttpAnswer {
let mut builder = Request::builder().method(method).uri(uri);
let request = match body {
Some(v) => {
builder = builder.header("content-type", "application/json");
builder.body(Body::from(v.to_string()))
}
None => builder.body(Body::empty()),
}
.expect("build request");
let response = router.oneshot(request).await.expect("http call");
let status = response.status().as_u16();
let bytes = axum::body::to_bytes(response.into_body(), usize::MAX)
.await
.expect("read body");
let text = String::from_utf8_lossy(&bytes).into_owned();
let json = serde_json::from_slice::<Value>(&bytes).ok();
HttpAnswer { status, text, json }
}
async fn rpc_call(router: &RpcRouter, method: &str, params: Value) -> RpcResponse {
let frame = json!({"jsonrpc": "2.0", "id": 1, "method": method, "params": params});
router.dispatch(frame.to_string().as_bytes()).await
}
fn assert_parity(label: &str, http: &HttpAnswer, rpc: &RpcResponse) {
if (200..300).contains(&http.status) {
let expected = http.json.as_ref().unwrap_or_else(|| {
panic!("{label}: a 2xx HTTP body must be JSON, got {:?}", http.text)
});
let got = rpc.result.as_ref().unwrap_or_else(|| {
panic!("{label}: rpc refused a route HTTP answered {}", http.status)
});
assert_eq!(got, expected, "{label}: response bodies must be identical");
return;
}
let error = rpc
.error
.as_ref()
.unwrap_or_else(|| panic!("{label}: rpc succeeded where HTTP answered {}", http.status));
assert_eq!(
error.message, http.text,
"{label}: the refusal message must be the HTTP body verbatim"
);
assert_eq!(
error.code,
status_to_rpc_code(http.status),
"{label}: the refusal code must be the documented projection of HTTP {}",
http.status
);
}
const MISSING_ID: &str = "11111111-2222-3333-4444-555555555555";
#[tokio::test]
async fn every_scoped_route_has_a_method() {
let (state, _dir) = test_state().await;
let router = rpc_router(&state);
let registered: Vec<&str> = router.method_names().chain(router.stream_names()).collect();
for expected in [
"mpm.managed.spawn",
"mpm.managed.list",
"mpm.managed.adopt",
"mpm.managed.prune",
"mpm.managed.decommission_ephemeral",
"mpm.managed.prune_worktrees",
"mpm.managed.reconcile_worktrees",
"mpm.managed.fleet",
"mpm.managed.register_supervisor",
"mpm.residency.active",
"mpm.managed.get",
"mpm.managed.stop",
"mpm.managed.runtime_stop",
"mpm.managed.rename",
"mpm.managed.send",
"mpm.managed.provision_status",
"mpm.managed.sync_assets",
"mpm.managed.sync_assets_all",
"mpm.managed.answer",
"mpm.managed.attach_cmd",
"mpm.managed.activity",
"mpm.managed.resume",
"mpm.managed.reactivate",
"mpm.managed.decommission",
"mpm.managed.delete",
"mpm.control.list",
"mpm.control.run",
"mpm.control.connect",
"mpm.control.stop",
"mpm.control.auth",
"mpm.proxy.focus",
"mpm.proxy.get_focus",
"mpm.proxy.unfocus",
"mpm.proxy.message",
"mpm.proxy.summary",
] {
assert!(
registered.contains(&expected),
"{expected} is not registered; the listener serves {registered:?}"
);
}
}
#[tokio::test]
async fn an_unknown_method_is_still_method_not_found() {
let (state, _dir) = test_state().await;
let response = rpc_call(&rpc_router(&state), "mpm.managed.nope", json!({})).await;
assert_eq!(
response.error.expect("unknown method refuses").code,
CODE_METHOD_NOT_FOUND
);
}
#[tokio::test]
async fn malformed_params_are_invalid_params() {
let (state, _dir) = test_state().await;
let response = rpc_call(&rpc_router(&state), "mpm.managed.get", json!({})).await;
assert_eq!(
response.error.expect("must refuse").code,
CODE_INVALID_PARAMS
);
}
#[tokio::test]
async fn caller_trust_refusal_matches_the_http_403() {
let (state, _dir) = test_state_no_spawn().await;
let peer = SocketAddr::new(IpAddr::V4(Ipv4Addr::new(192, 168, 1, 50)), 40000);
let mut request = Request::builder()
.method(Method::POST)
.uri("/api/v1/control/sessions/run")
.header("content-type", "application/json")
.body(Body::from(
json!({"project_id": "p", "workdir": "/tmp", "backend": "stream-json"}).to_string(),
))
.expect("build");
request
.extensions_mut()
.insert(axum::extract::ConnectInfo(peer));
let response = api::router(Arc::clone(&state))
.oneshot(request)
.await
.expect("http call");
assert_eq!(response.status().as_u16(), 403);
let bytes = axum::body::to_bytes(response.into_body(), usize::MAX)
.await
.expect("body");
assert_eq!(
String::from_utf8_lossy(&bytes),
CallerTrust::REFUSAL,
"the shared refusal string must be what HTTP actually sends"
);
let refusal = CallerTrust::unverified()
.ensure_local()
.expect_err("an unverified caller must be refused")
.into_rpc()
.expect_err("a 403 is always an error");
assert_eq!(refusal.code, CODE_FORBIDDEN);
assert_eq!(refusal.message, CallerTrust::REFUSAL);
}
#[tokio::test]
async fn rpc_ctl_run_refuses_an_untrusted_caller() {
let (state, _dir) = test_state_no_spawn().await;
let dir = tempfile::tempdir().expect("temp dir");
let outcome = crate::daemon::api::control_routes::ctl_run_core(
&state,
CallerTrust::unverified(),
serde_json::from_value(json!({
"project_id": "p",
"workdir": dir.path().to_string_lossy(),
"backend": "stream-json",
}))
.expect("decode"),
)
.await;
assert_eq!(
outcome.status, 403,
"an unverified caller must be refused before any spawn"
);
let error = outcome.into_rpc().expect_err("403 is an error");
assert_eq!(error.code, CODE_FORBIDDEN);
assert_eq!(error.message, CallerTrust::REFUSAL);
}
#[tokio::test]
async fn rpc_ctl_run_rejects_claude_cmd_outside_allowlist() {
let (state, _dir) = test_state_no_spawn().await;
let dir = tempfile::tempdir().expect("temp dir");
let response = rpc_call(
&rpc_router(&state),
"mpm.control.run",
json!({
"project_id": "p",
"workdir": dir.path().to_string_lossy(),
"backend": "tmux",
"claude_cmd": "sh",
}),
)
.await;
let error = response.error.expect("must refuse");
assert_eq!(
error.code, CODE_INVALID_PARAMS,
"a 400 projects onto -32602"
);
assert_eq!(
error.message,
"claude_cmd override is not permitted over HTTP; omit it to use the default `claude`",
"the refusal must come from validate_claude_cmd, not from somewhere else"
);
}
#[tokio::test]
async fn rpc_ctl_run_rejects_prompt_file_injection() {
let (state, _dir) = test_state_no_spawn().await;
let dir = tempfile::tempdir().expect("temp dir");
let response = rpc_call(
&rpc_router(&state),
"mpm.control.run",
json!({
"project_id": "p",
"workdir": dir.path().to_string_lossy(),
"backend": "tmux",
"prompt_file": "/tmp/x; touch /tmp/PWNED; #",
}),
)
.await;
let error = response.error.expect("must refuse");
assert_eq!(error.code, CODE_INVALID_PARAMS);
assert_eq!(
error.message,
"prompt_file must be an absolute path (no `..`, no shell metacharacters) to an existing file"
);
assert!(
!std::path::Path::new("/tmp/PWNED").exists(),
"the injected command must never have run"
);
}
#[tokio::test]
async fn rpc_ctl_run_rejects_relative_workdir() {
let (state, _dir) = test_state_no_spawn().await;
let response = rpc_call(
&rpc_router(&state),
"mpm.control.run",
json!({"project_id": "p", "workdir": "relative/dir", "backend": "stream-json"}),
)
.await;
let error = response.error.expect("must refuse");
assert_eq!(error.code, CODE_INVALID_PARAMS);
assert_eq!(
error.message,
"workdir must be an absolute path (no `..`) to an existing directory"
);
}
#[tokio::test]
async fn rpc_ctl_run_rejects_workdir_traversal() {
let (state, _dir) = test_state_no_spawn().await;
let response = rpc_call(
&rpc_router(&state),
"mpm.control.run",
json!({"project_id": "p", "workdir": "/tmp/../etc", "backend": "stream-json"}),
)
.await;
let error = response.error.expect("must refuse");
assert_eq!(error.code, CODE_INVALID_PARAMS);
assert_eq!(
error.message,
"workdir must be an absolute path (no `..`) to an existing directory"
);
}
#[tokio::test]
async fn rpc_ctl_run_accepts_a_default_request_and_reaches_the_spawn_path() {
let (state, _dir) = test_state_no_spawn().await;
let dir = tempfile::tempdir().expect("temp dir");
let response = rpc_call(
&rpc_router(&state),
"mpm.control.run",
json!({
"project_id": "p",
"workdir": dir.path().to_string_lossy(),
"backend": "stream-json",
}),
)
.await;
let error = response
.error
.expect("the cap-0 state refuses at admission");
assert_eq!(
error.code,
trusty_common::uds::server::CODE_INTERNAL_ERROR,
"a valid request must reach run_session, not a guard: {}",
error.message
);
assert_ne!(error.message, CallerTrust::REFUSAL);
}
#[tokio::test]
async fn rpc_ctl_list_parity() {
let (state, _dir) = test_state().await;
let http = http_call(
http_router(Arc::clone(&state)),
Method::GET,
"/api/v1/control/sessions",
None,
)
.await;
let rpc = rpc_call(&rpc_router(&state), "mpm.control.list", json!({})).await;
assert_parity("mpm.control.list", &http, &rpc);
}
#[tokio::test]
async fn rpc_ctl_stop_parity() {
let (state, _dir) = test_state().await;
let http = http_call(
http_router(Arc::clone(&state)),
Method::POST,
"/api/v1/control/sessions/ghost/stop",
None,
)
.await;
let rpc = rpc_call(
&rpc_router(&state),
"mpm.control.stop",
json!({"id": "ghost"}),
)
.await;
assert_eq!(http.status, 404);
assert_parity("mpm.control.stop", &http, &rpc);
}
#[tokio::test]
async fn rpc_ctl_auth_parity() {
let (state, _dir) = test_state().await;
let http = http_call(
http_router(Arc::clone(&state)),
Method::GET,
"/api/v1/control/sessions/ghost/auth",
None,
)
.await;
let rpc = rpc_call(
&rpc_router(&state),
"mpm.control.auth",
json!({"id": "ghost"}),
)
.await;
assert_eq!(http.status, 404);
assert_parity("mpm.control.auth", &http, &rpc);
}
#[tokio::test]
async fn rpc_ctl_connect_unknown_session_refuses_before_any_frame() {
let (state, _dir) = test_state().await;
let router = rpc_router(&state);
let frame = json!({
"jsonrpc": "2.0", "id": 1, "stream": true,
"method": "mpm.control.connect", "params": {"id": "ghost"},
});
let outcome = router
.dispatch_streaming(frame.to_string().as_bytes())
.await;
let trusty_common::uds::server::RpcOutcome::Stream { mut items, .. } = outcome else {
panic!("a streaming request must produce a stream outcome");
};
let first = items.recv().await.expect("one terminal frame");
let error = first.expect_err("an unknown session must refuse");
assert_eq!(error.code, CODE_NOT_FOUND);
assert_eq!(error.message, "session ghost not found");
assert!(
items.recv().await.is_none(),
"a refusal carries exactly one frame"
);
}
#[tokio::test]
async fn rpc_ctl_connect_refuses_a_unary_request() {
let (state, _dir) = test_state().await;
let response = rpc_call(
&rpc_router(&state),
"mpm.control.connect",
json!({"id": "x"}),
)
.await;
assert_eq!(
response.error.expect("must refuse").code,
trusty_common::uds::server::CODE_STREAM_REQUIRED
);
}
#[tokio::test]
async fn rpc_ctl_connect_streams_events() {
use crate::control::id::ControlSessionId;
use crate::control::state::SessionMetadata;
let (state, _dir) = test_state().await;
let id = ControlSessionId::new("stream-proj", 0);
let (command_tx, _command_rx) = tokio::sync::mpsc::channel(4);
let (event_tx, _) = tokio::sync::broadcast::channel(16);
let handle = crate::control::actor::SessionActorHandle {
command_tx,
event_tx: event_tx.clone(),
write_lock_held: Arc::new(std::sync::atomic::AtomicBool::new(false)),
metadata: Arc::new(tokio::sync::RwLock::new(SessionMetadata::new(
id.clone(),
"stream-proj".into(),
crate::control::event::BackendKind::StreamJson,
))),
};
state.session_registry.register(id.clone(), handle).await;
let router = rpc_router(&state);
let frame = json!({
"jsonrpc": "2.0", "id": 1, "stream": true,
"method": "mpm.control.connect", "params": {"id": id.to_string()},
});
let outcome = router
.dispatch_streaming(frame.to_string().as_bytes())
.await;
let trusty_common::uds::server::RpcOutcome::Stream { mut items, .. } = outcome else {
panic!("a streaming request must produce a stream outcome");
};
let event = crate::control::event::SessionEvent::ObserverLagged {
session_id: id.clone(),
dropped: 7,
};
let expected = serde_json::to_value(&event).expect("serialize");
event_tx.send(event).expect("the subscriber is live");
let first = items.recv().await.expect("one item").expect("not an error");
assert_eq!(
first, expected,
"a stream frame must carry the SessionEvent verbatim"
);
}
async fn assert_missing_session_parity(
label: &str,
http_method: Method,
http_uri: &str,
rpc_method: &str,
rpc_params: Value,
) {
let (state, _dir) = test_state().await;
let body = if http_method == Method::GET || http_method == Method::DELETE {
None
} else {
Some(json!({"text": "x", "answer": "x", "name": "x"}))
};
let http = http_call(http_router(Arc::clone(&state)), http_method, http_uri, body).await;
let rpc = rpc_call(&rpc_router(&state), rpc_method, rpc_params).await;
assert!(
http.status >= 400,
"{label}: an absent session must refuse, got {}",
http.status
);
assert_parity(label, &http, &rpc);
}
#[tokio::test]
async fn managed_get_parity() {
assert_missing_session_parity(
"mpm.managed.get",
Method::GET,
&format!("/api/v1/sessions/managed/{MISSING_ID}"),
"mpm.managed.get",
json!({"id": MISSING_ID}),
)
.await;
}
#[tokio::test]
async fn managed_runtime_stop_parity() {
assert_missing_session_parity(
"mpm.managed.runtime_stop",
Method::POST,
&format!("/api/v1/sessions/managed/{MISSING_ID}/runtime-stop"),
"mpm.managed.runtime_stop",
json!({"id": MISSING_ID}),
)
.await;
}
#[tokio::test]
async fn managed_stop_alias_parity() {
assert_missing_session_parity(
"mpm.managed.stop",
Method::DELETE,
&format!("/api/v1/sessions/managed/{MISSING_ID}"),
"mpm.managed.stop",
json!({"id": MISSING_ID}),
)
.await;
}
#[tokio::test]
async fn managed_send_parity() {
assert_missing_session_parity(
"mpm.managed.send",
Method::POST,
&format!("/api/v1/sessions/managed/{MISSING_ID}/send"),
"mpm.managed.send",
json!({"id": MISSING_ID, "text": "x"}),
)
.await;
}
#[tokio::test]
async fn managed_answer_parity() {
assert_missing_session_parity(
"mpm.managed.answer",
Method::POST,
&format!("/api/v1/sessions/managed/{MISSING_ID}/answer"),
"mpm.managed.answer",
json!({"id": MISSING_ID, "answer": "x"}),
)
.await;
}
#[tokio::test]
async fn managed_attach_cmd_parity() {
assert_missing_session_parity(
"mpm.managed.attach_cmd",
Method::GET,
&format!("/api/v1/sessions/managed/{MISSING_ID}/attach-cmd"),
"mpm.managed.attach_cmd",
json!({"id": MISSING_ID}),
)
.await;
}
#[tokio::test]
async fn managed_activity_parity() {
assert_missing_session_parity(
"mpm.managed.activity",
Method::GET,
&format!("/api/v1/sessions/managed/{MISSING_ID}/activity"),
"mpm.managed.activity",
json!({"id": MISSING_ID}),
)
.await;
}
#[tokio::test]
async fn managed_rename_parity() {
assert_missing_session_parity(
"mpm.managed.rename",
Method::PATCH,
&format!("/api/v1/sessions/managed/{MISSING_ID}"),
"mpm.managed.rename",
json!({"id": MISSING_ID, "name": "x"}),
)
.await;
}
#[tokio::test]
async fn managed_resume_parity() {
assert_missing_session_parity(
"mpm.managed.resume",
Method::POST,
&format!("/api/v1/sessions/managed/{MISSING_ID}/resume"),
"mpm.managed.resume",
json!({"id": MISSING_ID}),
)
.await;
}
#[tokio::test]
async fn managed_reactivate_parity() {
assert_missing_session_parity(
"mpm.managed.reactivate",
Method::POST,
&format!("/api/v1/sessions/managed/{MISSING_ID}/reactivate"),
"mpm.managed.reactivate",
json!({"id": MISSING_ID}),
)
.await;
}
#[tokio::test]
async fn managed_decommission_parity() {
assert_missing_session_parity(
"mpm.managed.decommission",
Method::POST,
&format!("/api/v1/sessions/managed/{MISSING_ID}/decommission"),
"mpm.managed.decommission",
json!({"id": MISSING_ID}),
)
.await;
}
#[tokio::test]
async fn managed_delete_parity() {
assert_missing_session_parity(
"mpm.managed.delete",
Method::POST,
&format!("/api/v1/sessions/managed/{MISSING_ID}/delete"),
"mpm.managed.delete",
json!({"id": MISSING_ID}),
)
.await;
}
#[tokio::test]
async fn managed_provision_status_parity() {
assert_missing_session_parity(
"mpm.managed.provision_status",
Method::GET,
&format!("/api/v1/sessions/managed/{MISSING_ID}/provision-status"),
"mpm.managed.provision_status",
json!({"id": MISSING_ID}),
)
.await;
}
#[tokio::test]
async fn managed_sync_assets_parity() {
assert_missing_session_parity(
"mpm.managed.sync_assets",
Method::POST,
&format!("/api/v1/sessions/managed/{MISSING_ID}/sync-assets"),
"mpm.managed.sync_assets",
json!({"id": MISSING_ID}),
)
.await;
}
#[tokio::test]
async fn managed_unparseable_id_parity() {
let (state, _dir) = test_state().await;
let http = http_call(
http_router(Arc::clone(&state)),
Method::GET,
"/api/v1/sessions/managed/not-a-uuid",
None,
)
.await;
let rpc = rpc_call(
&rpc_router(&state),
"mpm.managed.get",
json!({"id": "not-a-uuid"}),
)
.await;
assert_eq!(http.status, 400);
assert_parity("mpm.managed.get (bad id)", &http, &rpc);
}
#[tokio::test]
async fn managed_list_parity() {
let (state, _dir) = test_state().await;
let http = http_call(
http_router(Arc::clone(&state)),
Method::GET,
"/api/v1/sessions/managed",
None,
)
.await;
let rpc = rpc_call(&rpc_router(&state), "mpm.managed.list", json!({})).await;
assert_eq!(http.status, 200);
assert_parity("mpm.managed.list", &http, &rpc);
}
#[tokio::test]
async fn managed_fleet_parity() {
let (state, _dir) = test_state().await;
let http = http_call(
http_router(Arc::clone(&state)),
Method::GET,
"/api/v1/sessions/managed/fleet",
None,
)
.await;
let rpc = rpc_call(&rpc_router(&state), "mpm.managed.fleet", json!({})).await;
assert_eq!(http.status, 200);
assert_parity("mpm.managed.fleet", &http, &rpc);
}
#[tokio::test]
async fn managed_sync_assets_all_parity() {
let (state, _dir) = test_state().await;
let http = http_call(
http_router(Arc::clone(&state)),
Method::POST,
"/api/v1/sessions/managed/sync-assets",
None,
)
.await;
let rpc = rpc_call(
&rpc_router(&state),
"mpm.managed.sync_assets_all",
json!({}),
)
.await;
assert_eq!(http.status, 200);
assert_parity("mpm.managed.sync_assets_all", &http, &rpc);
}
#[tokio::test]
async fn managed_decommission_ephemeral_parity() {
let (state, _dir) = test_state().await;
let http = http_call(
http_router(Arc::clone(&state)),
Method::POST,
"/api/v1/sessions/managed/decommission-ephemeral?dry_run=true",
None,
)
.await;
let rpc = rpc_call(
&rpc_router(&state),
"mpm.managed.decommission_ephemeral",
json!({"dry_run": true}),
)
.await;
assert_eq!(http.status, 200);
assert_parity("mpm.managed.decommission_ephemeral", &http, &rpc);
}
#[tokio::test]
async fn managed_prune_parity() {
let (state, _dir) = test_state().await;
let body = json!({"state": "stopped", "dry_run": true, "include_active": false});
let http = http_call(
http_router(Arc::clone(&state)),
Method::POST,
"/api/v1/sessions/managed/prune",
Some(body.clone()),
)
.await;
let rpc = rpc_call(&rpc_router(&state), "mpm.managed.prune", body).await;
assert_eq!(http.status, 200);
assert_parity("mpm.managed.prune", &http, &rpc);
}
#[tokio::test]
async fn managed_prune_rejects_an_unparseable_invoking_session() {
let (state, _dir) = test_state().await;
let body = json!({
"state": "stopped", "dry_run": true, "include_active": false,
"invoking_session": "not-a-uuid",
});
let http = http_call(
http_router(Arc::clone(&state)),
Method::POST,
"/api/v1/sessions/managed/prune",
Some(body.clone()),
)
.await;
let rpc = rpc_call(&rpc_router(&state), "mpm.managed.prune", body).await;
assert_eq!(http.status, 400);
assert!(
http.text.contains("#6118"),
"the refusal must be #6118's, got {:?}",
http.text
);
assert_parity("mpm.managed.prune (bad invoker)", &http, &rpc);
}
fn reported_paths(body: &Value) -> Vec<String> {
["paths", "owner_unknown_paths", "agent_owned_paths"]
.iter()
.filter_map(|key| body.get(*key).and_then(|v| v.as_array()))
.flatten()
.filter_map(|v| v.as_str().map(str::to_owned))
.collect()
}
#[serial_test::serial]
#[tokio::test]
async fn managed_prune_worktrees_parity_defaults_to_a_preview() {
let (state, _dir) = test_state().await;
let workspace = tempfile::tempdir().expect("workspace root");
let _env = WorkspaceRootEnv::pin(workspace.path());
let http = http_call(
http_router(Arc::clone(&state)),
Method::POST,
"/api/v1/sessions/managed/prune-worktrees",
Some(json!({})),
)
.await;
let rpc = rpc_call(
&rpc_router(&state),
"mpm.managed.prune_worktrees",
json!({}),
)
.await;
assert_eq!(http.status, 200);
assert_eq!(
http.json.as_ref().and_then(|v| v["dry_run"].as_bool()),
Some(true),
"an unspecified prune must preview, not delete"
);
assert_parity("mpm.managed.prune_worktrees", &http, &rpc);
}
#[serial_test::serial]
#[tokio::test]
async fn managed_prune_worktrees_enumerates_only_the_pinned_workspace_root() {
use crate::session_manager::worktree_git_fixture::GitWorktreeFixture;
let (state, _dir) = test_state().await;
let fx = GitWorktreeFixture::new();
let orphan = fx.add_worktree("orphaned-6551");
GitWorktreeFixture::stamp_reclaimable_sentinel(&orphan);
let root = fx
.repos_root
.canonicalize()
.unwrap_or_else(|_| fx.repos_root.clone());
let _env = WorkspaceRootEnv::pin(&root);
let http = http_call(
http_router(Arc::clone(&state)),
Method::POST,
"/api/v1/sessions/managed/prune-worktrees",
Some(json!({})),
)
.await;
assert_eq!(http.status, 200);
let body = http.json.as_ref().expect("prune-worktrees returns json");
let paths = reported_paths(body);
assert!(
!paths.is_empty(),
"the seeded orphan at {} must be reported, or this proves nothing: {body}",
orphan.display()
);
let stray: Vec<&String> = paths
.iter()
.filter(|p| !std::path::Path::new(p).starts_with(&root))
.collect();
assert!(
stray.is_empty(),
"#6551: the preview scanned outside the pinned workspace root {} — \
these paths come from the ambient $HOME: {stray:?}",
root.display()
);
}
#[serial_test::serial]
#[tokio::test]
async fn managed_reconcile_worktrees_parity() {
let (state, _dir) = test_state().await;
let workspace = tempfile::tempdir().expect("workspace root");
let _env = WorkspaceRootEnv::pin(workspace.path());
let http = http_call(
http_router(Arc::clone(&state)),
Method::GET,
"/api/v1/sessions/managed/reconcile-worktrees",
None,
)
.await;
let rpc = rpc_call(
&rpc_router(&state),
"mpm.managed.reconcile_worktrees",
json!({}),
)
.await;
assert_parity("mpm.managed.reconcile_worktrees", &http, &rpc);
}
#[tokio::test]
async fn managed_spawn_rejects_an_invalid_runtime_on_both_transports() {
let (state, _dir) = test_state().await;
let body = json!({
"repo_url": "https://example.com/x.git",
"ref": "main",
"task": "t",
"runtime": "nonsense-runtime",
});
let http = http_call(
http_router(Arc::clone(&state)),
Method::POST,
"/api/v1/sessions/managed",
Some(body.clone()),
)
.await;
let rpc = rpc_call(&rpc_router(&state), "mpm.managed.spawn", body).await;
assert_eq!(
http.status, 400,
"an invalid runtime must be refused before any provisioning side effect"
);
assert_parity("mpm.managed.spawn (bad runtime)", &http, &rpc);
}
#[tokio::test]
async fn managed_adopt_rejects_an_invalid_runtime_on_both_transports() {
let (state, _dir) = test_state().await;
let body = json!({"tmux_name": "no-such-pane", "cwd": "/tmp", "runtime": "nonsense-runtime"});
let http = http_call(
http_router(Arc::clone(&state)),
Method::POST,
"/api/v1/sessions/managed/adopt",
Some(body.clone()),
)
.await;
let rpc = rpc_call(&rpc_router(&state), "mpm.managed.adopt", body).await;
assert_eq!(http.status, 400);
assert_parity("mpm.managed.adopt (bad runtime)", &http, &rpc);
}
#[derive(Debug, Default)]
struct LiveTrackingTmux {
live: std::sync::Mutex<Vec<String>>,
}
impl crate::session_manager::ManagedTmuxDriver for LiveTrackingTmux {
fn create_session(&self, name: &str, _workdir: &str) -> Result<(), ManagedError> {
self.live
.lock()
.expect("not poisoned")
.push(name.to_owned());
Ok(())
}
fn kill_session(&self, name: &str) -> Result<(), ManagedError> {
self.live
.lock()
.expect("not poisoned")
.retain(|n| n != name);
Ok(())
}
fn send_line(&self, _name: &str, _text: &str) -> Result<(), ManagedError> {
Ok(())
}
fn send_keys_literal(&self, _name: &str, _text: &str) -> Result<(), ManagedError> {
Ok(())
}
fn send_interrupt(&self, _name: &str) -> Result<(), ManagedError> {
Ok(())
}
fn capture(&self, _name: &str, _lines: usize) -> Result<String, ManagedError> {
Ok(String::new())
}
fn list_sessions(&self) -> Result<Vec<String>, ManagedError> {
Ok(self.live.lock().expect("not poisoned").clone())
}
}
async fn test_state_live_tmux() -> (Arc<DaemonState>, tempfile::TempDir) {
let dir = tempfile::tempdir().expect("temp dir");
let driver: Arc<dyn crate::session_manager::ManagedTmuxDriver> =
Arc::new(LiveTrackingTmux::default());
let state =
DaemonState::with_root_isolated_managed_and_driver(dir.path().to_path_buf(), driver).await;
(Arc::new(state), dir)
}
async fn seed_running_session(
state: &Arc<DaemonState>,
root: &std::path::Path,
) -> crate::session_manager::ManagedSessionId {
let mgr = state.session_manager().await;
let id = crate::session_manager::ManagedSessionId::new();
let workspace = root.join(format!("{id}-force-test"));
mgr.create_with_id(
id,
"force-flag test".to_string(),
Some(workspace.clone()),
None,
Some(workspace),
Some("https://example.com/r.git".to_string()),
Some("main".to_string()),
crate::runtime::RuntimeKind::default(),
false,
false,
)
.await
.expect("seed session");
id
}
#[tokio::test]
async fn managed_delete_refuses_a_running_session_without_force() {
let (state, dir) = test_state_live_tmux().await;
let session = seed_running_session(&state, dir.path()).await;
let id = session.to_string();
let http = http_call(
http_router(Arc::clone(&state)),
Method::POST,
&format!("/api/v1/sessions/managed/{id}/delete?force=false"),
None,
)
.await;
let rpc = rpc_call(
&rpc_router(&state),
"mpm.managed.delete",
json!({"id": id, "force": false}),
)
.await;
assert_eq!(
http.status, 409,
"a running session must be refused without force, body={:?}",
http.text
);
assert_parity("mpm.managed.delete (running, no force)", &http, &rpc);
let mgr = state.session_manager().await;
let record = mgr.get(&session).await.expect("record must survive");
assert_ne!(
record.state,
crate::session_manager::ManagedSessionState::Deleted,
"a refused delete must not have deleted the record"
);
}
#[tokio::test]
async fn managed_delete_force_bypasses_the_running_guard_on_both_transports() {
let (state, dir) = test_state_live_tmux().await;
let over_http = seed_running_session(&state, dir.path()).await;
let over_rpc = seed_running_session(&state, dir.path()).await;
let http = http_call(
http_router(Arc::clone(&state)),
Method::POST,
&format!("/api/v1/sessions/managed/{over_http}/delete?force=true"),
None,
)
.await;
let rpc = rpc_call(
&rpc_router(&state),
"mpm.managed.delete",
json!({"id": over_rpc.to_string(), "force": true}),
)
.await;
assert_eq!(
http.status, 200,
"force=true must bypass the running guard, body={:?}",
http.text
);
let http_body = http.json.clone().expect("a 200 delete answers JSON");
let rpc_body = rpc.result.clone().expect("the socket must not refuse");
assert_eq!(
http_body["deleted"],
json!(true),
"HTTP must report the record deleted"
);
assert_eq!(
rpc_body["deleted"],
json!(true),
"the socket must report the record deleted"
);
assert_eq!(
blank_identity(&http_body),
blank_identity(&rpc_body),
"the two transports must answer the same body once the per-record \
identity fields are set aside"
);
let mgr = state.session_manager().await;
for id in [&over_http, &over_rpc] {
let record = mgr
.get(id)
.await
.expect("a deleted record is kept, not removed");
assert_eq!(
record.state,
crate::session_manager::ManagedSessionState::Deleted,
"session {id} must be marked deleted on both transports"
);
}
}
fn blank_identity(body: &Value) -> Value {
let mut body = body.clone();
if let Some(map) = body.as_object_mut() {
for field in [
"id",
"name",
"tmux_name",
"cwd",
"workspace_path",
"created_at",
"runtime_left_running",
] {
if map.contains_key(field) {
map.insert(field.to_owned(), Value::Null);
}
}
}
body
}
#[tokio::test]
async fn rpc_ctl_stop_decodes_the_force_flag() {
use crate::control::ActorCommand;
let (state, _dir) = test_state().await;
let id = crate::control::ControlSessionId::new("force-proj", 0);
let (command_tx, mut commands) = tokio::sync::mpsc::channel(4);
let (event_tx, _) = tokio::sync::broadcast::channel(4);
let handle = crate::control::actor::SessionActorHandle {
command_tx,
event_tx,
write_lock_held: Arc::new(std::sync::atomic::AtomicBool::new(false)),
metadata: Arc::new(tokio::sync::RwLock::new(
crate::control::state::SessionMetadata::new(
id.clone(),
"force-proj".into(),
crate::control::event::BackendKind::StreamJson,
),
)),
};
state.session_registry.register(id.clone(), handle).await;
let router = rpc_router(&state);
let forced = rpc_call(
&router,
"mpm.control.stop",
json!({"id": id.to_string(), "force": true}),
)
.await;
assert_eq!(
forced.result.expect("stop must succeed")["force"],
json!(true),
"the response must echo the flag it acted on"
);
assert!(
matches!(
commands.recv().await.expect("a command must be sent"),
ActorCommand::ForceStop
),
"force: true must reach the actor as ForceStop, not Stop"
);
let graceful = rpc_call(&router, "mpm.control.stop", json!({"id": id.to_string()})).await;
assert_eq!(
graceful.result.expect("stop must succeed")["force"],
json!(false)
);
assert!(
matches!(
commands.recv().await.expect("a command must be sent"),
ActorCommand::Stop
),
"an omitted force must reach the actor as Stop"
);
}
#[tokio::test]
async fn proxy_focus_parity() {
let (state, _dir) = test_state().await;
let body = json!({"conversation_key": "c1", "session_id": ""});
let http = http_call(
http_router(Arc::clone(&state)),
Method::POST,
"/api/v1/sessions/proxy/focus",
Some(body.clone()),
)
.await;
let rpc = rpc_call(&rpc_router(&state), "mpm.proxy.focus", body).await;
assert_eq!(http.status, 200);
assert_parity("mpm.proxy.focus", &http, &rpc);
}
#[tokio::test]
async fn proxy_get_focus_parity() {
let (state, _dir) = test_state().await;
let http = http_call(
http_router(Arc::clone(&state)),
Method::GET,
"/api/v1/sessions/proxy/focus/c1",
None,
)
.await;
let rpc = rpc_call(
&rpc_router(&state),
"mpm.proxy.get_focus",
json!({"conversation_key": "c1"}),
)
.await;
assert_eq!(http.status, 200);
assert_parity("mpm.proxy.get_focus", &http, &rpc);
}
#[tokio::test]
async fn proxy_unfocus_parity() {
let (state, _dir) = test_state().await;
let body = json!({"conversation_key": "c1"});
let http = http_call(
http_router(Arc::clone(&state)),
Method::POST,
"/api/v1/sessions/proxy/unfocus",
Some(body.clone()),
)
.await;
let rpc = rpc_call(&rpc_router(&state), "mpm.proxy.unfocus", body).await;
assert_eq!(http.status, 200);
assert_parity("mpm.proxy.unfocus", &http, &rpc);
}
#[tokio::test]
async fn proxy_message_parity() {
let (state, _dir) = test_state().await;
let body = json!({"conversation_key": "c1", "text": "hello"});
let http = http_call(
http_router(Arc::clone(&state)),
Method::POST,
"/api/v1/sessions/proxy/message",
Some(body.clone()),
)
.await;
let rpc = rpc_call(&rpc_router(&state), "mpm.proxy.message", body).await;
assert_eq!(http.status, 200);
assert_parity("mpm.proxy.message", &http, &rpc);
}
#[tokio::test]
async fn proxy_summary_parity() {
let (state, _dir) = test_state().await;
let http = http_call(
http_router(Arc::clone(&state)),
Method::GET,
"/api/v1/sessions/proxy/summary/c1",
None,
)
.await;
let rpc = rpc_call(
&rpc_router(&state),
"mpm.proxy.summary",
json!({"conversation_key": "c1"}),
)
.await;
assert_eq!(http.status, 200);
assert_parity("mpm.proxy.summary", &http, &rpc);
}
#[tokio::test]
async fn residency_active_round_trips_over_the_real_socket() {
use tokio::sync::oneshot;
let (state, _dir) = test_state().await;
let router = Arc::new(managed::register(RpcRouter::new(), &state));
let socket_dir = tempfile::tempdir().expect("socket tempdir");
let socket = socket_dir.path().join("trusty-mpm.sock");
let listener = trusty_common::uds::bind_hardened(&socket).expect("bind");
let (stop, shutdown) = oneshot::channel::<()>();
tokio::spawn(async move {
trusty_common::uds::server::serve_until(
&listener,
router,
trusty_common::uds::server::RpcServeOptions::default(),
async {
let _ = shutdown.await;
},
)
.await;
});
let set = trusty_common::mpm_rpc::fetch_active_projects_at(
&socket,
std::time::Duration::from_secs(5),
)
.await
.expect("fetch_active_projects_at");
assert_eq!(
set.schema,
trusty_common::residency::ACTIVE_PROJECT_SET_SCHEMA
);
assert_eq!(set.projects.len(), 0, "no managed sessions seeded");
let _ = stop.send(());
}