use std::path::{Path, PathBuf};
use serde_json::Value;
use trusty_mpm::core::agent::is_subagent_dispatch_tool;
use trusty_mpm::core::content_source::AgentRoster;
use trusty_mpm::core::dispatch_isolation::{
blocked_by_shared_tree, dispatch_agent, dispatch_isolation,
};
use trusty_mpm::daemon::delegation_routes::TREE_HOLDERS_MARKER;
use crate::commands::hook_payload::build_hook_payload;
use crate::commands::pm_guard_dispatch_deny::{
blocking_records, deny_reason, granted_deny_reason, records_in,
};
pub(crate) fn evaluate_shared_tree_dispatch(
roster: &AgentRoster,
tool_name: &str,
tool_input: Option<&Value>,
cwd: &Path,
live: &[String],
) -> Option<String> {
if live.is_empty() || !dispatch_shares_the_tree(roster, tool_name, tool_input) {
return None;
}
let agent = dispatch_agent(tool_input).unwrap_or("this");
Some(deny_reason(agent, cwd, live))
}
pub(crate) fn dispatch_shares_the_tree(
roster: &AgentRoster,
tool_name: &str,
tool_input: Option<&Value>,
) -> bool {
is_subagent_dispatch_tool(tool_name)
&& dispatch_agent(tool_input).is_some_and(|agent| {
blocked_by_shared_tree(Some(roster), agent, dispatch_isolation(tool_input))
})
}
pub(crate) fn dispatch_cwd(payload: &Value) -> Option<PathBuf> {
resolve_dispatch_cwd(std::env::current_dir().ok(), payload)
}
pub(crate) fn resolve_dispatch_cwd(
process_cwd: Option<PathBuf>,
payload: &Value,
) -> Option<PathBuf> {
process_cwd.or_else(|| {
payload
.get("cwd")
.and_then(Value::as_str)
.filter(|s| !s.is_empty())
.map(PathBuf::from)
})
}
fn project_dispatch_input(forwarded: &mut Value) {
const FORWARDED_INPUT_FIELDS: [&str; 3] = ["subagent_type", "isolation", "description"];
let Some(input) = forwarded.get("input").filter(|i| i.is_object()) else {
return;
};
let mut projected = serde_json::Map::new();
for key in FORWARDED_INPUT_FIELDS {
if let Some(value) = input.get(key) {
projected.insert(key.to_string(), value.clone());
}
}
forwarded["input"] = Value::Object(projected);
}
pub(crate) async fn claim_shared_tree(
url: &str,
session_id: &str,
cwd: &Path,
payload: &Value,
) -> SharedTreeClaim {
claim_shared_tree_on(
url,
session_id,
cwd,
payload,
SHARED_TREE_ROUTE,
warn_on_eligibility_divergence,
)
.await
}
async fn claim_shared_tree_on(
url: &str,
session_id: &str,
cwd: &Path,
payload: &Value,
route: &str,
warn_on_answer: impl Fn(&Value, &[String]),
) -> SharedTreeClaim {
match post_shared_tree(url, session_id, cwd, payload, route).await {
SharedTreeReply::Answered(body) => {
let live = writers_in(&body);
warn_on_answer(&body, &live);
SharedTreeClaim::Writers(live, records_in(&body))
}
SharedTreeReply::Unavailable(detail) => {
warn_guard_unavailable(&detail);
SharedTreeClaim::Writers(Vec::new(), Vec::new())
}
SharedTreeReply::Unanswered(detail) => SharedTreeClaim::Unknown(detail),
}
}
pub(crate) enum SharedTreeClaim {
Writers(
Vec<String>,
Vec<trusty_mpm::daemon::services::delegation_records::DelegationRecordView>,
),
Unknown(String),
}
fn warn_guard_unavailable(detail: &str) {
eprintln!(
"tm hook --pm-guard: no daemon answered the shared-tree claim (#5923) — {detail}. \
Concurrent shared-worktree dispatch is NOT being enforced for this dispatch: a second \
file-mutating agent can join this git HEAD, and git reports nothing when it does. Start \
the daemon (`tm start`) to restore the guard. Allowing the dispatch: an absent daemon is \
a degraded mode this path accepts deliberately, unlike a daemon that is running and did \
not answer, which denies."
);
}
fn unanswered_deny_reason(agent: &str, cwd: &Path, detail: &str) -> String {
format!(
"Concurrent shared-worktree dispatch denied (#5923): the daemon did not answer this \
guard's claim for {} — {detail}. That answer is the only thing that can say whether \
another file-mutating agent is already writing in this directory, so admitting this \
{agent} dispatch would put a second agent on one git HEAD with the check that exists to \
stop it never having run. A timeout or an error from a running daemon is correlated with \
load, which is when a concurrent dispatch is most likely, so this denies rather than \
assuming the tree is free. Re-dispatch this agent with `isolation: \"worktree\"` — an \
isolated dispatch never needs this answer and is never blocked by it. If isolation is \
unavailable here, serialize instead: dispatch one file-mutating agent at a time. If the \
daemon is unhealthy, `tm doctor` reports it and `tm restart` clears it.",
cwd.display()
)
}
pub(crate) async fn live_shared_tree_writers(
url: &str,
session_id: &str,
cwd: &Path,
payload: &Value,
) -> Vec<String> {
post_shared_tree(url, session_id, cwd, payload, SHARED_TREE_ROUTE)
.await
.answered()
.as_ref()
.map(writers_in)
.unwrap_or_default()
}
pub(crate) async fn live_shared_tree_writers_or_deny(
url: &str,
session_id: &str,
cwd: &Path,
payload: &Value,
) -> Result<Vec<String>, String> {
owners_or_deny(post_shared_tree(url, session_id, cwd, payload, SHARED_TREE_ROUTE).await)
}
fn owners_or_deny(reply: SharedTreeReply) -> Result<Vec<String>, String> {
match reply {
SharedTreeReply::Answered(body) => Ok(writers_in(&body)),
SharedTreeReply::Unavailable(detail) | SharedTreeReply::Unanswered(detail) => Err(detail),
}
}
pub(crate) async fn live_shared_tree_writers_in(
url: &str,
session_id: &str,
dirs: &[&Path],
payload: &Value,
) -> Vec<String> {
let mut asked: Vec<&Path> = Vec::with_capacity(dirs.len());
for dir in dirs {
if asked.contains(dir) {
continue;
}
asked.push(dir);
let live = live_shared_tree_writers(url, session_id, dir, payload).await;
if !live.is_empty() {
return live;
}
}
Vec::new()
}
pub(crate) async fn evaluate_granted_worktree(
url: &str,
session_id: &str,
cwd: &Path,
payload: &Value,
tool_input: Option<&Value>,
updated_input: &Value,
) -> Option<String> {
let mut granted = payload.clone();
granted["tool_input"] = updated_input.clone();
let agent = dispatch_agent(tool_input).unwrap_or("this");
match claim_shared_tree_on(
url,
session_id,
cwd,
&granted,
GRANTED_WORKTREE_ROUTE,
|body, live| warn_on_unrecorded_grant(body, live, cwd),
)
.await
{
SharedTreeClaim::Writers(live, _) if live.is_empty() => None,
SharedTreeClaim::Writers(live, records) => {
Some(granted_deny_reason(agent, cwd, &live) + &blocking_records(cwd, &records))
}
SharedTreeClaim::Unknown(detail) => Some(unanswered_grant_deny_reason(agent, cwd, &detail)),
}
}
fn warn_on_unrecorded_grant(body: &Value, live: &[String], cwd: &Path) {
if !eligibility_diverged(body, live) {
return;
}
eprintln!(
"tm hook --pm-guard: granted a worktree in {} but the daemon recorded nothing (#5769). \
The dispatch's delegation record therefore still reads as unisolated, so this agent will \
be named as writing in this checkout and `git merge`/`git rebase` here will be denied \
until the record \
goes stale. The usual cause is a running daemon older than the `tm` on PATH, built \
before the granted-worktree route existed — restart the daemon (`tm restart`) to clear \
it. Granting anyway: this path fails open by design.",
cwd.display()
);
}
fn unanswered_grant_deny_reason(agent: &str, cwd: &Path, detail: &str) -> String {
format!(
"Dispatch denied in a shared main checkout (#5923): {} is a project's main checkout and \
the daemon did not answer this guard's claim for it — {detail}. That answer is the only \
thing that can say whether another file-mutating agent is already writing here, and \
this guard asks it BEFORE granting a worktree precisely because the grant is a rewrite \
of this dispatch's arguments that this binary cannot confirm the harness applies \
(ADR-0048). Granting on silence would admit this {agent} dispatch with that check never \
having run, and if the rewrite is not applied a second file-mutating agent joins the \
same git HEAD — a commit landing on another workstream's branch, with no error at any \
step. A timeout or an error from a running daemon is correlated with load, which is \
when a concurrent dispatch is most likely, so this denies rather than assuming the \
checkout is free. Re-issue this dispatch with `isolation: \"worktree\"` declared \
explicitly — it needs no rewrite and never reaches this question. If isolation is \
unavailable here, serialize instead: dispatch one file-mutating agent at a time. If the \
daemon is unhealthy, `tm doctor` reports it and `tm restart` clears it.",
cwd.display()
)
}
pub(crate) const SHARED_TREE_ROUTE: &str = "shared-tree-dispatch";
const GRANTED_WORKTREE_ROUTE: &str = "granted-worktree";
pub(crate) enum SharedTreeReply {
Answered(Value),
Unavailable(String),
Unanswered(String),
}
impl SharedTreeReply {
fn answered(self) -> Option<Value> {
match self {
Self::Answered(body) => Some(body),
Self::Unavailable(_) | Self::Unanswered(_) => None,
}
}
}
pub(crate) async fn post_shared_tree(
url: &str,
session_id: &str,
cwd: &Path,
payload: &Value,
route: &str,
) -> SharedTreeReply {
post_shared_tree_with(shared_tree_client, url, session_id, cwd, payload, route).await
}
fn shared_tree_client() -> reqwest::Result<reqwest::Client> {
reqwest::Client::builder()
.connect_timeout(std::time::Duration::from_millis(500))
.timeout(std::time::Duration::from_secs(2))
.build()
}
async fn post_shared_tree_with<B>(
build: B,
url: &str,
session_id: &str,
cwd: &Path,
payload: &Value,
route: &str,
) -> SharedTreeReply
where
B: FnOnce() -> reqwest::Result<reqwest::Client> + Send + 'static,
{
if session_id.is_empty() {
return SharedTreeReply::Unavailable(
"the hook payload carries no session id, so no session's delegations can be addressed"
.to_string(),
);
}
if let Err(refusal) = trusty_mpm::core::refuse_isolated_no_daemon(url) {
return SharedTreeReply::Unavailable(refusal.to_string());
}
let (sender, built) = tokio::sync::oneshot::channel();
let spawned = std::thread::Builder::new()
.name("pm-guard-shared-tree-client".into())
.spawn(move || {
let _ = sender.send(build());
});
if let Err(error) = spawned {
return SharedTreeReply::Unavailable(format!(
"the guard's HTTP client build thread could not be started: {error}"
));
}
let client = match built.await {
Ok(Ok(client)) => client,
Ok(Err(error)) => {
return SharedTreeReply::Unavailable(format!(
"the guard's HTTP client could not be built: {error}"
));
}
Err(_) => panic!("the guard's HTTP client build panicked"),
};
let endpoint = format!("{url}/api/v1/sessions/{session_id}/delegations/{route}");
let mut forwarded = build_hook_payload(&cwd.display().to_string(), Some(payload), None);
project_dispatch_input(&mut forwarded);
if let Some(marker) = payload.get(TREE_HOLDERS_MARKER) {
forwarded[TREE_HOLDERS_MARKER] = marker.clone();
}
let body = serde_json::json!({ "payload": forwarded });
let response = match client.post(&endpoint).json(&body).send().await {
Ok(response) => response,
Err(error) => return classify_transport_failure(&endpoint, &error),
};
let status = response.status();
let response = match response.error_for_status() {
Ok(response) => response,
Err(_) if status == reqwest::StatusCode::NOT_FOUND => {
return SharedTreeReply::Unavailable(format!(
"the daemon has no {route} route (404), so it is older than this `tm`"
));
}
Err(error) => {
return SharedTreeReply::Unanswered(format!("the daemon answered {status}: {error}"));
}
};
match response.json::<Value>().await {
Ok(body) => SharedTreeReply::Answered(body),
Err(error) => {
SharedTreeReply::Unanswered(format!("the daemon's answer did not parse: {error}"))
}
}
}
fn classify_transport_failure(endpoint: &str, error: &reqwest::Error) -> SharedTreeReply {
if error.is_connect() && !error.is_timeout() {
return SharedTreeReply::Unavailable(format!("nothing is listening at {endpoint}"));
}
SharedTreeReply::Unanswered(format!(
"the request to {endpoint} did not complete: {error}"
))
}
pub(crate) fn writers_in(body: &Value) -> Vec<String> {
body.get("agents")
.and_then(Value::as_array)
.map(|rows| {
rows.iter()
.filter_map(|row| row.get("agent").and_then(Value::as_str))
.map(str::to_owned)
.collect()
})
.unwrap_or_default()
}
fn warn_on_eligibility_divergence(body: &Value, live: &[String]) {
if !eligibility_diverged(body, live) {
return;
}
eprintln!(
"tm hook --pm-guard: the daemon reported no live writers but claimed nothing (#5324). \
The guard classified this dispatch as sharing the working tree and the daemon did not, \
so concurrent shared-worktree dispatch is NOT being enforced for this agent. The usual \
cause is a running daemon older than the `tm` on PATH, built before this agent was \
added to its bundled table — restart the daemon (`tm restart`) to clear it. Allowing \
the dispatch: this path fails open by design."
);
}
fn eligibility_diverged(body: &Value, live: &[String]) -> bool {
let claimed = body
.get("claimed")
.and_then(Value::as_bool)
.unwrap_or(false);
live.is_empty() && !claimed
}
pub(crate) async fn evaluate(
roster: &AgentRoster,
url: &str,
payload: &Value,
tool_name: &str,
tool_input: Option<&Value>,
session_id: &str,
) -> Option<String> {
evaluate_with_cwd(
roster,
url,
dispatch_cwd(payload),
payload,
tool_name,
tool_input,
session_id,
)
.await
}
pub(crate) async fn evaluate_with_cwd(
roster: &AgentRoster,
url: &str,
cwd: Option<PathBuf>,
payload: &Value,
tool_name: &str,
tool_input: Option<&Value>,
session_id: &str,
) -> Option<String> {
if !dispatch_shares_the_tree(roster, tool_name, tool_input) {
return None;
}
let cwd = cwd?;
match claim_shared_tree(url, session_id, &cwd, payload).await {
SharedTreeClaim::Writers(live, records) => {
evaluate_shared_tree_dispatch(roster, tool_name, tool_input, &cwd, &live)
.map(|reason| reason + &blocking_records(&cwd, &records))
}
SharedTreeClaim::Unknown(detail) => Some(unanswered_deny_reason(
dispatch_agent(tool_input).unwrap_or("this"),
&cwd,
&detail,
)),
}
}
#[cfg(test)]
mod tests {
use super::*;
fn r() -> &'static trusty_mpm::core::content_source::AgentRoster {
crate::commands::install::test_roster_ref()
}
use trusty_mpm::core::agent::SUBAGENT_DISPATCH_TOOLS;
fn input(agent: &str, isolation: Option<&str>) -> Value {
match isolation {
Some(i) => serde_json::json!({"subagent_type": agent, "isolation": i}),
None => serde_json::json!({"subagent_type": agent}),
}
}
#[test]
fn denies_a_second_concurrent_unisolated_engineer() {
for tool in SUBAGENT_DISPATCH_TOOLS {
let reason = evaluate_shared_tree_dispatch(
r(),
tool,
Some(&input("rust-engineer", None)),
Path::new("/repo"),
&["python-engineer".to_string()],
)
.expect("a second unisolated engineer must be denied");
assert!(reason.contains("python-engineer"), "{reason}");
assert!(reason.contains("/repo"), "{reason}");
assert!(reason.contains(r#"isolation: "worktree""#), "{reason}");
}
}
#[test]
fn allows_the_first_dispatch() {
assert_eq!(
evaluate_shared_tree_dispatch(
r(),
"Agent",
Some(&input("rust-engineer", None)),
Path::new("/repo"),
&[],
),
None
);
}
#[test]
fn allows_an_isolated_dispatch() {
for mode in ["worktree", "remote"] {
assert_eq!(
evaluate_shared_tree_dispatch(
r(),
"Agent",
Some(&input("rust-engineer", Some(mode))),
Path::new("/repo"),
&["python-engineer".to_string()],
),
None,
"isolation={mode} must be allowed"
);
}
}
#[test]
fn allows_a_read_only_agent() {
for agent in ["research", "code-critic", "code-analyzer"] {
assert_eq!(
evaluate_shared_tree_dispatch(
r(),
"Agent",
Some(&input(agent, None)),
Path::new("/repo"),
&["rust-engineer".to_string()],
),
None,
"{agent} must be allowed alongside a running engineer"
);
}
}
#[test]
fn allows_version_control_alongside_a_live_writer() {
for tool in SUBAGENT_DISPATCH_TOOLS {
assert_eq!(
evaluate_shared_tree_dispatch(
r(),
tool,
Some(&input("version-control", None)),
Path::new("/repo"),
&["rust-engineer".to_string()],
),
None,
"{tool}: version-control must run in the tree it was given"
);
}
}
#[test]
fn denies_a_file_writing_non_engineer_alongside_an_engineer() {
for agent in ["documentation", "qa", "web-qa", "api-qa"] {
let reason = evaluate_shared_tree_dispatch(
r(),
"Agent",
Some(&input(agent, None)),
Path::new("/repo"),
&["rust-engineer".to_string()],
)
.unwrap_or_else(|| panic!("{agent} writes files and must be denied"));
assert!(reason.contains("rust-engineer"), "{reason}");
}
}
#[test]
fn allows_when_the_agent_is_unknown() {
for agent in ["some-project-agent", ""] {
assert_eq!(
evaluate_shared_tree_dispatch(
r(),
"Agent",
Some(&input(agent, None)),
Path::new("/repo"),
&["rust-engineer".to_string()],
),
None
);
}
assert_eq!(
evaluate_shared_tree_dispatch(
r(),
"Agent",
None,
Path::new("/repo"),
&["rust-engineer".to_string()]
),
None
);
}
#[test]
fn allows_every_non_dispatch_tool() {
for tool in [
"Read",
"Edit",
"Write",
"Bash",
"SendMessage",
"agent",
"task",
] {
assert_eq!(
evaluate_shared_tree_dispatch(
r(),
tool,
Some(&input("rust-engineer", None)),
Path::new("/repo"),
&["python-engineer".to_string()],
),
None,
"{tool} must never be denied by this guard"
);
}
}
#[test]
fn resolve_dispatch_cwd_falls_back_to_the_payload() {
let payload = serde_json::json!({"cwd": "/from/payload"});
assert_eq!(
resolve_dispatch_cwd(None, &payload),
Some(PathBuf::from("/from/payload"))
);
assert_eq!(
resolve_dispatch_cwd(Some(PathBuf::from("/from/process")), &payload),
Some(PathBuf::from("/from/process"))
);
}
#[test]
fn resolve_dispatch_cwd_is_none_when_nothing_resolves() {
for payload in [
serde_json::json!({}),
serde_json::json!({"cwd": ""}),
serde_json::json!({"cwd": 7}),
] {
assert_eq!(resolve_dispatch_cwd(None, &payload), None, "{payload}");
}
}
fn spawn_denying_mock() -> String {
use std::io::{Read, Write};
use std::net::TcpListener;
let listener = TcpListener::bind("127.0.0.1:0").expect("bind");
let url = format!("http://{}", listener.local_addr().expect("addr"));
std::thread::spawn(move || {
if let Ok((mut socket, _)) = listener.accept() {
let mut buf = [0u8; 4096];
let _ = socket.read(&mut buf);
let body = r#"{"agents":[{"agent":"python-engineer","count":1}],"total":1}"#;
let response = format!(
"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}",
body.len()
);
let _ = socket.write_all(response.as_bytes());
}
});
url
}
#[tokio::test]
async fn evaluate_allows_when_the_cwd_cannot_be_resolved() {
let url = spawn_denying_mock();
let verdict = evaluate_with_cwd(
r(),
&url,
None,
&serde_json::json!({}),
"Agent",
Some(&input("rust-engineer", None)),
"11111111-1111-1111-1111-111111111111",
)
.await;
assert_eq!(
verdict, None,
"an unresolvable cwd must ALLOW even when the daemon would deny"
);
}
#[test]
fn dispatch_cwd_prefers_the_process_directory() {
let payload = serde_json::json!({"cwd": "/some/other/place"});
assert_eq!(dispatch_cwd(&payload), std::env::current_dir().ok());
}
fn writers_of(claim: SharedTreeClaim) -> Vec<String> {
match claim {
SharedTreeClaim::Writers(live, _) => live,
SharedTreeClaim::Unknown(detail) => {
panic!("expected an answered claim, got Unknown({detail})")
}
}
}
fn spawn_silent_mock() -> String {
use std::io::Read;
use std::net::TcpListener;
let listener = TcpListener::bind("127.0.0.1:0").expect("bind");
let url = format!("http://{}", listener.local_addr().expect("addr"));
std::thread::spawn(move || {
if let Ok((mut socket, _)) = listener.accept() {
let mut buf = [0u8; 4096];
let _ = socket.read(&mut buf);
std::thread::sleep(std::time::Duration::from_secs(10));
}
});
url
}
fn spawn_mock_answering(status_line: &'static str, body: &'static str) -> String {
use std::io::{Read, Write};
use std::net::TcpListener;
let listener = TcpListener::bind("127.0.0.1:0").expect("bind");
let url = format!("http://{}", listener.local_addr().expect("addr"));
std::thread::spawn(move || {
if let Ok((mut socket, _)) = listener.accept() {
let mut buf = [0u8; 4096];
let _ = socket.read(&mut buf);
let response = format!(
"HTTP/1.1 {status_line}\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}",
body.len()
);
let _ = socket.write_all(response.as_bytes());
}
});
url
}
async fn claim_against(url: &str) -> SharedTreeClaim {
claim_shared_tree(
url,
"11111111-1111-1111-1111-111111111111",
Path::new("/repo"),
&serde_json::json!({"tool_use_id": "toolu_X"}),
)
.await
}
#[tokio::test]
async fn claim_shared_tree_is_empty_when_the_daemon_is_unreachable() {
assert!(writers_of(claim_against("http://127.0.0.1:1").await).is_empty());
}
fn unaccepted_listener() -> (std::net::TcpListener, String) {
let listener = std::net::TcpListener::bind("127.0.0.1:0").expect("bind");
let url = format!("http://{}", listener.local_addr().expect("addr"));
(listener, url)
}
fn was_dialled(listener: &std::net::TcpListener) -> bool {
listener.set_nonblocking(true).expect("nonblocking");
listener.accept().is_ok()
}
#[tokio::test]
async fn claim_shared_tree_is_empty_without_a_session_id() {
let (listener, url) = unaccepted_listener();
let claim = claim_shared_tree(&url, "", Path::new("/repo"), &serde_json::json!({})).await;
assert!(!was_dialled(&listener), "no session id must not dial");
assert!(writers_of(claim).is_empty());
}
#[tokio::test]
async fn a_deadline_fires_while_the_client_build_is_still_running() {
use std::sync::atomic::{AtomicBool, Ordering};
let (release, gate) = std::sync::mpsc::channel::<()>();
let built = std::sync::Arc::new(AtomicBool::new(false));
let flag = std::sync::Arc::clone(&built);
let slow_build = move || {
let _ = gate.recv_timeout(std::time::Duration::from_secs(10));
flag.store(true, Ordering::SeqCst);
shared_tree_client()
};
let (_listener, url) = unaccepted_listener();
let payload = serde_json::json!({});
let post = post_shared_tree_with(
slow_build,
&url,
"11111111-1111-1111-1111-111111111111",
Path::new("/repo"),
&payload,
SHARED_TREE_ROUTE,
);
let outcome = tokio::time::timeout(std::time::Duration::from_millis(50), post).await;
let built_before_the_deadline = built.load(Ordering::SeqCst);
let _ = release.send(());
assert!(
!built_before_the_deadline,
"the deadline fired only after the client build returned: the build held the runtime"
);
assert!(outcome.is_err(), "the caller's deadline must decide");
}
#[tokio::test]
async fn an_unbuildable_client_is_unavailable_and_the_owner_query_denies() {
let unbuildable = || {
reqwest::Proxy::all("http://[::1")
.and_then(|proxy| reqwest::Client::builder().proxy(proxy).build())
};
let (listener, url) = unaccepted_listener();
let reply = post_shared_tree_with(
unbuildable,
&url,
"11111111-1111-1111-1111-111111111111",
Path::new("/repo"),
&serde_json::json!({}),
SHARED_TREE_ROUTE,
)
.await;
assert!(!was_dialled(&listener), "an unbuilt client must not dial");
let SharedTreeReply::Unavailable(detail) = &reply else {
panic!("a build error must be Unavailable");
};
assert!(detail.contains("could not be built"), "{detail}");
let denied = owners_or_deny(reply).expect_err("the owner query must deny");
assert!(denied.contains("could not be built"), "{denied}");
}
#[tokio::test]
async fn the_sandbox_refusal_url_is_unavailable_not_unanswered() {
let reply = post_shared_tree_with(
|| reqwest::Client::builder().build(),
trusty_mpm::core::ISOLATED_NO_DAEMON_URL,
"11111111-1111-1111-1111-111111111111",
Path::new("/repo"),
&serde_json::json!({}),
SHARED_TREE_ROUTE,
)
.await;
let SharedTreeReply::Unavailable(detail) = &reply else {
panic!("the sandbox refusal must be Unavailable");
};
assert!(
detail.contains("no daemon reachable for this sandbox"),
"{detail}"
);
}
#[tokio::test]
#[should_panic(expected = "client build panicked")]
async fn a_panicking_client_build_panics_the_caller() {
let panicking = || -> reqwest::Result<reqwest::Client> { panic!("build blew up") };
let (_listener, url) = unaccepted_listener();
let _ = post_shared_tree_with(
panicking,
&url,
"11111111-1111-1111-1111-111111111111",
Path::new("/repo"),
&serde_json::json!({}),
SHARED_TREE_ROUTE,
)
.await;
}
#[tokio::test]
async fn claim_is_empty_when_the_daemon_has_no_such_route() {
let url = spawn_mock_answering("404 Not Found", r#"{"error":"no such route"}"#);
assert!(writers_of(claim_against(&url).await).is_empty());
}
#[tokio::test]
async fn claim_is_unknown_when_the_daemon_times_out() {
let url = spawn_silent_mock();
let started = std::time::Instant::now();
let claim = claim_against(&url).await;
assert!(
matches!(claim, SharedTreeClaim::Unknown(_)),
"a daemon that never answered must not read as an empty tree"
);
assert!(
started.elapsed() < std::time::Duration::from_secs(5),
"the claim must stay inside its own bounded budget"
);
}
#[tokio::test]
async fn claim_is_unknown_when_the_daemon_answers_500() {
let url = spawn_mock_answering("500 Internal Server Error", r#"{"error":"boom"}"#);
assert!(matches!(
claim_against(&url).await,
SharedTreeClaim::Unknown(_)
));
}
#[tokio::test]
async fn claim_is_unknown_when_the_body_does_not_parse() {
let url = spawn_mock_answering("200 OK", "not json at all");
assert!(matches!(
claim_against(&url).await,
SharedTreeClaim::Unknown(_)
));
}
#[tokio::test]
async fn claim_is_answered_when_the_body_parses_with_an_unexpected_shape() {
let url = spawn_mock_answering("200 OK", r#"{"unexpected":"shape"}"#);
assert!(writers_of(claim_against(&url).await).is_empty());
}
#[tokio::test]
async fn evaluate_denies_when_a_running_daemon_does_not_answer() {
let url = spawn_silent_mock();
let reason = evaluate_with_cwd(
r(),
&url,
Some(PathBuf::from("/repo")),
&serde_json::json!({"tool_use_id": "toolu_X"}),
"Agent",
Some(&input("rust-engineer", None)),
"11111111-1111-1111-1111-111111111111",
)
.await
.expect("an unanswered claim must deny");
assert!(reason.contains("#5923"), "{reason}");
assert!(reason.contains("isolation"), "{reason}");
}
#[tokio::test]
async fn evaluate_allows_when_no_daemon_is_listening() {
let verdict = evaluate_with_cwd(
r(),
"http://127.0.0.1:1",
Some(PathBuf::from("/repo")),
&serde_json::json!({"tool_use_id": "toolu_X"}),
"Agent",
Some(&input("rust-engineer", None)),
"11111111-1111-1111-1111-111111111111",
)
.await;
assert_eq!(verdict, None);
}
async fn granted_against(url: &str) -> Option<String> {
evaluate_granted_worktree(
url,
"11111111-1111-1111-1111-111111111111",
Path::new("/repo"),
&serde_json::json!({"tool_use_id": "toolu_X"}),
Some(&input("rust-engineer", None)),
&input("rust-engineer", Some("worktree")),
)
.await
}
#[tokio::test]
async fn granted_worktree_denies_when_a_running_daemon_does_not_answer() {
let url = spawn_silent_mock();
let started = std::time::Instant::now();
let reason = granted_against(&url)
.await
.expect("an unanswered grant must deny");
assert!(reason.contains("#5923"), "{reason}");
assert!(reason.contains("ADR-0048"), "{reason}");
assert!(reason.contains(r#"isolation: "worktree""#), "{reason}");
assert!(
started.elapsed() < std::time::Duration::from_secs(5),
"the grant must stay inside its own bounded budget"
);
}
#[tokio::test]
async fn granted_worktree_grants_when_no_daemon_is_listening() {
assert_eq!(granted_against("http://127.0.0.1:1").await, None);
}
#[tokio::test]
async fn granted_worktree_denies_beside_a_live_writer() {
let url = spawn_denying_mock();
let reason = granted_against(&url)
.await
.expect("a live writer must deny the grant");
assert!(reason.contains("python-engineer"), "{reason}");
assert!(reason.contains("ADR-0048"), "{reason}");
}
#[test]
fn unanswered_grant_deny_reason_names_the_failure_and_the_rewrite() {
let reason = unanswered_grant_deny_reason(
"rust-engineer",
Path::new("/repo"),
"the daemon answered 500 Internal Server Error",
);
assert!(reason.contains("#5923"), "{reason}");
assert!(reason.contains("/repo"), "{reason}");
assert!(reason.contains("500 Internal Server Error"), "{reason}");
assert!(reason.contains("ADR-0048"), "{reason}");
assert!(reason.contains(r#"isolation: "worktree""#), "{reason}");
assert!(reason.contains("serialize"), "{reason}");
}
#[test]
fn unanswered_deny_reason_names_the_failure_and_offers_isolation() {
let reason = unanswered_deny_reason(
"rust-engineer",
Path::new("/repo"),
"the daemon answered 500 Internal Server Error",
);
assert!(reason.contains("#5923"), "{reason}");
assert!(reason.contains("/repo"), "{reason}");
assert!(reason.contains("500 Internal Server Error"), "{reason}");
assert!(reason.contains(r#"isolation: "worktree""#), "{reason}");
assert!(reason.contains("serialize"), "{reason}");
}
#[test]
fn claim_payload_carries_only_the_fields_the_daemon_reads() {
let mut forwarded = serde_json::json!({
"cwd": "/repo",
"tool": "Agent",
"input": {
"subagent_type": "rust-engineer",
"isolation": "worktree",
"description": "short label",
"prompt": "x".repeat(4096),
"extra": {"nested": true},
},
});
project_dispatch_input(&mut forwarded);
let input = forwarded.get("input").expect("input survives");
let keys: Vec<&String> = input
.as_object()
.expect("input stays an object")
.keys()
.collect();
assert_eq!(
keys,
vec!["description", "isolation", "subagent_type"],
"only the three fields the route and the tracker read may be sent"
);
assert_eq!(input["subagent_type"], "rust-engineer");
assert_eq!(input["isolation"], "worktree");
assert_eq!(input["description"], "short label");
assert_eq!(forwarded["tool"], "Agent");
assert_eq!(forwarded["cwd"], "/repo");
}
#[test]
fn claim_payload_projection_leaves_a_non_object_input_alone() {
let mut absent = serde_json::json!({"cwd": "/repo", "tool": "Agent"});
project_dispatch_input(&mut absent);
assert!(absent.get("input").is_none(), "absent input stays absent");
let mut scalar = serde_json::json!({"cwd": "/repo", "input": "not-an-object"});
project_dispatch_input(&mut scalar);
assert_eq!(scalar["input"], "not-an-object");
}
#[test]
fn eligibility_divergence_is_an_empty_answer_that_claimed_nothing() {
let empty: Vec<String> = Vec::new();
assert!(
eligibility_diverged(&serde_json::json!({"agents": [], "claimed": false}), &empty),
"empty answer that claimed nothing is divergence"
);
assert!(
eligibility_diverged(&serde_json::json!({"agents": []}), &empty),
"a daemon too old to send `claimed` reads as not-claimed"
);
assert!(
!eligibility_diverged(&serde_json::json!({"agents": [], "claimed": true}), &empty),
"the ordinary first dispatch claims, and is silent"
);
assert!(
!eligibility_diverged(
&serde_json::json!({"claimed": false}),
&["rust-engineer".to_string()]
),
"a non-empty answer is a deny, where claiming nothing is correct"
);
}
#[tokio::test]
async fn evaluate_never_calls_the_daemon_for_a_non_dispatch_tool() {
let (listener, url) = unaccepted_listener();
let payload = serde_json::json!({"cwd": "/repo"});
let verdict = evaluate(
r(),
&url,
&payload,
"Read",
Some(&input("rust-engineer", None)),
"11111111-1111-1111-1111-111111111111",
)
.await;
assert!(
!was_dialled(&listener),
"the daemon must not be dialled for a non-dispatch tool"
);
assert_eq!(verdict, None);
}
}