1use std::{
48 path::{Path, PathBuf},
49 sync::{
50 Arc,
51 atomic::{AtomicUsize, Ordering},
52 },
53 time::Duration,
54};
55
56use anyhow::Context as _;
57use rmcp::{
58 ErrorData, ServerHandler, ServiceExt,
59 model::{
60 CallToolRequestParams, CallToolResponse, CallToolResult, ErrorCode, ListToolsResult,
61 PaginatedRequestParams, ServerCapabilities, ServerConfig, Tool,
62 },
63 service::{ClientInitializeError, RequestContext, RoleServer, RunningService, ServiceError},
64 transport::{
65 StreamableHttpClientTransport,
66 streamable_http_client::{StreamableHttpClientTransportConfig, StreamableHttpError},
67 },
68};
69use schemars::JsonSchema;
70use serde::{Deserialize, Serialize};
71use serde_json::{Value, json};
72use tokio::sync::{Mutex, RwLock};
73use tokio_util::sync::CancellationToken;
74
75use crate::context::{self, Inputs, Resolved};
76
77pub const SESSION_PREFIX: &str = "s-";
80const SESSION_HEX: usize = 32;
85
86const PRESENCE_TTL_SECS: i64 = 900;
88const KEEPALIVE_EVERY: Duration = Duration::from_secs(300);
89const SESSION_TTL_ENV: &str = "BUS_SESSION_TTL_SECS";
93const RENEW_LEAD_ENV: &str = "BUS_SESSION_RENEW_LEAD_SECS";
97const DRAIN_TIMEOUT: Duration = Duration::from_secs(5);
100const EXIT_TIMEOUT: Duration = Duration::from_secs(3);
102const ADOPT_ATTEMPTS: u32 = 3;
106const ROOTS_WAIT: Duration = Duration::from_secs(5);
109
110pub const CONFIGURE_TOOL: &str = "configure_session";
111pub const STATUS_TOOL: &str = "session_status";
112const SERVER_WAIT_LIMIT_SECS: i64 = 55;
114const SERVER_WAIT_SLICE_SECS: i64 = 50;
116const PROXY_MAX_WAIT_SECS: i64 = 1800;
119
120type Remote = RunningService<rmcp::RoleClient, rmcp::model::ClientConfig>;
121
122pub const ANTIGRAVITY_META_KEY: &str = "antigravity.google/conversation_id";
125
126#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, JsonSchema)]
128#[serde(rename_all = "kebab-case")]
129pub enum Binding {
130 Explicit,
132 ClaudeCode,
135 Antigravity,
140 RequestMeta,
142 Instance,
144}
145
146#[derive(Clone, Debug, Default)]
148pub struct ProxyOptions {
149 pub inputs: Inputs,
150 pub project: Option<String>,
151 pub role: Option<String>,
152 pub channel: Option<String>,
153 pub host_session: Option<String>,
155 pub state_dir: PathBuf,
157}
158
159pub use crate::context::session_for_host as session_for;
163
164fn check_label(field: &str, raw: &str) -> Result<Option<String>, ErrorData> {
168 crate::store::presence::normalize_label(field, raw)
169 .map(|v| (!v.is_empty()).then_some(v))
170 .map_err(|e| ErrorData::invalid_params(e.to_string(), None))
171}
172
173fn env_id(name: &str) -> Option<String> {
175 std::env::var(name)
176 .ok()
177 .map(|s| s.trim().to_owned())
178 .filter(|s| !s.is_empty())
179}
180
181fn path_of_file_uri(uri: &str) -> Option<PathBuf> {
184 let rest = uri.strip_prefix("file://")?;
185 let rest = rest.strip_prefix("localhost").unwrap_or(rest);
186 if !rest.starts_with('/') {
187 return None;
188 }
189 let bytes = rest.as_bytes();
190 let mut out = Vec::with_capacity(bytes.len());
191 let mut i = 0;
192 while i < bytes.len() {
193 if bytes[i] == b'%'
194 && i + 2 < bytes.len()
195 && let Ok(b) = u8::from_str_radix(rest.get(i + 1..i + 3)?, 16)
196 {
197 out.push(b);
198 i += 3;
199 } else {
200 out.push(bytes[i]);
201 i += 1;
202 }
203 }
204 let path = PathBuf::from(String::from_utf8(out).ok()?);
205 path.is_dir().then_some(path)
206}
207
208fn is_claude_code(name: &str) -> bool {
210 name.trim().eq_ignore_ascii_case("claude-code")
211}
212
213fn random_session() -> String {
214 let raw = crate::auth::generate_token();
215 format!(
216 "{SESSION_PREFIX}{}",
217 &raw[crate::auth::TOKEN_PREFIX.len()..crate::auth::TOKEN_PREFIX.len() + SESSION_HEX]
218 )
219}
220
221fn env_secs(name: &str) -> Option<i64> {
222 std::env::var(name)
223 .ok()
224 .and_then(|v| v.trim().parse::<i64>().ok())
225 .filter(|v| *v > 0)
226}
227
228fn requested_session_ttl() -> Option<i64> {
233 env_secs(SESSION_TTL_ENV).map(|v| v.clamp(60, crate::auth::MAX_SESSION_TTL_SECS))
234}
235
236fn renewal_lead_secs(lifetime: i64, override_secs: Option<i64>) -> i64 {
241 let lifetime = lifetime.max(2);
242 override_secs.unwrap_or(lifetime / 2).clamp(1, lifetime - 1)
243}
244
245#[derive(Clone, Debug)]
247pub struct SessionProof {
248 pub token: String,
251 pub session_id: String,
252 pub epoch: i64,
253 pub expires_at: String,
254}
255
256enum Renewal {
258 Renewed,
259 Refused,
261 Retry,
263 Nothing,
265}
266
267struct Connected {
269 resolved: Resolved,
270 agent: String,
271 team: String,
272 remote: Arc<Remote>,
273 tools: Vec<Tool>,
274 remote_instructions: Option<String>,
275 proof: Option<SessionProof>,
276 ct: CancellationToken,
278}
279
280struct State {
281 connected: Option<Connected>,
284 disconnected_reason: Option<String>,
286 session: String,
287 binding: Binding,
288 host_id: Option<String>,
289 bound_by_request: bool,
293 project: Option<String>,
294 role: Option<String>,
295 channel: Option<String>,
296 generation: u64,
298 stranded: Option<Identity>,
304 roots_dir: Option<PathBuf>,
309}
310
311#[derive(Clone)]
314struct Identity {
315 profile: Option<String>,
316 agent: String,
317 team: String,
318 mcp_url: String,
319 proof: Option<SessionProof>,
320}
321
322impl State {
323 fn identity(&self) -> Option<Identity> {
329 match (&self.connected, &self.stranded) {
330 (Some(c), _) => Some(Identity {
331 profile: c.resolved.profile.clone(),
332 agent: c.agent.clone(),
333 team: c.team.clone(),
334 mcp_url: c.resolved.mcp_url.clone(),
335 proof: c.proof.clone(),
336 }),
337 (None, Some(s)) => Some(s.clone()),
338 (None, None) => None,
339 }
340 }
341}
342
343struct InFlight(Arc<AtomicUsize>);
346
347impl InFlight {
348 fn enter(counter: &Arc<AtomicUsize>) -> Self {
349 counter.fetch_add(1, Ordering::SeqCst);
350 Self(counter.clone())
351 }
352}
353
354impl Drop for InFlight {
355 fn drop(&mut self) {
356 self.0.fetch_sub(1, Ordering::SeqCst);
357 }
358}
359
360const WAITING_TOOLS: &[&str] = &["wait_for_updates", "ask_agent"];
363const ATTENTION_REPORT_EVERY: Duration = Duration::from_secs(15);
368const FLUSH_RETRY: Duration = Duration::from_secs(2);
370
371#[derive(Default)]
377struct Attention {
378 calls: usize,
380 waits: usize,
382 unreported_call: Option<std::time::Instant>,
384 reported: Option<(&'static str, std::time::Instant)>,
386}
387
388#[derive(Clone, Copy, Debug, PartialEq, Eq)]
391struct AttentionReport {
392 state: &'static str,
393 call: Option<std::time::Instant>,
395}
396
397impl AttentionReport {
398 fn tool_activity(&self) -> bool {
399 self.call.is_some()
400 }
401
402 fn fields(&self) -> Value {
406 let mut v = json!({
407 "refresh_only": true,
408 "attention": self.state,
409 "tool_activity": self.tool_activity(),
410 });
411 if self.state == "attending"
412 && let Some(at) = self.call
413 {
414 v["attention_observed_ms_ago"] = json!(at.elapsed().as_millis() as i64);
415 }
416 v
417 }
418}
419
420impl Attention {
421 fn due(&mut self, refresh: bool) -> Option<AttentionReport> {
427 let now = std::time::Instant::now();
428 let recent = |at: std::time::Instant| now.duration_since(at) < ATTENTION_REPORT_EVERY * 2;
429 if self.unreported_call.is_some_and(|at| !recent(at)) && self.calls == 0 {
430 self.unreported_call = None;
431 }
432 let reported_age = self.reported.map(|(_, at)| now.duration_since(at));
433 let (state, call) = if self.waits > 0 {
434 let changed = self.reported.is_none_or(|(s, _)| s != "waiting");
435 if !(changed || refresh) {
436 return None;
437 }
438 ("waiting", self.unreported_call)
439 } else if let Some(call) = self.unreported_call {
440 let changed = self.reported.is_none_or(|(s, _)| s != "attending");
441 let old = reported_age.is_none_or(|age| age >= ATTENTION_REPORT_EVERY);
442 if !(changed || old || (refresh && self.calls > 0)) {
443 return None;
444 }
445 ("attending", Some(call))
446 } else if refresh && self.calls > 0 {
447 ("attending", None)
449 } else {
450 return None;
451 };
452 Some(AttentionReport { state, call })
453 }
454
455 fn flush_at(&self) -> Option<std::time::Instant> {
459 if self.waits > 0 {
462 return None;
463 }
464 self.unreported_call?;
465 self.reported.map(|(_, at)| at + ATTENTION_REPORT_EVERY)
466 }
467
468 fn sent(&mut self, report: AttentionReport) {
470 self.reported = Some((report.state, std::time::Instant::now()));
471 if self.unreported_call.is_some() && self.unreported_call == report.call {
473 self.unreported_call = None;
474 }
475 }
476}
477
478struct ToolTurn {
480 attention: Arc<std::sync::Mutex<Attention>>,
481 changed: Arc<tokio::sync::Notify>,
482 waits: bool,
483}
484
485impl ToolTurn {
486 fn enter(proxy: &Proxy, tool: &str) -> Self {
487 let waits = WAITING_TOOLS.contains(&tool);
488 if let Ok(mut a) = proxy.attention.lock() {
489 a.calls += 1;
490 a.waits += usize::from(waits);
491 a.unreported_call = Some(std::time::Instant::now());
492 }
493 proxy.attention_changed.notify_one();
494 Self {
495 attention: proxy.attention.clone(),
496 changed: proxy.attention_changed.clone(),
497 waits,
498 }
499 }
500}
501
502impl Drop for ToolTurn {
503 fn drop(&mut self) {
504 if let Ok(mut a) = self.attention.lock() {
505 a.calls = a.calls.saturating_sub(1);
506 if self.waits {
507 a.waits = a.waits.saturating_sub(1);
508 a.unreported_call = Some(std::time::Instant::now());
510 }
511 }
512 self.changed.notify_one();
515 }
516}
517
518#[derive(Clone)]
519pub struct Proxy {
520 state: Arc<RwLock<State>>,
521 in_flight: Arc<AtomicUsize>,
522 attention: Arc<std::sync::Mutex<Attention>>,
525 attention_changed: Arc<tokio::sync::Notify>,
526 switch: Arc<Mutex<()>>,
528 presence: Arc<Mutex<()>>,
533 wake: Arc<tokio::sync::Notify>,
538 opts: Arc<ProxyOptions>,
539 project_dir: PathBuf,
540 pending_claude: Arc<Mutex<Option<String>>>,
547 roots_gate: Arc<Mutex<()>>,
551 roots_hold: Arc<std::sync::Mutex<Option<tokio::sync::OwnedMutexGuard<()>>>>,
557}
558
559#[derive(Debug, Default, Deserialize, JsonSchema)]
562pub struct ConfigureArgs {
563 #[serde(default)]
567 pub role: Option<String>,
568 #[serde(default)]
571 pub project: Option<String>,
572 #[serde(default)]
574 pub channel: Option<String>,
575 #[serde(default)]
578 pub profile: Option<String>,
579}
580
581#[derive(Debug, Serialize, JsonSchema)]
582pub struct Status {
583 pub connected: bool,
585 pub error: Option<String>,
587 pub agent: Option<String>,
589 pub team: Option<String>,
590 pub session: String,
592 pub address: Option<String>,
594 pub project: Option<String>,
595 pub role: Option<String>,
596 pub channel: Option<String>,
597 pub profile: Option<String>,
598 pub credential_from: Option<String>,
602 pub binding: Binding,
603 pub project_root: Option<String>,
604 pub project_root_from: Option<String>,
609 pub bus: Option<String>,
610 pub attention: Option<String>,
616 pub attention_reported_seconds_ago: Option<u64>,
618}
619
620#[derive(Debug, Serialize, JsonSchema)]
621pub struct ConfigureResult {
622 pub status: Status,
623 pub previous: Option<PreviousIdentity>,
627}
628
629#[derive(Debug, Serialize, JsonSchema)]
630pub struct PreviousIdentity {
631 pub agent: String,
632 pub team: String,
633 pub session: String,
634 pub open_claims: Vec<String>,
635 pub held_locks: Vec<String>,
636}
637
638fn schema_of<T: JsonSchema>() -> Arc<rmcp::model::JsonObject> {
639 let schema = schemars::schema_for!(T);
640 match serde_json::to_value(schema) {
641 Ok(Value::Object(map)) => Arc::new(map),
642 _ => Arc::new(rmcp::model::JsonObject::new()),
643 }
644}
645
646fn local_tools() -> Vec<Tool> {
647 vec![
648 Tool::new(
649 CONFIGURE_TOOL,
650 "Set how THIS window presents itself on the bus: role (implementation, design, \
651 review, …), project and default channel. Metadata only — it never changes who \
652 you are or your session id, so cursors, claims and locks stay yours. `profile` \
653 switches to another locally approved credential of the same team after \
654 verifying it; a different team needs a new conversation. Affects this window \
655 only.",
656 schema_of::<ConfigureArgs>(),
657 )
658 .with_title("Configure this session")
659 .with_output_schema::<ConfigureResult>(),
660 Tool::new(
661 STATUS_TOOL,
662 "Who this window is on the bus (verified agent and team), its session id and \
663 address (`agent/session`, what teammates use to reach exactly this window), \
664 project, role, default channel, and where its credential came from \
665 (`credential_from`). Never returns credentials.",
666 schema_of::<EmptyArgs>(),
667 )
668 .with_title("Session status")
669 .with_output_schema::<Status>(),
670 ]
671}
672
673#[derive(Debug, Default, Deserialize, JsonSchema)]
674pub struct EmptyArgs {}
675
676async fn connect_remote(
679 url: &str,
680 credential: &str,
681 session: &str,
682 epoch: Option<i64>,
683) -> anyhow::Result<Remote> {
684 let mut config = StreamableHttpClientTransportConfig::with_uri(url.to_owned());
685 config.auth_header = Some(credential.to_owned());
686 config.allow_stateless = true;
687 config.custom_headers.insert(
688 crate::auth::SESSION_HEADER.parse()?,
689 session
690 .parse()
691 .context("session label is not a valid header value")?,
692 );
693 if let Some(epoch) = epoch {
697 config.custom_headers.insert(
698 crate::auth::EPOCH_HEADER.parse()?,
699 epoch
700 .to_string()
701 .parse()
702 .context("epoch is not a valid header value")?,
703 );
704 }
705 let transport = StreamableHttpClientTransport::from_config(config);
706 let remote = rmcp::model::ClientConfig::default()
707 .serve(transport)
708 .await
709 .map_err(|e| {
710 tracing::warn!(error = %e, url, "could not open a connection to the bus");
715 let lost = || "the connection failed before an answer came back".to_owned();
716 let (why, rejected) = match &e {
717 ClientInitializeError::JsonRpcError(data) => (data.message.to_string(), false),
718 ClientInitializeError::TransportError { error, .. } => {
719 let rejected = matches!(
720 http_error_in(&*error.error),
721 Some(StreamableHttpError::AuthRequired(_))
722 );
723 let why = match refusal_in(&*error.error) {
724 Some(r) => r.text(),
725 None if rejected => "the bus rejected the credential".to_owned(),
726 None => lost(),
727 };
728 (why, rejected)
729 }
730 _ => (lost(), false),
731 };
732 let text = format!("could not connect to the bus: {why}");
733 if rejected {
734 anyhow::Error::new(Verdict::Unauthorized).context(text)
735 } else {
736 anyhow::anyhow!(text)
737 }
738 })?;
739 let peer_info = remote.peer_info();
744 if let Some(si) = peer_info.as_ref().and_then(|i| i.server_info.as_ref()) {
745 let ours = env!("CARGO_PKG_VERSION");
746 if si.name == "ai-crew-sync" && si.version != ours {
747 tracing::warn!(
748 binary = ours,
749 bus = %si.version,
750 "this binary and the bus run different ai-crew-sync versions; \
751 if tools fail to load or calls are refused, align the two \
752 before debugging anything else"
753 );
754 } else if si.name != "ai-crew-sync" {
755 tracing::debug!(
756 server = %si.name,
757 version = %si.version,
758 "the bus did not identify an ai-crew-sync version (0.7.0 or older)"
759 );
760 }
761 }
762 Ok(remote)
763}
764
765fn unauthorized(e: &ServiceError) -> bool {
771 let ServiceError::TransportSend(sent) = e else {
772 return false;
773 };
774 match http_error_in(&*sent.error) {
775 Some(http) => matches!(http, StreamableHttpError::AuthRequired(_)),
776 None => sent.error.to_string().contains("Auth required"),
779 }
780}
781
782fn http_error_in<'a>(
786 root: &'a (dyn std::error::Error + 'static),
787) -> Option<&'a StreamableHttpError<reqwest::Error>> {
788 let mut cause = Some(root);
789 while let Some(err) = cause {
790 if let Some(http) = err.downcast_ref::<StreamableHttpError<reqwest::Error>>() {
791 return Some(http);
792 }
793 cause = err.source();
794 }
795 None
796}
797
798fn no_such_tool(e: &ServiceError) -> bool {
806 match e {
807 ServiceError::McpError(err) => {
808 err.code == ErrorCode::METHOD_NOT_FOUND
809 || (err.code == ErrorCode::INVALID_PARAMS && err.message.trim() == "tool not found")
810 }
811 _ => false,
812 }
813}
814
815#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
819enum Verdict {
820 #[error("the bus rejected the credential")]
821 Unauthorized,
822 #[error("the bus has no such tool")]
823 NoSuchTool,
824}
825
826fn verdict(e: &ServiceError) -> Option<Verdict> {
827 if unauthorized(e) {
828 Some(Verdict::Unauthorized)
829 } else if no_such_tool(e) {
830 Some(Verdict::NoSuchTool)
831 } else {
832 None
833 }
834}
835
836fn credential_expired(e: &anyhow::Error) -> bool {
841 format!("{e:#}").contains("session credential has expired")
842}
843
844fn verdict_of(e: &anyhow::Error) -> Option<Verdict> {
845 e.chain().find_map(|c| c.downcast_ref::<Verdict>().copied())
846}
847
848fn settled_ids(reply: Option<&Value>, sent: &[String]) -> std::collections::HashSet<String> {
853 let mut settled = std::collections::HashSet::new();
854 let Some(reply) = reply else {
855 return settled;
856 };
857 let listed = reply.get("confirmed_ids").is_some() || reply.get("already_confirmed").is_some();
858 if listed {
859 for key in ["confirmed_ids", "already_confirmed"] {
860 if let Some(ids) = reply.get(key).and_then(|v| v.as_array()) {
861 settled.extend(ids.iter().filter_map(|v| v.as_str()).map(str::to_owned));
862 }
863 }
864 } else if reply.get("confirmed").and_then(|v| v.as_i64()) == Some(sent.len() as i64) {
865 settled.extend(sent.iter().cloned());
866 }
867 settled
868}
869
870#[derive(Debug, PartialEq, Eq)]
879struct Refusal {
880 status: u16,
881 said: Option<String>,
883}
884
885impl Refusal {
886 fn text(&self) -> String {
887 match &self.said {
888 Some(said) => format!("the bus refused it before running it: {said}"),
889 None => format!(
890 "the bus refused it with HTTP {} before running it",
891 self.status
892 ),
893 }
894 }
895}
896
897fn refusal_in(root: &(dyn std::error::Error + 'static)) -> Option<Refusal> {
902 let StreamableHttpError::UnexpectedServerResponse(msg) = http_error_in(root)? else {
903 return None;
904 };
905 let rest = msg.strip_prefix("HTTP ")?;
906 let (head, body) = rest.split_once(": ").unwrap_or((rest, ""));
907 let status: u16 = head.split_whitespace().next()?.parse().ok()?;
908 let said = serde_json::from_str::<Value>(body)
909 .ok()
910 .and_then(|v| v.get("error")?.as_str().map(str::to_owned));
911 (said.is_some() || (400..500).contains(&status)).then_some(Refusal { status, said })
912}
913
914fn refusal(e: &ServiceError) -> Option<Refusal> {
915 match e {
916 ServiceError::TransportSend(sent) => refusal_in(&*sent.error),
917 _ => None,
918 }
919}
920
921fn remote_error_text(e: &ServiceError) -> String {
930 if let ServiceError::McpError(data) = e {
931 return data.message.to_string();
932 }
933 tracing::warn!(error = %e, "the call to the bus failed in transport");
934 match refusal(e) {
935 Some(r) => r.text(),
936 None => "the connection to the bus failed before an answer came back".to_owned(),
937 }
938}
939
940async fn call_remote(remote: &Remote, name: &str, args: Value) -> anyhow::Result<Value> {
941 let arguments: rmcp::model::JsonObject =
942 serde_json::from_value(args).context("arguments must be an object")?;
943 let result = remote
944 .call_tool(CallToolRequestParams::new(name.to_owned()).with_arguments(arguments))
945 .await
946 .map_err(|e| {
947 let text = format!("{name}: {}", remote_error_text(&e));
950 match verdict(&e) {
951 Some(v) => anyhow::Error::new(v).context(text),
952 None => anyhow::anyhow!(text),
953 }
954 })?;
955 if result.is_error == Some(true) {
956 let said: Vec<String> = result
958 .content
959 .iter()
960 .filter_map(|c| c.as_text().map(|t| t.text.clone()))
961 .collect();
962 anyhow::bail!("{name}: {}", said.join(" "));
963 }
964 Ok(result.structured_content.unwrap_or(Value::Null))
965}
966
967async fn register_new(remote: &Remote, session: &str) -> anyhow::Result<Option<SessionProof>> {
973 let mut args = json!({ "session": session });
974 if let Some(ttl) = requested_session_ttl() {
975 args["ttl_seconds"] = json!(ttl);
976 }
977 match call_remote(remote, "register_session", args).await {
978 Ok(v) => match v["session_token"].as_str() {
979 Some(token) => Ok(Some(SessionProof {
980 token: token.to_owned(),
981 session_id: v["session_id"].as_str().unwrap_or_default().to_owned(),
982 epoch: v["epoch"].as_i64().unwrap_or(1),
983 expires_at: v["expires_at"].as_str().unwrap_or_default().to_owned(),
984 })),
985 None => anyhow::bail!(
986 "the bus accepted register_session but returned no credential; refusing to \
987 continue with an asserted label while reporting a proven identity"
988 ),
989 },
990 Err(e) => {
991 let text = e.to_string();
992 if verdict_of(&e) == Some(Verdict::NoSuchTool) {
996 tracing::warn!(
997 "this bus does not issue session credentials; continuing with the label only"
998 );
999 Ok(None)
1000 } else {
1001 Err(anyhow::anyhow!(
1002 "could not register this window's session: {text}"
1003 ))
1004 }
1005 }
1006 }
1007}
1008
1009async fn resume_with(
1014 url: &str,
1015 prior: &SessionProof,
1016 session: &str,
1017) -> anyhow::Result<Option<SessionProof>> {
1018 let remote = connect_remote(url, &prior.token, session, Some(prior.epoch))
1019 .await
1020 .context("the stored session credential could not open a connection")?;
1021 let mut args = json!({});
1022 if let Some(ttl) = requested_session_ttl() {
1023 args["ttl_seconds"] = json!(ttl);
1024 }
1025 let outcome = call_remote(&remote, "resume_session", args).await;
1026 let _ = remote.cancel().await;
1027 let v =
1028 outcome.context("this window's session could not be resumed; it may have been revoked")?;
1029 let token = v["session_token"]
1030 .as_str()
1031 .context("resume_session returned no credential")?;
1032 Ok(Some(SessionProof {
1033 token: token.to_owned(),
1034 session_id: v["session_id"].as_str().unwrap_or_default().to_owned(),
1035 epoch: v["epoch"].as_i64().unwrap_or(1),
1036 expires_at: v["expires_at"].as_str().unwrap_or_default().to_owned(),
1037 }))
1038}
1039
1040async fn rotate(url: &str, prior: &SessionProof, session: &str) -> Result<SessionProof, String> {
1045 const KEPT: &str = "nothing was resumed and this window keeps the credential it holds";
1046 let rejected = || {
1047 "resume_session: the bus rejected this window's credential (revoked or rotated), so \
1048 nothing was resumed; restart this MCP server to reconnect the window"
1049 .to_owned()
1050 };
1051 let remote = connect_remote(url, &prior.token, session, Some(prior.epoch))
1052 .await
1053 .map_err(|e| {
1054 tracing::warn!(error = format!("{e:#}"), "resume_session could not connect");
1055 if verdict_of(&e) == Some(Verdict::Unauthorized) {
1056 rejected()
1057 } else {
1058 format!("resume_session: {e}; {KEPT}. Try again once the bus answers")
1060 }
1061 })?;
1062 let mut args = rmcp::model::JsonObject::new();
1063 if let Some(ttl) = requested_session_ttl() {
1064 args.insert("ttl_seconds".into(), json!(ttl));
1065 }
1066 let outcome = remote
1067 .call_tool(CallToolRequestParams::new("resume_session").with_arguments(args))
1068 .await;
1069 let _ = remote.cancel().await;
1070 let lost = |what: &str| {
1071 format!(
1072 "resume_session: {what}, so the resume may or may not have happened. If this \
1073 window's next call is refused, it did and the new credential is lost: close the \
1074 window with revoke_session (its label, from another window of this agent or \
1075 `ai-crew-sync client call revoke_session`) and restart this MCP server"
1076 )
1077 };
1078 let result = outcome.map_err(|e| {
1079 tracing::warn!(error = %e, "resume_session through the proxy failed");
1080 if unauthorized(&e) {
1081 rejected()
1082 } else if let ServiceError::McpError(data) = &e {
1083 format!("resume_session: {KEPT}: {}", data.message)
1084 } else if let Some(r) = refusal(&e) {
1085 format!("resume_session: {}; {KEPT}", r.text())
1086 } else {
1087 lost("the connection to the bus failed before an answer came back")
1088 }
1089 })?;
1090 if result.is_error == Some(true) {
1091 let said: Vec<String> = result
1092 .content
1093 .iter()
1094 .filter_map(|c| c.as_text().map(|t| t.text.clone()))
1095 .collect();
1096 return Err(format!("resume_session: {KEPT}: {}", said.join(" ")));
1097 }
1098 let v = result.structured_content.unwrap_or(Value::Null);
1099 let Some(token) = v["session_token"].as_str() else {
1100 tracing::warn!("resume_session answered without a credential");
1101 return Err(lost("the bus answered without a credential"));
1102 };
1103 Ok(SessionProof {
1104 token: token.to_owned(),
1105 session_id: v["session_id"].as_str().unwrap_or_default().to_owned(),
1106 epoch: v["epoch"].as_i64().unwrap_or(1),
1107 expires_at: v["expires_at"].as_str().unwrap_or_default().to_owned(),
1108 })
1109}
1110
1111async fn open_with(
1114 url: &str,
1115 proof: &SessionProof,
1116 session: &str,
1117) -> Result<(Remote, Vec<Tool>), String> {
1118 let remote = connect_remote(url, &proof.token, session, Some(proof.epoch))
1119 .await
1120 .map_err(|e| e.to_string())?;
1123 match remote.list_all_tools().await {
1124 Ok(tools) => Ok((remote, tools)),
1125 Err(e) => {
1126 let why = format!("could not list the bus's tools: {}", remote_error_text(&e));
1127 let _ = remote.cancel().await;
1128 Err(why)
1129 }
1130 }
1131}
1132
1133async fn establish(
1134 inputs: &Inputs,
1135 session: &str,
1136 existing_proof: Option<SessionProof>,
1137) -> anyhow::Result<(
1138 Resolved,
1139 String,
1140 String,
1141 Remote,
1142 Vec<Tool>,
1143 Option<String>,
1144 Option<SessionProof>,
1145)> {
1146 let resolved = context::resolve(inputs)?;
1147 for w in &resolved.warnings {
1150 tracing::warn!("{w}");
1151 }
1152 if resolved.source == context::Source::UserDefault {
1155 tracing::warn!(
1156 "no BUS_TOKEN, no BUS_PROFILE and no .acs.toml naming a profile here: this window \
1157 uses the user default profile '{}'. `ai-crew-sync context profile default --clear` removes it; a \
1158 project's .acs.toml selects its own",
1159 resolved.profile.as_deref().unwrap_or("?")
1160 );
1161 }
1162 let rejected = || {
1169 anyhow::anyhow!(
1170 "the bus rejected this window's credential — it has been revoked or \
1171 rotated{}. The credential came from {}. Issue a new token \
1172 (`ai-crew-sync admin token issue --save`) or select another \
1173 approved profile",
1174 resolved
1175 .profile
1176 .as_deref()
1177 .map(|p| format!(" (profile '{p}')"))
1178 .unwrap_or_default(),
1179 resolved.credential_provenance()
1180 )
1181 };
1182 let remote = match connect_remote(&resolved.mcp_url, &resolved.token, session, None).await {
1183 Ok(remote) => remote,
1184 Err(e) if verdict_of(&e) == Some(Verdict::Unauthorized) => return Err(rejected()),
1185 Err(e) => return Err(e),
1186 };
1187 let me = match call_remote(&remote, "whoami", json!({})).await {
1188 Ok(me) => me,
1189 Err(e) => {
1190 let raw = e.to_string();
1191 let _ = remote.cancel().await;
1192 if verdict_of(&e) == Some(Verdict::Unauthorized) {
1193 return Err(rejected());
1194 }
1195 anyhow::bail!("the bus did not accept the credential: {raw}");
1196 }
1197 };
1198 let agent = me["agent"].as_str().unwrap_or_default().to_owned();
1199 let team = me["team"].as_str().unwrap_or_default().to_owned();
1200 if let Some((exp_team, exp_agent)) = &resolved.expected
1201 && (&agent != exp_agent || &team != exp_team)
1202 {
1203 let _ = remote.cancel().await;
1204 anyhow::bail!(
1205 "profile '{}' expects {exp_agent}@{exp_team} but the token authenticates as \
1206 {agent}@{team}; fix the profile or its token entry",
1207 resolved.profile.as_deref().unwrap_or("?")
1208 );
1209 }
1210
1211 let stored = existing_proof;
1217 let proof = match &stored {
1218 Some(prior) => match resume_with(&resolved.mcp_url, prior, session).await {
1219 Ok(proof) => proof,
1220 Err(e) if credential_expired(&e) => {
1225 tracing::warn!(
1226 "this window's stored session credential had expired; registering \
1227 session '{session}' again with the agent token"
1228 );
1229 register_new(&remote, session).await?
1230 }
1231 Err(e) if verdict_of(&e) == Some(Verdict::Unauthorized) => {
1242 tracing::warn!(
1243 "the bus rejected this window's stored session credential (revoked or \
1244 rotated); registering session '{session}' again with the agent token"
1245 );
1246 register_new(&remote, session).await?
1247 }
1248 Err(e) => return Err(e),
1251 },
1252 None => register_new(&remote, session).await?,
1253 };
1254
1255 let (remote, tools, instructions) = match &proof {
1256 Some(proof) => {
1257 let _ = remote.cancel().await;
1258 let remote =
1259 connect_remote(&resolved.mcp_url, &proof.token, session, Some(proof.epoch))
1260 .await
1261 .context("the session credential could not open a connection")?;
1262 let tools = remote.list_all_tools().await.map_err(|e| {
1263 anyhow::anyhow!("could not list the bus's tools: {}", remote_error_text(&e))
1264 })?;
1265 let instructions = remote.peer_info().and_then(|i| i.instructions.clone());
1266 (remote, tools, instructions)
1267 }
1268 None => {
1269 let tools = remote.list_all_tools().await.map_err(|e| {
1270 anyhow::anyhow!("could not list the bus's tools: {}", remote_error_text(&e))
1271 })?;
1272 let instructions = remote.peer_info().and_then(|i| i.instructions.clone());
1273 (remote, tools, instructions)
1274 }
1275 };
1276 Ok((resolved, agent, team, remote, tools, instructions, proof))
1277}
1278
1279fn git_place(dir: &Path) -> (Option<String>, Option<String>) {
1282 let run = |args: &[&str]| {
1283 std::process::Command::new("git")
1284 .args(args)
1285 .current_dir(dir)
1286 .output()
1287 .ok()
1288 .filter(|o| o.status.success())
1289 .map(|o| String::from_utf8_lossy(&o.stdout).trim().to_owned())
1290 .filter(|s| !s.is_empty())
1291 };
1292 let repo = run(&["config", "--get", "remote.origin.url"]).map(|url| {
1293 let trimmed = url.trim_end_matches(".git");
1294 let tail: Vec<&str> = trimmed.rsplit(['/', ':']).take(2).collect();
1295 if tail.len() == 2 {
1296 format!("{}/{}", tail[1], tail[0])
1297 } else {
1298 trimmed.to_owned()
1299 }
1300 });
1301 let branch = run(&["branch", "--show-current"]);
1302 (repo, branch)
1303}
1304
1305impl Proxy {
1306 pub async fn start(opts: ProxyOptions) -> Self {
1309 let project_dir = opts
1310 .inputs
1311 .project_dir
1312 .clone()
1313 .or_else(|| std::env::var_os("CLAUDE_PROJECT_DIR").map(PathBuf::from))
1314 .or_else(|| std::env::current_dir().ok())
1315 .unwrap_or_else(|| PathBuf::from("."));
1316
1317 let explicit = opts
1318 .host_session
1319 .as_deref()
1320 .map(str::trim)
1321 .filter(|s| !s.is_empty())
1322 .map(str::to_owned);
1323 let claude = env_id("CLAUDE_CODE_SESSION_ID");
1324 let (host_id, binding) = match (&explicit, &claude) {
1325 (Some(id), _) => (Some(id.clone()), Binding::Explicit),
1326 (None, Some(_)) => (None, Binding::Instance),
1329 (None, None) => match env_id("ANTIGRAVITY_CONVERSATION_ID") {
1330 Some(id) => (Some(id), Binding::Antigravity),
1331 None => (None, Binding::Instance),
1332 },
1333 };
1334 let pending = explicit.is_none().then_some(claude).flatten();
1335 let session = match &host_id {
1336 Some(id) => session_for(id),
1337 None => random_session(),
1338 };
1339 let deferred = pending.is_some();
1340
1341 let proxy = Self {
1342 state: Arc::new(RwLock::new(State {
1343 connected: None,
1344 disconnected_reason: deferred.then(|| {
1345 "waiting for the MCP client to identify itself before binding the \
1346 conversation"
1347 .to_owned()
1348 }),
1349 session,
1350 binding,
1351 host_id,
1352 bound_by_request: false,
1353 project: opts.project.clone(),
1354 role: opts.role.clone(),
1355 channel: opts.channel.clone(),
1356 generation: 0,
1357 stranded: None,
1358 roots_dir: None,
1359 })),
1360 in_flight: Arc::new(AtomicUsize::new(0)),
1361 attention: Arc::new(std::sync::Mutex::new(Attention::default())),
1362 attention_changed: Arc::new(tokio::sync::Notify::new()),
1363 switch: Arc::new(Mutex::new(())),
1364 presence: Arc::new(Mutex::new(())),
1365 wake: Arc::new(tokio::sync::Notify::new()),
1366 opts: Arc::new(opts),
1367 project_dir,
1368 pending_claude: Arc::new(Mutex::new(pending)),
1369 roots_gate: Arc::new(Mutex::new(())),
1370 roots_hold: Arc::new(std::sync::Mutex::new(None)),
1371 };
1372 if !deferred {
1373 proxy.connect_initial().await;
1374 }
1375 proxy
1376 }
1377
1378 async fn connect_initial(&self) {
1381 let inputs = self.inputs(&*self.state.read().await);
1382 if let Err(e) = self.connect_with(&inputs, true).await {
1383 tracing::warn!(error = %e, "proxy started without a bus connection");
1384 self.state.write().await.disconnected_reason = Some(format!("{e:#}"));
1385 }
1386 }
1387
1388 async fn admit(&self, client: Option<&rmcp::model::Implementation>) {
1394 let mut pending = self.pending_claude.lock().await;
1395 let Some(id) = pending.take() else { return };
1396 let name = client.map(|c| c.name.as_str()).unwrap_or_default();
1397 {
1398 let mut st = self.state.write().await;
1399 if is_claude_code(name) {
1400 st.session = session_for(&id);
1401 st.host_id = Some(id);
1402 st.binding = Binding::ClaudeCode;
1403 } else {
1404 tracing::info!(
1405 client = name,
1406 "CLAUDE_CODE_SESSION_ID is set but the client is not Claude Code; it was \
1407 inherited from a parent window and is ignored"
1408 );
1409 if let Some(ag) = env_id("ANTIGRAVITY_CONVERSATION_ID") {
1410 st.session = session_for(&ag);
1411 st.host_id = Some(ag);
1412 st.binding = Binding::Antigravity;
1413 }
1414 }
1415 st.disconnected_reason = None;
1416 }
1417 self.connect_initial().await;
1418 }
1419
1420 async fn await_roots(&self) {
1422 let _ = tokio::time::timeout(ROOTS_WAIT * 2, self.roots_gate.lock()).await;
1423 }
1424
1425 fn inputs(&self, st: &State) -> Inputs {
1428 let mut inputs = self.opts.inputs.clone();
1429 if let Some(dir) = &st.roots_dir {
1430 inputs.project_dir = Some(dir.clone());
1431 }
1432 inputs
1433 }
1434
1435 #[allow(deprecated)]
1443 async fn discover_roots(&self, peer: &rmcp::Peer<RoleServer>) {
1444 if self.opts.inputs.project_dir.is_some() || self.opts.inputs.profile.is_some() {
1445 return;
1446 }
1447 let start = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
1448 if matches!(context::find_project(&start), Ok(Some(_))) {
1449 return;
1450 }
1451 let roots = match tokio::time::timeout(ROOTS_WAIT, peer.list_roots()).await {
1452 Ok(Ok(r)) => r.roots,
1453 Ok(Err(e)) => {
1454 tracing::debug!(error = %e, "the client did not answer roots/list");
1455 return;
1456 }
1457 Err(_) => {
1458 tracing::debug!("the client did not answer roots/list in time");
1459 return;
1460 }
1461 };
1462 let Some(dir) = roots
1463 .iter()
1464 .filter_map(|r| path_of_file_uri(&r.uri))
1465 .find(|p| matches!(context::find_project(p), Ok(Some(_))))
1466 else {
1467 return;
1468 };
1469 tracing::info!(root = %dir.display(), "project found through the client's MCP roots");
1470 if let Err(e) = self.adopt_project_dir(dir).await {
1471 tracing::warn!(error = %e, "the project an MCP root names could not be used");
1472 return;
1473 }
1474 let _ = peer.notify_tool_list_changed().await;
1476 }
1477
1478 async fn adopt_project_dir(&self, dir: PathBuf) -> anyhow::Result<()> {
1482 let (inputs, same_profile, connected) = {
1483 let mut st = self.state.write().await;
1484 let previous = st.roots_dir.replace(dir);
1485 let inputs = self.inputs(&st);
1486 let resolved = match context::resolve(&inputs) {
1487 Ok(r) => r,
1488 Err(e) => {
1489 st.roots_dir = previous;
1490 return Err(e);
1491 }
1492 };
1493 let same = st.connected.as_ref().is_some_and(|c| {
1496 c.resolved.profile == resolved.profile
1497 && c.resolved.token == resolved.token
1498 && c.resolved.mcp_url == resolved.mcp_url
1499 });
1500 if same {
1501 if st.project.is_none() {
1502 st.project = resolved.project.clone();
1503 }
1504 if st.channel.is_none() {
1505 st.channel = resolved.channel.clone();
1506 }
1507 if let Some(c) = st.connected.as_mut() {
1508 c.resolved.project_root = resolved.project_root.clone();
1509 }
1510 }
1511 (inputs, same, st.connected.is_some())
1512 };
1513 if !same_profile {
1514 if let Err(e) = self.connect_with(&inputs, !connected).await {
1517 self.state.write().await.roots_dir = None;
1518 return Err(e);
1519 }
1520 return Ok(());
1521 }
1522 self.heartbeat("active").await;
1523 self.write_binding().await;
1524 Ok(())
1525 }
1526
1527 async fn connect_with(
1534 &self,
1535 inputs: &Inputs,
1536 reuse_proof: bool,
1537 ) -> anyhow::Result<Option<PreviousIdentity>> {
1538 let _guard = self.switch.lock().await;
1539 let (session, binding_key) = {
1540 let st = self.state.read().await;
1541 (
1542 st.session.clone(),
1543 st.host_id.clone().unwrap_or_else(|| st.session.clone()),
1544 )
1545 };
1546 let existing = reuse_proof
1551 .then(|| context::read_binding(&self.opts.state_dir, &binding_key))
1552 .flatten()
1553 .and_then(|b| match (b.session_token, b.session_id, b.epoch) {
1554 (Some(token), Some(session_id), Some(epoch)) if !token.is_empty() => {
1555 Some(SessionProof {
1556 token,
1557 session_id,
1558 epoch,
1559 expires_at: b.expires_at.unwrap_or_default(),
1560 })
1561 }
1562 _ => None,
1563 });
1564 let (resolved, agent, team, remote, tools, instructions, proof) =
1565 establish(inputs, &session, existing).await?;
1566
1567 {
1573 let st = self.state.read().await;
1574 if let Some(old) = st.identity()
1575 && old.team != team
1576 {
1577 let _ = remote.cancel().await;
1578 anyhow::bail!(
1579 "this conversation is bound to team '{}'; the profile '{}' belongs to team \
1580 '{team}'. Switching teams inside a conversation is not allowed — the \
1581 transcript already holds '{}' material. Start a new conversation with \
1582 that profile instead",
1583 old.team,
1584 resolved.profile.as_deref().unwrap_or("?"),
1585 old.team
1586 );
1587 }
1588 }
1589
1590 {
1593 let mut st = self.state.write().await;
1594 if st.project.is_none() {
1595 st.project = resolved.project.clone();
1596 }
1597 if st.channel.is_none() {
1598 st.channel = resolved.channel.clone();
1599 }
1600 }
1601
1602 let previous = {
1605 let old = {
1606 let mut st = self.state.write().await;
1607 st.connected.take()
1608 };
1609 match old {
1610 Some(old) => {
1611 old.ct.cancel();
1612 let started = std::time::Instant::now();
1613 while self.in_flight.load(Ordering::SeqCst) > 0
1614 && started.elapsed() < DRAIN_TIMEOUT
1615 {
1616 tokio::time::sleep(Duration::from_millis(20)).await;
1617 }
1618 let held = report_holdings(&old.remote, &old.agent, &old.team, &session).await;
1619 if old.proof.is_some() {
1627 let _ = tokio::time::timeout(
1628 EXIT_TIMEOUT,
1629 call_remote(&old.remote, "revoke_session", json!({})),
1630 )
1631 .await;
1632 }
1633 let _ = tokio::time::timeout(
1635 EXIT_TIMEOUT,
1636 call_remote(
1637 &old.remote,
1638 "heartbeat",
1639 json!({"status": "idle", "ttl_seconds": 30}),
1640 ),
1641 )
1642 .await;
1643 close_remote(old.remote).await;
1644 Some(held)
1645 }
1646 None => None,
1647 }
1648 };
1649
1650 let connected = Connected {
1651 resolved,
1652 agent,
1653 team,
1654 remote: Arc::new(remote),
1655 tools,
1656 remote_instructions: instructions,
1657 proof,
1658 ct: CancellationToken::new(),
1659 };
1660 {
1661 let mut st = self.state.write().await;
1662 st.connected = Some(connected);
1663 st.disconnected_reason = None;
1664 st.stranded = None;
1665 st.generation += 1;
1666 }
1667 self.wake.notify_one();
1670 self.heartbeat("active").await;
1671 self.write_binding().await;
1672 Ok(previous)
1673 }
1674
1675 async fn heartbeat(&self, status: &str) {
1678 self.publish_presence(json!({"status": status})).await;
1679 }
1680
1681 async fn refresh_presence(&self) {
1686 let due = self.attention.lock().ok().and_then(|mut a| a.due(true));
1687 let args = match due {
1688 Some(report) => report.fields(),
1689 None => json!({"refresh_only": true}),
1690 };
1691 if self.publish_presence(args).await
1692 && let Some(report) = due
1693 && let Ok(mut a) = self.attention.lock()
1694 {
1695 a.sent(report);
1696 }
1697 }
1698
1699 async fn report_attention(&self) {
1701 let due = self.attention.lock().ok().and_then(|mut a| a.due(false));
1702 let Some(report) = due else { return };
1703 if self.publish_presence(report.fields()).await
1704 && let Ok(mut a) = self.attention.lock()
1705 {
1706 a.sent(report);
1707 }
1708 }
1709
1710 async fn publish_presence(&self, fields: Value) -> bool {
1713 let _ordered = self.presence.lock().await;
1714 let (remote, project, role, dir) = {
1715 let st = self.state.read().await;
1716 let Some(c) = &st.connected else { return false };
1717 let dir = st
1718 .roots_dir
1719 .clone()
1720 .unwrap_or_else(|| self.project_dir.clone());
1721 (c.remote.clone(), st.project.clone(), st.role.clone(), dir)
1722 };
1723 let (repo, branch) = git_place(&dir);
1724 let mut args = fields;
1725 args["ttl_seconds"] = json!(PRESENCE_TTL_SECS);
1726 if let Some(r) = repo {
1727 args["repo"] = Value::String(r);
1728 }
1729 if let Some(b) = branch {
1730 args["branch"] = Value::String(b);
1731 }
1732 args["project"] = Value::String(project.unwrap_or_default());
1734 args["role"] = Value::String(role.unwrap_or_default());
1735 match call_remote(&remote, "heartbeat", args).await {
1736 Ok(_) => true,
1737 Err(e) => {
1738 tracing::warn!(error = %e, "heartbeat failed");
1739 false
1740 }
1741 }
1742 }
1743
1744 async fn write_binding(&self) {
1750 let st = self.state.read().await;
1751 let key = st.host_id.clone().unwrap_or_else(|| st.session.clone());
1755 let identity = st.identity();
1758 let (profile, agent, team, mcp_url, proof) = match identity {
1759 Some(i) => (
1760 i.profile,
1761 Some(i.agent),
1762 Some(i.team),
1763 Some(i.mcp_url),
1764 i.proof,
1765 ),
1766 None => (None, None, None, None, None),
1767 };
1768 let record = json!({
1772 "host_id_present": st.host_id.is_some(),
1773 "binding": st.binding,
1774 "session": st.session,
1775 "project": st.project,
1776 "role": st.role,
1777 "channel": st.channel,
1778 "profile": profile,
1779 "agent": agent,
1780 "team": team,
1781 "mcp_url": mcp_url,
1782 "session_token": proof.as_ref().map(|p| p.token.clone()),
1783 "session_id": proof.as_ref().map(|p| p.session_id.clone()),
1784 "epoch": proof.as_ref().map(|p| p.epoch),
1787 "expires_at": proof.as_ref().map(|p| p.expires_at.clone()),
1788 "proxy_pid": std::process::id(),
1789 "updated_at": chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Secs, true),
1790 });
1791 drop(st);
1792 let path = context::binding_path(&self.opts.state_dir, &key);
1796 let text = record.to_string();
1797 if let Err(e) = under_config_lock(self.opts.state_dir.clone(), move || {
1798 context::write_binding_file(&path, &text)
1799 })
1800 .await
1801 {
1802 tracing::warn!(error = %e, "could not write the session binding");
1803 }
1804 }
1805
1806 async fn status(&self) -> Status {
1807 let reported = self.attention.lock().ok().and_then(|a| a.reported);
1808 let st = self.state.read().await;
1809 let c = st.connected.as_ref();
1810 Status {
1811 connected: c.is_some(),
1812 error: st.disconnected_reason.clone(),
1813 agent: c.map(|c| c.agent.clone()),
1814 team: c.map(|c| c.team.clone()),
1815 session: st.session.clone(),
1816 address: c.map(|c| format!("{}/{}", c.agent, st.session)),
1817 project: st.project.clone(),
1818 role: st.role.clone(),
1819 channel: st.channel.clone(),
1820 profile: c.and_then(|c| c.resolved.profile.clone()),
1821 credential_from: c.map(|c| c.resolved.credential_provenance()),
1822 binding: st.binding,
1823 project_root: c
1824 .and_then(|c| c.resolved.project_root.as_ref())
1825 .map(|p| p.display().to_string()),
1826 project_root_from: c.and_then(|c| c.resolved.project_root.as_ref()).map(|_| {
1827 if st.roots_dir.is_some() {
1828 "mcp-roots"
1829 } else if self.opts.inputs.project_dir.is_some() {
1830 "project-dir"
1831 } else {
1832 "working-directory"
1833 }
1834 .to_owned()
1835 }),
1836 bus: c.map(|c| c.resolved.mcp_url.clone()),
1837 attention: reported.map(|(s, _)| s.to_owned()),
1838 attention_reported_seconds_ago: reported.map(|(_, at)| at.elapsed().as_secs()),
1839 }
1840 }
1841
1842 async fn configure(&self, args: ConfigureArgs) -> anyhow::Result<ConfigureResult> {
1843 let mut previous = None;
1845 let staged_role = match args.role {
1850 Some(v) => Some(check_label("role", &v).map_err(|e| anyhow::anyhow!("{}", e.message))?),
1851 None => None,
1852 };
1853 let staged_project = match args.project {
1854 Some(v) => {
1855 Some(check_label("project", &v).map_err(|e| anyhow::anyhow!("{}", e.message))?)
1856 }
1857 None => None,
1858 };
1859 let staged_channel = match args.channel {
1860 Some(v) => {
1861 Some(check_label("channel", &v).map_err(|e| anyhow::anyhow!("{}", e.message))?)
1862 }
1863 None => None,
1864 };
1865
1866 if let Some(profile) = args
1867 .profile
1868 .map(|p| p.trim().to_owned())
1869 .filter(|p| !p.is_empty())
1870 {
1871 let mut inputs = self.inputs(&*self.state.read().await);
1872 inputs.profile = Some(profile);
1873 inputs.explicit_token = None;
1876 inputs.explicit_url = None;
1877 previous = self.connect_with(&inputs, false).await?;
1878 }
1879 {
1881 let mut st = self.state.write().await;
1882 if let Some(role) = staged_role {
1883 st.role = role;
1884 }
1885 if let Some(project) = staged_project {
1886 st.project = project;
1887 }
1888 if let Some(channel) = staged_channel {
1889 st.channel = channel;
1890 }
1891 }
1892 self.heartbeat("active").await;
1893 self.write_binding().await;
1894 Ok(ConfigureResult {
1895 status: self.status().await,
1896 previous,
1897 })
1898 }
1899
1900 async fn observe_meta(&self, meta: &rmcp::model::RequestMetaObject) -> Result<(), ErrorData> {
1905 let id_at = |key: &str| {
1906 meta.0
1907 .0
1908 .get(key)
1909 .and_then(Value::as_str)
1910 .map(str::trim)
1911 .filter(|s| !s.is_empty())
1912 };
1913 let (thread, source) = match id_at(ANTIGRAVITY_META_KEY) {
1917 Some(id) => (id, Binding::Antigravity),
1918 None => match id_at("threadId").or_else(|| id_at("sessionId")) {
1919 Some(id) => (id, Binding::RequestMeta),
1920 None => return Ok(()),
1921 },
1922 };
1923 let (bound, current, by_request) = {
1924 let st = self.state.read().await;
1925 (st.host_id.clone(), st.binding, st.bound_by_request)
1926 };
1927 match bound {
1928 Some(id) if id == thread => Ok(()),
1929 Some(_) if by_request && current == source => Err(ErrorData::invalid_request(
1930 "this proxy instance is bound to another conversation; a second one is using \
1931 the same MCP process, which is not supported. Configure the host to start \
1932 one `ai-crew-sync mcp proxy` per conversation",
1933 None,
1934 )),
1935 Some(_) => Ok(()),
1938 None => self.rebind(thread.to_owned(), source).await.map_err(|e| {
1939 ErrorData::internal_error(format!("could not bind the conversation: {e:#}"), None)
1940 }),
1941 }
1942 }
1943
1944 async fn rebind(&self, host_id: String, source: Binding) -> anyhow::Result<()> {
1948 let previous = {
1953 let st = self.state.read().await;
1954 (
1955 st.host_id.clone(),
1956 st.binding,
1957 st.session.clone(),
1958 st.bound_by_request,
1959 )
1960 };
1961 {
1962 let mut st = self.state.write().await;
1963 st.host_id = Some(host_id.clone());
1964 st.binding = source;
1965 st.bound_by_request = true;
1966 st.session = session_for(&host_id);
1967 }
1968 let inputs = {
1971 let st = self.state.read().await;
1972 match st.identity() {
1973 Some(id) => {
1974 let mut i = self.inputs(&st);
1975 i.profile = id.profile;
1976 i
1977 }
1978 None => self.inputs(&st),
1979 }
1980 };
1981 match self.connect_with(&inputs, true).await {
1982 Ok(_) => Ok(()),
1983 Err(e) => {
1984 let mut st = self.state.write().await;
1985 (st.host_id, st.binding, st.session, st.bound_by_request) = previous;
1986 st.disconnected_reason = Some(format!("{e:#}"));
1987 Err(e)
1988 }
1989 }
1990 }
1991
1992 async fn spool_and_confirm(
2002 &self,
2003 result: CallToolResult,
2004 host_ct: CancellationToken,
2005 ) -> CallToolResult {
2006 let Some(structured) = result.structured_content.clone() else {
2007 return result;
2008 };
2009 let session = self.state.read().await.session.clone();
2010 let path = crate::spool::spool_path(&self.opts.state_dir, &session);
2011
2012 let mut entries: Vec<crate::spool::Entry> = structured
2013 .get("references")
2014 .and_then(|v| v.as_array())
2015 .map(|refs| {
2016 refs.iter()
2017 .filter_map(|r| {
2018 Some(crate::spool::Entry {
2019 delivery_id: r.get("delivery_id")?.as_str()?.to_owned(),
2020 message_id: r.get("message_id")?.as_str()?.to_owned(),
2021 conversation_id: r
2022 .get("conversation_id")
2023 .and_then(|v| v.as_str())
2024 .unwrap_or_default()
2025 .to_owned(),
2026 seq: r.get("seq").and_then(|v| v.as_i64()).unwrap_or(0),
2027 from_address: r
2028 .get("from_address")
2029 .and_then(|v| v.as_str())
2030 .unwrap_or_default()
2031 .to_owned(),
2032 created_at: r
2033 .get("created_at")
2034 .and_then(|v| v.as_str())
2035 .unwrap_or_default()
2036 .to_owned(),
2037 confirmed: false,
2038 spooled_at: chrono::Utc::now().to_rfc3339(),
2039 })
2040 })
2041 .collect::<Vec<_>>()
2042 })
2043 .unwrap_or_default();
2044
2045 let mut held = crate::spool::read(&path);
2049 let spooled = match crate::spool::append(&path, &entries) {
2050 Ok(written) => written,
2051 Err(e) => {
2052 tracing::warn!(error = %e, "could not spool inbox references; not confirming");
2053 return result;
2054 }
2055 };
2056 held.extend(spooled.iter().cloned());
2057 entries.clear();
2058 let to_confirm = crate::spool::unconfirmed(&held);
2059 if to_confirm.is_empty() {
2060 return result;
2061 }
2062
2063 let mut params = rmcp::model::JsonObject::new();
2064 params.insert(
2065 "delivery_ids".into(),
2066 Value::Array(
2067 to_confirm
2068 .iter()
2069 .map(|id| Value::String(id.clone()))
2070 .collect(),
2071 ),
2072 );
2073 let confirm =
2074 CallToolRequestParams::new("confirm_inbox_delivery".to_string()).with_arguments(params);
2075 match self.forward(confirm, host_ct).await {
2076 Ok(confirmation) => {
2077 let settled = settled_ids(confirmation.structured_content.as_ref(), &to_confirm);
2084 let sent: std::collections::HashSet<&String> = to_confirm.iter().collect();
2085 let mut left = 0usize;
2086 for entry in held.iter_mut() {
2087 if sent.contains(&entry.delivery_id) {
2088 if settled.contains(&entry.delivery_id) {
2089 entry.confirmed = true;
2090 } else {
2091 left += 1;
2092 }
2093 }
2094 }
2095 if left > 0 {
2096 tracing::warn!(
2097 sent = to_confirm.len(),
2098 left,
2099 "the bus did not settle every reference sent; keeping the rest in \
2100 the spool"
2101 );
2102 }
2103 if let Err(e) = crate::spool::rewrite(&path, &held) {
2104 tracing::warn!(error = %e, "could not compact the inbox spool");
2108 }
2109 }
2110 Err(e) => tracing::warn!(error = %e, "could not confirm inbox delivery"),
2111 }
2112 result
2113 }
2114
2115 async fn forward(
2116 &self,
2117 request: CallToolRequestParams,
2118 host_ct: CancellationToken,
2119 ) -> Result<CallToolResult, ErrorData> {
2120 self.forward_bound(request, host_ct, None).await
2121 }
2122
2123 async fn forward_bound(
2129 &self,
2130 request: CallToolRequestParams,
2131 host_ct: CancellationToken,
2132 bound: Option<u64>,
2133 ) -> Result<CallToolResult, ErrorData> {
2134 let (remote, ct, generation, _guard) = {
2135 let st = self.state.read().await;
2136 if let Some(started) = bound
2137 && st.generation != started
2138 {
2139 return Err(ErrorData::invalid_request(
2140 format!(
2141 "{} was cancelled: this window switched credentials while the call was \
2142 in flight (generation {started}). Nothing was replayed; call again if \
2143 it is still wanted, as the new identity",
2144 request.name
2145 ),
2146 None,
2147 ));
2148 }
2149 let Some(c) = &st.connected else {
2150 return Err(ErrorData::invalid_request(
2151 format!(
2152 "not connected to the bus: {}. Call {CONFIGURE_TOOL} with an approved \
2153 profile, or fix the local configuration and start a new conversation",
2154 st.disconnected_reason
2155 .as_deref()
2156 .unwrap_or("no profile resolved")
2157 ),
2158 None,
2159 ));
2160 };
2161 (
2162 c.remote.clone(),
2163 c.ct.clone(),
2164 st.generation,
2165 InFlight::enter(&self.in_flight),
2166 )
2167 };
2168 let name = request.name.to_string();
2169 let profile = {
2170 let st = self.state.read().await;
2171 st.connected
2172 .as_ref()
2173 .and_then(|c| c.resolved.profile.clone())
2174 };
2175 let outcome = tokio::select! {
2176 r = remote.call_tool(request) => r.map_err(|e| {
2177 if unauthorized(&e) {
2181 self.mark_unauthorized(&profile, generation);
2182 ErrorData::invalid_request(
2183 format!(
2184 "{name}: the bus rejected this window's credential — it has been \
2185 revoked or rotated{}. Issue a new token (`ai-crew-sync admin \
2186 token issue --save`) and call {CONFIGURE_TOOL} with an approved \
2187 profile; nothing was sent",
2188 profile
2189 .as_deref()
2190 .map(|p| format!(" (profile '{p}')"))
2191 .unwrap_or_default()
2192 ),
2193 None,
2194 )
2195 } else if let ServiceError::McpError(data) = e {
2196 data
2199 } else if let Some(r) = refusal(&e) {
2200 tracing::warn!(error = %e, tool = %name, "the bus refused a forwarded call");
2203 ErrorData::invalid_request(format!("{name}: {}", r.text()), None)
2204 } else {
2205 tracing::warn!(error = %e, tool = %name, "a forwarded call failed in transport");
2209 ErrorData::internal_error(
2210 format!(
2211 "{name} could not reach the bus: the connection failed before an \
2212 answer came back, so the call may or may not have run. Check \
2213 before repeating anything that is not safe to repeat."
2214 ),
2215 None,
2216 )
2217 }
2218 }),
2219 _ = ct.cancelled() => Err(ErrorData::invalid_request(
2220 format!(
2221 "{name} was cancelled: this window switched credentials while the call was \
2222 in flight (generation {generation}). Nothing was replayed; call again if \
2223 it is still wanted, as the new identity"
2224 ),
2225 None,
2226 )),
2227 _ = host_ct.cancelled() => Err(ErrorData::invalid_request(
2228 format!("{name} was cancelled by the client"),
2229 None,
2230 )),
2231 };
2232 outcome.map(|mut result| {
2238 result
2239 .result_type
2240 .get_or_insert(rmcp::model::ResultType::COMPLETE);
2241 result
2242 })
2243 }
2244
2245 async fn long_wait(
2250 &self,
2251 request: CallToolRequestParams,
2252 host_ct: CancellationToken,
2253 ) -> Result<CallToolResult, ErrorData> {
2254 let requested = request
2255 .arguments
2256 .as_ref()
2257 .and_then(|a| a.get("timeout_seconds"))
2258 .and_then(Value::as_i64);
2259 let Some(total) = requested.filter(|t| *t > SERVER_WAIT_LIMIT_SECS) else {
2260 return self.forward(request, host_ct).await;
2261 };
2262 let started_under = self.state.read().await.generation;
2264 let mut since = request
2267 .arguments
2268 .as_ref()
2269 .and_then(|a| a.get("since"))
2270 .and_then(Value::as_str)
2271 .map(str::to_owned);
2272 let deadline = std::time::Instant::now()
2273 + std::time::Duration::from_secs(total.min(PROXY_MAX_WAIT_SECS) as u64);
2274 loop {
2275 let left = deadline
2276 .saturating_duration_since(std::time::Instant::now())
2277 .as_secs() as i64;
2278 let mut slice = request.clone();
2279 let args = slice.arguments.get_or_insert_with(Default::default);
2280 args.insert(
2281 "timeout_seconds".into(),
2282 json!(left.clamp(5, SERVER_WAIT_SLICE_SECS)),
2283 );
2284 if let Some(position) = &since {
2285 args.insert("since".into(), json!(position));
2286 }
2287 let result = self
2288 .forward_bound(slice, host_ct.clone(), Some(started_under))
2289 .await?;
2290 let woke = result
2291 .structured_content
2292 .as_ref()
2293 .and_then(|v| v["woke"].as_bool())
2294 .unwrap_or(true);
2295 if woke || left <= SERVER_WAIT_SLICE_SECS || host_ct.is_cancelled() {
2296 return Ok(result);
2297 }
2298 since = result
2299 .structured_content
2300 .as_ref()
2301 .and_then(|v| v["resume_from"].as_str().map(str::to_owned))
2302 .or(since);
2303 }
2304 }
2305
2306 fn mark_unauthorized(&self, profile: &Option<String>, generation: u64) {
2314 if let Ok(mut st) = self.state.try_write()
2315 && st.generation == generation
2316 {
2317 st.disconnected_reason = Some(format!(
2318 "the bus rejected the credential{} (revoked or rotated)",
2319 profile
2320 .as_deref()
2321 .map(|p| format!(" of profile '{p}'"))
2322 .unwrap_or_default()
2323 ));
2324 }
2325 }
2326
2327 async fn resume_in_place(&self) -> Option<Result<Value, String>> {
2340 let _guard = self.switch.lock().await;
2341 let (url, proof, session) = {
2342 let st = self.state.read().await;
2343 let c = st.connected.as_ref()?;
2344 let proof = c.proof.clone()?;
2345 (c.resolved.mcp_url.clone(), proof, st.session.clone())
2346 };
2347 Some(self.adopt_resumed(&url, &proof, &session).await)
2348 }
2349
2350 async fn adopt_resumed(
2351 &self,
2352 url: &str,
2353 prior: &SessionProof,
2354 session: &str,
2355 ) -> Result<Value, String> {
2356 let proof = rotate(url, prior, session).await?;
2357 let mut attempt = 1;
2361 let opened = loop {
2362 match open_with(url, &proof, session).await {
2363 Ok(opened) => break Ok(opened),
2364 Err(why) if attempt < ADOPT_ATTEMPTS => {
2365 tracing::warn!(
2366 attempt,
2367 "the resumed credential could not open a connection ({why}); retrying"
2368 );
2369 tokio::time::sleep(Duration::from_millis(250 * 4u64.pow(attempt - 1))).await;
2370 attempt += 1;
2371 }
2372 Err(why) => break Err(why),
2373 }
2374 };
2375 let old = {
2376 let mut st = self.state.write().await;
2377 let Some(c) = st.connected.take() else {
2378 return Err(
2379 "resume_session: the session was resumed, but this window lost its \
2380 connection meanwhile; restart this MCP server to reconnect it"
2381 .to_owned(),
2382 );
2383 };
2384 match opened {
2385 Ok((remote, tools)) => {
2386 let instructions = remote.peer_info().and_then(|i| i.instructions.clone());
2387 st.connected = Some(Connected {
2388 resolved: c.resolved.clone(),
2389 agent: c.agent.clone(),
2390 team: c.team.clone(),
2391 remote: Arc::new(remote),
2392 tools,
2393 remote_instructions: instructions,
2394 proof: Some(proof.clone()),
2395 ct: CancellationToken::new(),
2396 });
2397 st.disconnected_reason = None;
2398 }
2399 Err(why) => {
2400 st.stranded = Some(Identity {
2406 profile: c.resolved.profile.clone(),
2407 agent: c.agent.clone(),
2408 team: c.team.clone(),
2409 mcp_url: c.resolved.mcp_url.clone(),
2410 proof: Some(proof.clone()),
2411 });
2412 let said = format!(
2413 "the session was resumed and the bus rotated this window's credential, \
2414 but the new one could not open a connection ({why}); it is saved, and \
2415 this window is off the bus until this MCP server is restarted, which \
2416 reconnects with it"
2417 );
2418 st.disconnected_reason = Some(said.clone());
2419 st.generation += 1;
2420 drop(st);
2421 c.ct.cancel();
2422 close_remote(c.remote).await;
2423 self.wake.notify_one();
2424 self.write_binding().await;
2425 return Err(format!("resume_session: {said}"));
2426 }
2427 }
2428 st.generation += 1;
2429 c
2430 };
2431 old.ct.cancel();
2435 close_remote(old.remote).await;
2436 self.wake.notify_one();
2437 self.write_binding().await;
2438 let expires_in_seconds = chrono::DateTime::parse_from_rfc3339(&proof.expires_at)
2439 .map(|t| (t.with_timezone(&chrono::Utc) - chrono::Utc::now()).num_seconds())
2440 .unwrap_or(0)
2441 .max(0);
2442 let described = crate::model::SessionCredential {
2444 session_token: None,
2445 session_id: proof.session_id,
2446 session: session.to_owned(),
2447 address: format!("{}/{session}", old.agent),
2448 epoch: proof.epoch,
2449 expires_at: proof.expires_at,
2450 expires_in_seconds,
2451 };
2452 serde_json::to_value(described).map_err(|e| {
2453 tracing::warn!(error = %e, "could not describe the resumed credential");
2454 "resume_session: the session was resumed and this window holds the new \
2455 credential, but its description could not be built; session_status shows it"
2456 .to_owned()
2457 })
2458 }
2459
2460 async fn default_channel(&self) -> Option<String> {
2463 let st = self.state.read().await;
2464 st.channel.clone().or_else(|| st.project.clone())
2465 }
2466
2467 fn instructions(&self, st: &State) -> String {
2468 let mut lines = Vec::new();
2469 match &st.connected {
2470 Some(c) => {
2471 lines.push(format!(
2472 "[ai-crew-sync] You are agent '{}' on team '{}', in session '{}'. Teammates \
2473 reach exactly this window at '{}/{}'.",
2474 c.agent, c.team, st.session, c.agent, st.session
2475 ));
2476 lines.push(format!(
2477 "- project: {}, role: {}, default channel: {}. Change them with \
2478 {CONFIGURE_TOOL}; see them with {STATUS_TOOL}. Find teammates' windows \
2479 with list_sessions.",
2480 st.project.as_deref().unwrap_or("(none — set it)"),
2481 st.role.as_deref().unwrap_or("(none — set it)"),
2482 st.channel
2483 .as_deref()
2484 .or(st.project.as_deref())
2485 .unwrap_or("(none)"),
2486 ));
2487 lines.push(
2488 "- Nothing is pushed into an idle turn: call read_messages or wait_for_updates \
2489 to receive what teammates sent."
2490 .to_owned(),
2491 );
2492 if let Some(remote) = &c.remote_instructions {
2493 lines.push(String::new());
2494 lines.push(remote.clone());
2495 }
2496 }
2497 None => {
2498 lines.push(format!(
2499 "[ai-crew-sync] Not connected to the team bus: {}. Only {CONFIGURE_TOOL} and \
2500 {STATUS_TOOL} are available until a locally approved profile connects.",
2501 st.disconnected_reason
2502 .as_deref()
2503 .unwrap_or("no profile resolved")
2504 ));
2505 }
2506 }
2507 lines.join("\n")
2508 }
2509
2510 pub async fn keepalive(self, ct: CancellationToken) {
2515 let mut next_heartbeat = tokio::time::Instant::now() + KEEPALIVE_EVERY;
2516 let mut last_flush = tokio::time::Instant::now() - FLUSH_RETRY;
2517 let mut not_before: Option<(u64, tokio::time::Instant)> = None;
2524 loop {
2525 let deadline = self.renewal_deadline().await;
2526 let renew_at = deadline.map(|(generation, at)| match not_before {
2527 Some((for_generation, nb)) if for_generation == generation => at.max(nb),
2528 _ => at,
2529 });
2530 let flush_at = self
2534 .attention
2535 .lock()
2536 .ok()
2537 .and_then(|a| a.flush_at())
2538 .map(tokio::time::Instant::from_std)
2539 .map(|at| at.max(last_flush + FLUSH_RETRY));
2540 let flush_sleep = tokio::time::sleep_until(
2541 flush_at.unwrap_or_else(|| tokio::time::Instant::now() + KEEPALIVE_EVERY),
2542 );
2543 let renew_sleep = tokio::time::sleep_until(
2544 renew_at.unwrap_or_else(|| tokio::time::Instant::now() + KEEPALIVE_EVERY),
2545 );
2546 tokio::select! {
2547 _ = ct.cancelled() => return,
2548 _ = self.wake.notified() => {}
2550 _ = tokio::time::sleep_until(next_heartbeat) => {
2551 self.refresh_presence().await;
2554 next_heartbeat = tokio::time::Instant::now() + KEEPALIVE_EVERY;
2555 }
2556 _ = self.attention_changed.notified() => {
2557 self.report_attention().await;
2558 }
2559 _ = flush_sleep, if flush_at.is_some() => {
2560 last_flush = tokio::time::Instant::now();
2561 self.report_attention().await;
2562 }
2563 _ = renew_sleep, if renew_at.is_some() => {
2564 let pause = match self.renew_credential().await {
2565 Renewal::Refused => KEEPALIVE_EVERY,
2569 Renewal::Renewed | Renewal::Retry | Renewal::Nothing => self.renewal_retry().await,
2570 };
2571 if let Some((generation, _)) = deadline {
2572 not_before = Some((generation, tokio::time::Instant::now() + pause));
2573 }
2574 }
2575 }
2576 }
2577 }
2578
2579 async fn renewal_deadline(&self) -> Option<(u64, tokio::time::Instant)> {
2583 let (generation, expires_at) = {
2584 let st = self.state.read().await;
2585 let expires_at = st
2586 .connected
2587 .as_ref()
2588 .and_then(|c| c.proof.as_ref())
2589 .map(|p| p.expires_at.clone())?;
2590 (st.generation, expires_at)
2591 };
2592 let expires_at = chrono::DateTime::parse_from_rfc3339(&expires_at).ok()?;
2593 let remaining = (expires_at.with_timezone(&chrono::Utc) - chrono::Utc::now())
2594 .num_seconds()
2595 .max(0);
2596 let due_in = (remaining - self.renewal_lead().await).max(0) as u64;
2597 Some((
2598 generation,
2599 tokio::time::Instant::now() + Duration::from_secs(due_in),
2600 ))
2601 }
2602
2603 async fn renewal_lead(&self) -> i64 {
2604 let lifetime = requested_session_ttl().unwrap_or(crate::auth::SESSION_TTL_SECS);
2605 renewal_lead_secs(lifetime, env_secs(RENEW_LEAD_ENV))
2606 }
2607
2608 async fn renewal_retry(&self) -> Duration {
2611 Duration::from_secs((self.renewal_lead().await / 4).clamp(2, 60) as u64)
2612 }
2613
2614 async fn renew_credential(&self) -> Renewal {
2619 let (remote, proof, generation, ct, profile) = {
2620 let st = self.state.read().await;
2621 let Some(c) = &st.connected else {
2622 return Renewal::Nothing;
2623 };
2624 let Some(p) = &c.proof else {
2625 return Renewal::Nothing;
2626 };
2627 (
2628 c.remote.clone(),
2629 p.clone(),
2630 st.generation,
2631 c.ct.clone(),
2632 c.resolved.profile.clone(),
2633 )
2634 };
2635 let mut args = json!({});
2636 if let Some(ttl) = requested_session_ttl() {
2637 args["ttl_seconds"] = json!(ttl);
2638 }
2639 let outcome = tokio::select! {
2640 _ = ct.cancelled() => return Renewal::Nothing,
2641 r = call_remote(&remote, "renew_session", args) => r,
2642 };
2643 match outcome {
2644 Ok(v) => {
2645 let Some(expires_at) = v["expires_at"].as_str().map(str::to_owned) else {
2646 tracing::warn!("renew_session answered without an expiry; keeping the old one");
2647 return Renewal::Retry;
2648 };
2649 if v["epoch"].as_i64().is_some_and(|e| e != proof.epoch) {
2650 tracing::warn!(
2653 "renew_session answered for another epoch; keeping the credential this \
2654 window holds"
2655 );
2656 return Renewal::Retry;
2657 }
2658 {
2659 let mut st = self.state.write().await;
2660 if st.generation != generation {
2661 return Renewal::Nothing;
2662 }
2663 let Some(current) = st.connected.as_mut().and_then(|c| c.proof.as_mut()) else {
2664 return Renewal::Nothing;
2665 };
2666 if current.session_id != proof.session_id || current.epoch != proof.epoch {
2667 return Renewal::Nothing;
2668 }
2669 current.expires_at = expires_at.clone();
2670 }
2671 self.stamp_binding_expiry(&proof, &expires_at).await;
2672 tracing::debug!(expires_at = %expires_at, "session credential renewed");
2673 Renewal::Renewed
2674 }
2675 Err(e) => match verdict_of(&e) {
2676 Some(Verdict::Unauthorized) => {
2677 tracing::warn!(error = %e, "the bus refused to renew this window's credential");
2678 self.mark_unauthorized(&profile, generation);
2679 Renewal::Refused
2680 }
2681 Some(Verdict::NoSuchTool) => {
2682 tracing::debug!("this bus does not renew credentials");
2683 Renewal::Nothing
2684 }
2685 None => {
2686 tracing::warn!(error = %e, "could not renew this window's credential; retrying");
2687 Renewal::Retry
2688 }
2689 },
2690 }
2691 }
2692
2693 async fn stamp_binding_expiry(&self, proof: &SessionProof, expires_at: &str) {
2697 let key = {
2698 let st = self.state.read().await;
2699 st.host_id.clone().unwrap_or_else(|| st.session.clone())
2700 };
2701 let path = context::binding_path(&self.opts.state_dir, &key);
2702 let (session_id, epoch, expires_at) =
2707 (proof.session_id.clone(), proof.epoch, expires_at.to_owned());
2708 let stamped = under_config_lock(self.opts.state_dir.clone(), move || {
2709 let Ok(text) = std::fs::read_to_string(&path) else {
2710 return Ok(false);
2711 };
2712 let Ok(mut value) = serde_json::from_str::<Value>(&text) else {
2713 return Ok(false);
2714 };
2715 let same = value["session_id"].as_str() == Some(session_id.as_str())
2716 && value["epoch"].as_i64() == Some(epoch);
2717 if !same {
2718 return Ok(false);
2719 }
2720 if let Some(map) = value.as_object_mut() {
2721 map.insert("expires_at".into(), json!(expires_at));
2722 map.insert(
2723 "updated_at".into(),
2724 json!(chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Secs, true)),
2725 );
2726 }
2727 context::write_binding_file(&path, &value.to_string())?;
2728 Ok(true)
2729 })
2730 .await;
2731 match stamped {
2732 Ok(true) => {}
2733 Ok(false) => tracing::debug!(
2734 binding = %key,
2735 "another instance owns this binding now, or it is gone; not stamping"
2736 ),
2737 Err(e) => {
2738 tracing::warn!(error = %e, binding = %key, "could not record the renewed expiry")
2739 }
2740 }
2741 }
2742
2743 pub async fn shutdown(&self) {
2745 let remote = {
2746 let st = self.state.read().await;
2747 st.connected.as_ref().map(|c| c.remote.clone())
2748 };
2749 if let Some(remote) = remote {
2750 let _ = tokio::time::timeout(
2751 EXIT_TIMEOUT,
2752 call_remote(
2753 &remote,
2754 "heartbeat",
2755 json!({"status": "idle", "ttl_seconds": 120, "attention": "idle", "wake": ""}),
2759 ),
2760 )
2761 .await;
2762 close_remote(remote).await;
2763 }
2764 self.mark_closed().await;
2768 }
2769
2770 async fn mark_closed(&self) {
2782 let (key, mine) = {
2783 let st = self.state.read().await;
2784 let key = st.host_id.clone().unwrap_or_else(|| st.session.clone());
2785 let Some(identity) = st.identity() else {
2789 return;
2790 };
2791 let mine = identity.proof.map(|p| (p.session_id, p.epoch));
2792 (key, mine)
2793 };
2794 let path = context::binding_path(&self.opts.state_dir, &key);
2795 let shown = key.clone();
2796 let outcome = under_config_lock(self.opts.state_dir.clone(), move || {
2797 let Ok(text) = std::fs::read_to_string(&path) else {
2798 return Ok(false);
2799 };
2800 let Ok(mut value) = serde_json::from_str::<Value>(&text) else {
2801 return Ok(false);
2802 };
2803 if let Some((session_id, epoch)) = mine {
2806 let same = value["session_id"].as_str() == Some(session_id.as_str())
2807 && value["epoch"].as_i64() == Some(epoch);
2808 if !same {
2809 return Ok(false);
2810 }
2811 }
2812 if let Some(map) = value.as_object_mut() {
2813 map.insert("closed_at".into(), json!(chrono::Utc::now().to_rfc3339()));
2814 }
2815 context::write_binding_file(&path, &value.to_string())?;
2816 Ok(true)
2817 })
2818 .await;
2819 match outcome {
2820 Ok(true) => {}
2821 Ok(false) => tracing::debug!(
2822 binding = %shown,
2823 "another instance owns this binding now, or it is gone; leaving it alone"
2824 ),
2825 Err(e) => {
2826 tracing::warn!(error = %e, binding = %shown, "could not mark the binding closed")
2827 }
2828 }
2829 }
2830}
2831
2832async fn under_config_lock<T: Send + 'static>(
2837 dir: PathBuf,
2838 f: impl FnOnce() -> anyhow::Result<T> + Send + 'static,
2839) -> anyhow::Result<T> {
2840 tokio::task::spawn_blocking(move || context::with_config_lock(&dir, f))
2841 .await
2842 .map_err(|e| anyhow::anyhow!("the binding writer task failed: {e}"))?
2843}
2844
2845async fn close_remote(remote: Arc<Remote>) {
2849 if let Ok(owned) = Arc::try_unwrap(remote) {
2850 let _ = owned.cancel().await;
2851 }
2852}
2853
2854async fn report_holdings(
2856 remote: &Remote,
2857 agent: &str,
2858 team: &str,
2859 session: &str,
2860) -> PreviousIdentity {
2861 let open_claims = call_remote(remote, "list_tasks", json!({"mine_only": true}))
2862 .await
2863 .ok()
2864 .and_then(|v| v["tasks"].as_array().cloned())
2865 .unwrap_or_default()
2866 .iter()
2867 .filter(|t| t["status"] == "claimed")
2868 .filter_map(|t| t["key"].as_str().map(str::to_owned))
2869 .collect();
2870 let held_locks = call_remote(remote, "list_locks", json!({}))
2871 .await
2872 .ok()
2873 .and_then(|v| v["locks"].as_array().cloned())
2874 .unwrap_or_default()
2875 .iter()
2876 .filter(|l| {
2879 l["holder"] == agent && l["holder_session"].as_str().unwrap_or_default() == session
2880 })
2881 .filter_map(|l| l["name"].as_str().map(str::to_owned))
2882 .collect();
2883 PreviousIdentity {
2884 agent: agent.to_owned(),
2885 team: team.to_owned(),
2886 session: session.to_owned(),
2887 open_claims,
2888 held_locks,
2889 }
2890}
2891
2892const RESUME_TTL_REFUSED: &str = "resume_session: through this proxy a resumed credential keeps \
2894 the lifetime this window renews it with (BUS_SESSION_TTL_SECS, else the bus default), so \
2895 ttl_seconds is not accepted and nothing was resumed; call it without ttl_seconds";
2896
2897fn as_proxied(mut tool: Tool) -> Tool {
2904 if tool.name != "resume_session" {
2905 return tool;
2906 }
2907 let mut schema = (*tool.input_schema).clone();
2908 if let Some(Value::Object(properties)) = schema.get_mut("properties") {
2909 properties.remove("ttl_seconds");
2910 }
2911 if let Some(Value::Array(required)) = schema.get_mut("required") {
2912 required.retain(|r| r != "ttl_seconds");
2913 }
2914 schema.insert("additionalProperties".into(), Value::Bool(false));
2915 tool.input_schema = Arc::new(schema);
2916 let said = tool.description.as_deref().unwrap_or_default();
2917 tool.description = Some(
2918 format!(
2919 "{said} Through this proxy, the proxy runs the resume and keeps the new secret; \
2920 the lifetime is this window's (BUS_SESSION_TTL_SECS), so ttl_seconds is not \
2921 accepted."
2922 )
2923 .trim_start()
2924 .to_owned()
2925 .into(),
2926 );
2927 tool
2928}
2929
2930fn tool_error(msg: String) -> CallToolResult {
2931 CallToolResult::error(vec![rmcp::model::ContentBlock::text(msg)])
2932}
2933
2934impl ServerHandler for Proxy {
2935 async fn initialize(
2936 &self,
2937 request: rmcp::model::InitializeRequestParams,
2938 context: RequestContext<RoleServer>,
2939 ) -> Result<rmcp::model::InitializeResult, ErrorData> {
2940 self.admit(Some(&request.client_info)).await;
2943 if request.capabilities.roots.is_some()
2944 && self.opts.inputs.project_dir.is_none()
2945 && self.opts.inputs.profile.is_none()
2946 && let Ok(gate) = self.roots_gate.clone().try_lock_owned()
2947 && let Ok(mut hold) = self.roots_hold.lock()
2948 {
2949 *hold = Some(gate);
2950 }
2951 context.peer.set_peer_info(request.clone());
2952 self.negotiate_initialize(&request)
2953 }
2954
2955 async fn discover(
2956 &self,
2957 context: RequestContext<RoleServer>,
2958 ) -> Result<rmcp::model::DiscoverResult, ErrorData> {
2959 self.admit(context.meta.client_info().as_ref()).await;
2960 Ok(rmcp::model::DiscoverResult::from_server_info(
2961 self.supported_protocol_versions().into_owned(),
2962 self.get_info(),
2963 ))
2964 }
2965
2966 async fn on_initialized(&self, context: rmcp::service::NotificationContext<RoleServer>) {
2967 let Some(gate) = self.roots_hold.lock().ok().and_then(|mut h| h.take()) else {
2969 return;
2970 };
2971 let this = self.clone();
2972 tokio::spawn(async move {
2973 this.discover_roots(&context.peer).await;
2974 drop(gate);
2975 });
2976 }
2977
2978 fn get_info(&self) -> ServerConfig {
2979 let mut info = ServerConfig::new(
2980 ServerCapabilities::builder()
2981 .enable_tools()
2982 .enable_tool_list_changed()
2983 .build(),
2984 );
2985 let text = match self.state.try_read() {
2989 Ok(st) => self.instructions(&st),
2990 Err(_) => format!("[ai-crew-sync] initialising; call {STATUS_TOOL} for details."),
2991 };
2992 info.instructions = Some(text);
2993 info
2994 }
2995
2996 async fn list_tools(
2997 &self,
2998 _request: Option<PaginatedRequestParams>,
2999 context: RequestContext<RoleServer>,
3000 ) -> Result<ListToolsResult, ErrorData> {
3001 self.admit(context.meta.client_info().as_ref()).await;
3002 self.await_roots().await;
3003 let mut tools = local_tools();
3004 if let Some(c) = &self.state.read().await.connected {
3005 tools.extend(c.tools.iter().cloned().map(as_proxied));
3006 }
3007 Ok(crate::tools::catalogue(tools))
3008 }
3009
3010 async fn call_tool(
3011 &self,
3012 request: CallToolRequestParams,
3013 context: RequestContext<RoleServer>,
3014 ) -> Result<CallToolResponse, ErrorData> {
3015 self.admit(context.meta.client_info().as_ref()).await;
3017 self.await_roots().await;
3018 self.observe_meta(&context.meta).await?;
3019 let _turn = ToolTurn::enter(self, request.name.as_ref());
3021 match request.name.as_ref() {
3022 STATUS_TOOL => {
3023 let status = self.status().await;
3024 let value = serde_json::to_value(status)
3025 .map_err(|e| ErrorData::internal_error(e.to_string(), None))?;
3026 Ok(CallToolResult::structured(value).into())
3027 }
3028 CONFIGURE_TOOL => {
3029 let args: ConfigureArgs = match request.arguments {
3030 Some(map) => serde_json::from_value(Value::Object(map))
3031 .map_err(|e| ErrorData::invalid_params(e.to_string(), None))?,
3032 None => ConfigureArgs::default(),
3033 };
3034 match self.configure(args).await {
3035 Ok(result) => {
3036 let value = serde_json::to_value(result)
3037 .map_err(|e| ErrorData::internal_error(e.to_string(), None))?;
3038 Ok(CallToolResult::structured(value).into())
3039 }
3040 Err(e) => Ok(tool_error(format!("{e:#}")).into()),
3043 }
3044 }
3045 "fetch_conversation_inbox" => {
3050 let result = self.forward(request, context.ct.clone()).await?;
3051 Ok(self.spool_and_confirm(result, context.ct).await.into())
3052 }
3053 "wait_for_updates" => Ok(self.long_wait(request, context.ct).await?.into()),
3061 "resume_session" => {
3065 if request
3066 .arguments
3067 .as_ref()
3068 .and_then(|a| a.get("ttl_seconds"))
3069 .is_some_and(|v| !v.is_null())
3070 {
3071 return Ok(tool_error(RESUME_TTL_REFUSED.to_owned()).into());
3072 }
3073 match self.resume_in_place().await {
3074 Some(Ok(value)) => Ok(CallToolResult::structured(value).into()),
3075 Some(Err(said)) => Ok(tool_error(said).into()),
3076 None => Ok(self.forward(request, context.ct).await?.into()),
3077 }
3078 }
3079 "post_message" => {
3080 let mut request = request;
3081 if let Some(channel) = self.default_channel().await {
3082 let args = request.arguments.get_or_insert_with(Default::default);
3083 let addressed = args.contains_key("channel") || args.contains_key("to");
3084 if !addressed {
3085 args.insert("channel".into(), Value::String(channel));
3086 }
3087 }
3088 Ok(self.forward(request, context.ct).await?.into())
3089 }
3090 _ => Ok(self.forward(request, context.ct).await?.into()),
3091 }
3092 }
3093}
3094
3095pub async fn run(opts: ProxyOptions) -> anyhow::Result<()> {
3097 let proxy = Proxy::start(opts).await;
3098 let ct = CancellationToken::new();
3099 let keepalive = tokio::spawn(proxy.clone().keepalive(ct.child_token()));
3100
3101 let running = proxy
3102 .clone()
3103 .serve(rmcp::transport::stdio())
3104 .await
3105 .context("MCP initialize over stdio failed")?;
3106 let quit = running.waiting().await;
3107 tracing::debug!(?quit, "host closed the connection");
3108
3109 ct.cancel();
3110 let _ = keepalive.await;
3111 proxy.shutdown().await;
3112 Ok(())
3113}
3114
3115#[cfg(test)]
3116mod renewal_tests {
3117 #[test]
3118 fn renewal_lead_is_half_the_lifetime_unless_overridden_and_inside_it() {
3119 assert_eq!(super::renewal_lead_secs(24 * 3600, None), 12 * 3600);
3120 assert_eq!(super::renewal_lead_secs(60, None), 30);
3121 assert_eq!(super::renewal_lead_secs(60, Some(50)), 50);
3122 assert_eq!(
3123 super::renewal_lead_secs(60, Some(600)),
3124 59,
3125 "never past the lifetime"
3126 );
3127 assert_eq!(
3128 super::renewal_lead_secs(1, None),
3129 1,
3130 "a degenerate lifetime still yields a lead"
3131 );
3132 }
3133}
3134
3135#[cfg(test)]
3136mod unauthorized_tests {
3137 use rmcp::{
3138 RoleClient,
3139 model::{ErrorCode, ErrorData},
3140 transport::{
3141 DynamicTransportError, StreamableHttpClientTransport,
3142 streamable_http_client::{AuthRequiredError, StreamableHttpError},
3143 },
3144 };
3145
3146 use super::*;
3147
3148 fn transport(e: StreamableHttpError<reqwest::Error>) -> ServiceError {
3149 ServiceError::TransportSend(DynamicTransportError::new::<
3150 StreamableHttpClientTransport<reqwest::Client>,
3151 RoleClient,
3152 >(e))
3153 }
3154
3155 #[test]
3156 fn a_rejected_bearer_is_the_transport_saying_so() {
3157 let e = transport(StreamableHttpError::AuthRequired(AuthRequiredError::new(
3158 "Bearer".into(),
3159 )));
3160 assert!(unauthorized(&e));
3161 }
3162
3163 #[test]
3164 fn a_refusal_that_spells_401_is_still_a_refusal() {
3165 let e = ServiceError::McpError(ErrorData::invalid_request(
3168 "you do not hold the claim on 'api#1': it is held by joaquin (session \
3169 's-a7da401d8d70'), the lease expires in 401s",
3170 None,
3171 ));
3172 assert!(!unauthorized(&e));
3173 let e = ServiceError::McpError(ErrorData::internal_error("Auth required", None));
3174 assert!(
3175 !unauthorized(&e),
3176 "not even when it borrows the transport's words"
3177 );
3178 }
3179
3180 #[test]
3181 fn a_missing_tool_is_the_code_saying_so() {
3182 let e = ServiceError::McpError(ErrorData::invalid_params("tool not found", None));
3184 assert!(no_such_tool(&e));
3185 assert_eq!(verdict(&e), Some(Verdict::NoSuchTool));
3186 let e = ServiceError::McpError(ErrorData::new(
3188 ErrorCode::METHOD_NOT_FOUND,
3189 "Method not found",
3190 None,
3191 ));
3192 assert!(no_such_tool(&e));
3193 }
3194
3195 #[test]
3196 fn a_refusal_that_spells_a_missing_method_is_still_a_refusal() {
3197 let e = ServiceError::McpError(ErrorData::invalid_params(
3200 "conflict: session 's-32601f03e877' is already registered and still live. \
3201 Holding the agent token does not make you that window: Method aside, \
3202 reconnect it with resume_session",
3203 None,
3204 ));
3205 assert!(!no_such_tool(&e));
3206 assert_eq!(verdict(&e), None);
3207 let e = ServiceError::McpError(ErrorData::invalid_params("not found: message 32601", None));
3209 assert!(!no_such_tool(&e));
3210 let e = ServiceError::McpError(ErrorData::invalid_params(
3213 "tool not found: the deploy tool named in `depends_on` does not exist",
3214 None,
3215 ));
3216 assert!(!no_such_tool(&e));
3217 assert!(!no_such_tool(&ServiceError::TransportClosed));
3218 }
3219
3220 #[test]
3221 fn a_verdict_survives_the_anyhow_chain() {
3222 let e = anyhow::Error::new(Verdict::NoSuchTool).context("register_session failed");
3223 assert_eq!(verdict_of(&e), Some(Verdict::NoSuchTool));
3224 let e = anyhow::anyhow!("register_session failed: tool not found -32601 Method");
3225 assert_eq!(verdict_of(&e), None, "words are not a verdict");
3226 }
3227
3228 #[test]
3229 fn an_http_refusal_is_read_for_its_shape() {
3230 let answered = |msg: &str| {
3231 transport(StreamableHttpError::UnexpectedServerResponse(
3232 msg.to_owned().into(),
3233 ))
3234 };
3235 let e = answered(r#"HTTP 429 Too Many Requests: {"error":"rate limit exceeded"}"#);
3237 assert_eq!(
3238 refusal(&e),
3239 Some(Refusal {
3240 status: 429,
3241 said: Some("rate limit exceeded".into())
3242 })
3243 );
3244 assert!(remote_error_text(&e).contains("rate limit exceeded"));
3245 assert!(!unauthorized(&e));
3246 let e = answered("HTTP 404 Not Found: <html>nope</html>");
3248 assert_eq!(
3249 refusal(&e),
3250 Some(Refusal {
3251 status: 404,
3252 said: None
3253 })
3254 );
3255 assert_eq!(refusal(&answered("HTTP 504 Gateway Timeout: ")), None);
3257 assert_eq!(
3258 refusal(&answered("invalid www-authenticate header value")),
3259 None
3260 );
3261 assert_eq!(refusal(&ServiceError::TransportClosed), None);
3262 }
3263
3264 #[test]
3265 fn another_transport_failure_is_not_a_rejected_bearer() {
3266 let e = transport(StreamableHttpError::UnexpectedContentType(Some(
3267 "text/html; 401".into(),
3268 )));
3269 assert!(!unauthorized(&e));
3270 assert!(!unauthorized(&ServiceError::TransportClosed));
3271 }
3272}
3273
3274#[cfg(test)]
3275mod attention_tests {
3276 use super::{ATTENTION_REPORT_EVERY, Attention, AttentionReport};
3277 use std::time::{Duration, Instant};
3278
3279 fn ago(d: Duration) -> Instant {
3280 Instant::now() - d
3281 }
3282
3283 #[test]
3284 fn nothing_seen_reports_nothing_even_on_a_refresh() {
3285 let mut a = Attention::default();
3286 assert_eq!(a.due(true), None, "a keepalive is not news about the model");
3287 }
3288
3289 #[test]
3290 fn a_call_reports_attending_and_a_burst_is_throttled() {
3291 let call = Instant::now();
3292 let mut a = Attention {
3293 calls: 1,
3294 unreported_call: Some(call),
3295 ..Attention::default()
3296 };
3297 let r = a.due(false).expect("first call");
3298 assert_eq!(r.state, "attending");
3299 a.sent(r);
3300 a.unreported_call = Some(Instant::now());
3301 assert_eq!(a.due(false), None, "same state inside the window");
3302 a.reported = Some(("attending", ago(ATTENTION_REPORT_EVERY)));
3303 assert_eq!(a.due(false).map(|r| r.state), Some("attending"));
3304 }
3305
3306 #[test]
3307 fn a_keepalive_never_sends_a_stale_call() {
3308 let mut a = Attention {
3311 unreported_call: Some(ago(Duration::from_secs(120))),
3312 reported: Some(("attending", ago(Duration::from_secs(130)))),
3313 ..Attention::default()
3314 };
3315 assert_eq!(a.due(true), None);
3316 assert_eq!(a.unreported_call, None, "the stale call is dropped");
3317 }
3318
3319 #[test]
3320 fn a_failed_send_is_retried() {
3321 let mut a = Attention {
3322 calls: 1,
3323 unreported_call: Some(Instant::now()),
3324 ..Attention::default()
3325 };
3326 let r = a.due(false).expect("due");
3327 assert_eq!(a.due(false), Some(r));
3329 a.sent(r);
3330 assert_eq!(a.due(false), None);
3331 }
3332
3333 #[test]
3334 fn a_wait_reports_waiting_and_a_refresh_keeps_it_fresh() {
3335 let mut a = Attention {
3336 calls: 1,
3337 waits: 1,
3338 unreported_call: Some(Instant::now()),
3339 ..Attention::default()
3340 };
3341 let r = a.due(false).expect("wait");
3342 assert_eq!(r.state, "waiting");
3343 a.sent(r);
3344 assert_eq!(a.due(false), None);
3345 assert_eq!(
3346 a.due(true),
3347 Some(AttentionReport {
3348 state: "waiting",
3349 call: None
3350 })
3351 );
3352 a.waits = 0;
3354 a.unreported_call = Some(Instant::now());
3355 assert_eq!(a.due(false).map(|r| r.state), Some("attending"));
3356 }
3357}