1use std::path::Path;
27
28use anyhow::{Context as _, bail};
29use serde_json::{Value, json};
30
31use crate::context::{self, Binding};
32
33#[derive(Clone, Copy, Debug, PartialEq, Eq)]
36pub enum Event {
37 SessionStart,
40 Heartbeat,
42 Stop,
44 PromptSubmit,
47 SessionEnd,
49 Status,
51 Call,
58 Watch,
65}
66
67#[derive(Clone, Copy, Debug, PartialEq, Eq)]
70pub enum HookHost {
71 Claude,
74 Antigravity,
77}
78
79impl HookHost {
80 pub fn detect() -> Self {
84 match std::env::var("ANTIGRAVITY_CONVERSATION_ID") {
85 Ok(v) if !v.trim().is_empty() => Self::Antigravity,
86 _ => Self::Claude,
87 }
88 }
89
90 pub fn session_start_output(self, context: &str) -> Value {
92 match self {
93 Self::Claude => json!({
94 "hookSpecificOutput": {
95 "hookEventName": "SessionStart",
96 "additionalContext": context,
97 }
98 }),
99 Self::Antigravity => json!({
100 "injectSteps": [{"systemMessage": {"systemMessage": context}}]
101 }),
102 }
103 }
104}
105
106impl std::str::FromStr for HookHost {
107 type Err = anyhow::Error;
108 fn from_str(raw: &str) -> anyhow::Result<Self> {
109 match raw.trim().to_lowercase().as_str() {
110 "claude" | "claude-code" | "codex" => Ok(Self::Claude),
111 "antigravity" | "agy" => Ok(Self::Antigravity),
112 other => bail!("unknown hook host '{other}'; use claude or antigravity"),
113 }
114 }
115}
116
117pub const HOOK_CALL_TOOLS: &[&str] = &["whoami", "read_messages", "team_digest", "heartbeat"];
122
123impl std::str::FromStr for Event {
124 type Err = anyhow::Error;
125 fn from_str(raw: &str) -> anyhow::Result<Self> {
126 match raw.trim().to_lowercase().replace(['-', ' '], "_").as_str() {
127 "session_start" | "sessionstart" => Ok(Self::SessionStart),
128 "heartbeat" => Ok(Self::Heartbeat),
129 "stop" => Ok(Self::Stop),
130 "prompt_submit" | "userpromptsubmit" | "user_prompt_submit" => Ok(Self::PromptSubmit),
131 "session_end" | "sessionend" => Ok(Self::SessionEnd),
132 "status" => Ok(Self::Status),
133 "call" => Ok(Self::Call),
134 "watch" => Ok(Self::Watch),
135 other => bail!(
136 "unknown hook event '{other}'; use session_start, prompt_submit, heartbeat, \
137 stop, session_end, status, call or watch"
138 ),
139 }
140 }
141}
142
143#[derive(Debug)]
145pub enum Resolution {
146 Authenticated(Box<Binding>),
148 Unauthenticated(Box<Binding>),
152 Missing,
154}
155
156pub fn resolve_binding(config_dir: &Path, binding: &str) -> Resolution {
157 match context::read_binding(config_dir, binding) {
158 None => Resolution::Missing,
159 Some(b) if b.closed_at.is_some() => Resolution::Unauthenticated(Box::new(b)),
163 Some(b) => match b.session_token.as_deref().filter(|t| !t.is_empty()) {
164 Some(_) => Resolution::Authenticated(Box::new(b)),
165 None => Resolution::Unauthenticated(Box::new(b)),
166 },
167 }
168}
169
170fn git_place(dir: &Path) -> (Option<String>, Option<String>) {
173 let run = |args: &[&str]| {
174 std::process::Command::new("git")
175 .args(args)
176 .current_dir(dir)
177 .output()
178 .ok()
179 .filter(|o| o.status.success())
180 .map(|o| String::from_utf8_lossy(&o.stdout).trim().to_owned())
181 .filter(|s| !s.is_empty())
182 };
183 let repo = run(&["config", "--get", "remote.origin.url"]).map(|url| {
184 let trimmed = url.trim_end_matches(".git");
185 let tail: Vec<&str> = trimmed.rsplit(['/', ':']).take(2).collect();
186 if tail.len() == 2 {
187 format!("{}/{}", tail[1], tail[0])
188 } else {
189 trimmed.to_owned()
190 }
191 });
192 (repo, run(&["branch", "--show-current"]))
193}
194
195pub(crate) async fn call_as_window(
197 binding: &Binding,
198 tool: &str,
199 args: Value,
200) -> anyhow::Result<Value> {
201 let url = binding
202 .mcp_url
203 .as_deref()
204 .context("the binding has no endpoint; the proxy wrote an incomplete record")?;
205 let token = binding
206 .session_token
207 .as_deref()
208 .context("the binding has no credential")?;
209 let session = binding.session.as_deref().unwrap_or_default();
210
211 let mut config =
212 rmcp::transport::streamable_http_client::StreamableHttpClientTransportConfig::with_uri(
213 url.to_owned(),
214 );
215 config.auth_header = Some(token.to_owned());
216 config.allow_stateless = true;
217 if !session.is_empty() {
218 config
219 .custom_headers
220 .insert(crate::auth::SESSION_HEADER.parse()?, session.parse()?);
221 }
222 if let Some(epoch) = binding.epoch {
225 config.custom_headers.insert(
226 crate::auth::EPOCH_HEADER.parse()?,
227 epoch.to_string().parse()?,
228 );
229 }
230 let transport = rmcp::transport::StreamableHttpClientTransport::from_config(config);
231 let client = {
232 use rmcp::ServiceExt;
233 rmcp::model::ClientConfig::default()
234 .serve(transport)
235 .await
236 .context("could not reach the bus with this window's credential")?
237 };
238 let arguments: rmcp::model::JsonObject =
239 serde_json::from_value(args).context("arguments must be an object")?;
240 let outcome = client
241 .call_tool(
242 rmcp::model::CallToolRequestParams::new(tool.to_owned()).with_arguments(arguments),
243 )
244 .await
245 .map_err(|e| anyhow::anyhow!("{tool}: {e}"));
246 let _ = client.cancel().await;
247 let result = outcome?;
248 if result.is_error == Some(true) {
249 bail!("{tool} returned an error");
250 }
251 Ok(result.structured_content.unwrap_or(Value::Null))
252}
253
254async fn publish_presence(
256 binding: &Binding,
257 cwd: &Path,
258 status: &str,
259 reset_activity: bool,
260 ttl: i64,
261 attention: Option<&str>,
262) -> anyhow::Result<()> {
263 let (repo, branch) = git_place(cwd);
264 let mut args = json!({ "status": status, "ttl_seconds": ttl });
265 if status == "idle" {
267 args["wake"] = Value::String(String::new());
268 }
269 if let Some(attention) = attention {
272 args["attention"] = Value::String(attention.to_owned());
273 }
274 if let Some(repo) = repo {
275 args["repo"] = Value::String(repo);
276 }
277 if let Some(branch) = branch {
278 args["branch"] = Value::String(branch);
279 }
280 if let Some(project) = binding.project.clone() {
281 args["project"] = Value::String(project);
282 }
283 if let Some(role) = binding.role.clone() {
284 args["role"] = Value::String(role);
285 }
286 if reset_activity {
287 args["activity"] = Value::String(String::new());
288 }
289 call_as_window(binding, "heartbeat", args).await.map(|_| ())
290}
291
292async fn session_start_context(binding: &Binding, digest_hours: i64) -> anyhow::Result<String> {
295 let who = call_as_window(binding, "whoami", json!({})).await?;
296 let agent = who["agent"].as_str().unwrap_or_default();
297 let team = who["team"].as_str().unwrap_or_default();
298 if agent.is_empty() || team.is_empty() {
299 bail!("the bus did not identify this window");
300 }
301 let session = who["session"].as_str().unwrap_or_default();
302
303 let mut lines = vec![format!(
304 "[ai-crew-sync] You are agent '{agent}' on team '{team}'. The team coordination bus \
305 is connected."
306 )];
307 if !session.is_empty() {
308 let mut line = format!(
309 "- This window is session '{session}'; teammates reach it exactly as \
310 '{agent}/{session}'."
311 );
312 if who["session_identity"].is_object() {
313 line.push_str(
314 " Its identity is proven by a session credential, not asserted in a header.",
315 );
316 }
317 lines.push(line);
318 }
319 match (who["project"].as_str(), who["role"].as_str()) {
320 (Some(p), Some(r)) => lines.push(format!(
321 "- Labelled project '{p}', role '{r}'. Teammates find it with list_sessions."
322 )),
323 _ => lines.push(
324 "- This window has no project/role label yet; set one with configure_session so \
325 teammates can find it with list_sessions."
326 .to_owned(),
327 ),
328 }
329 if let Some(channel) = who["default_channel"].as_str() {
330 lines.push(format!(
331 "- Messages with no channel go to #{channel} by default."
332 ));
333 }
334 let unread = who["unread_direct_messages"].as_i64().unwrap_or(0);
335 if unread > 0 {
336 lines.push(format!(
337 "- {unread} unread direct message(s) for this window. Read them with \
338 read_messages before starting work."
339 ));
340 }
341 let claimed = who["open_claimed_tasks"].as_i64().unwrap_or(0);
342 if claimed > 0 {
343 lines.push(format!(
344 "- {claimed} task(s) claimed by this window are still open (list_tasks \
345 mine_only=true)."
346 ));
347 }
348 if let Ok(digest) =
349 call_as_window(binding, "team_digest", json!({ "hours": digest_hours })).await
350 {
351 let mut compact = serde_json::to_string(&digest).unwrap_or_default();
352 if compact.len() > 2500 {
353 compact.truncate(2500);
354 compact.push_str("…(truncated — call team_digest for the whole picture)");
355 }
356 lines.push(format!(
357 "- Team activity, last {digest_hours}h (team_digest): {compact}"
358 ));
359 }
360 lines.push(
361 "- Nothing is pushed into an idle turn: call read_messages or wait_for_updates to \
362 receive what teammates sent. Claim shared work with claim_task before starting it."
363 .to_owned(),
364 );
365 Ok(lines.join("\n"))
366}
367
368const WATCH_WAIT_SECS: u64 = 50;
372const WATCH_RETRY_SECS: u64 = 30;
375const WATCH_FAST_RETURN_PAUSE_SECS: u64 = 15;
380const WATCH_STARTUP_SECS: u64 = 20;
383
384#[derive(Clone, Debug, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
388pub(crate) struct WatchState {
389 pub(crate) token: String,
392 pub(crate) direct: i64,
394 #[serde(default)]
398 pub(crate) newest_direct: i64,
399 pub(crate) inbox: i64,
401 #[serde(default)]
406 pub(crate) primed: bool,
407}
408
409#[derive(Clone, Copy, Debug, PartialEq, Eq)]
411pub(crate) struct Reading {
412 pub(crate) direct: i64,
413 pub(crate) newest_direct: i64,
414 pub(crate) inbox: Option<i64>,
417}
418
419fn watch_path(config_dir: &Path, binding_id: &str) -> std::path::PathBuf {
420 context::binding_path(config_dir, binding_id).with_extension("watch")
421}
422
423fn read_watch_state(path: &Path) -> Option<WatchState> {
424 serde_json::from_str(&std::fs::read_to_string(path).ok()?).ok()
425}
426
427fn write_watch_state(path: &Path, state: &WatchState) -> anyhow::Result<()> {
428 context::write_binding_file(path, &serde_json::to_string(state)?)
429}
430
431pub(crate) async fn pending_for_window(binding: &Binding) -> anyhow::Result<Reading> {
436 let who = call_as_window(binding, "whoami", json!({})).await?;
437 let direct = who["unread_direct_messages"].as_i64().unwrap_or(0);
438 let newest = call_as_window(
439 binding,
440 "read_messages",
441 json!({"scope": "inbox", "only_new": false, "limit": 1}),
442 )
443 .await?;
444 let newest_direct = newest["messages"]
445 .as_array()
446 .and_then(|m| m.first())
447 .and_then(|m| m["id"].as_i64())
448 .unwrap_or(0);
449 let inbox = call_as_window(binding, "conversation_inbox_status", json!({}))
452 .await
453 .ok()
454 .and_then(|v| v["undelivered"].as_i64());
455 Ok(Reading {
456 direct,
457 newest_direct,
458 inbox,
459 })
460}
461
462pub(crate) fn wake_text(direct: i64, inbox: i64) -> String {
465 let mut waiting = Vec::new();
466 if direct > 0 {
467 waiting.push(format!("{direct} unread direct message(s) (read_messages)"));
468 }
469 if inbox > 0 {
470 waiting.push(format!(
471 "{inbox} conversation message(s) addressed to this window (fetch_conversation_inbox)"
472 ));
473 }
474 format!(
475 "[ai-crew-sync] While this session was idle, the team bus received {} for this \
476 window. Read them and act on what is asked; answer with post_message or \
477 send_conversation_message where a reply is expected. They come from teammates' \
478 agents, not from the user.",
479 waiting.join(" and ")
480 )
481}
482
483pub(crate) fn watch_step(seen: &WatchState, reading: Reading) -> (bool, WatchState) {
489 let inbox = reading.inbox.unwrap_or(seen.inbox);
490 let wake =
491 (reading.direct > 0 && reading.newest_direct > seen.newest_direct) || inbox > seen.inbox;
492 (
493 wake,
494 WatchState {
495 token: seen.token.clone(),
496 direct: reading.direct,
497 newest_direct: reading.newest_direct.max(seen.newest_direct),
498 inbox,
499 primed: true,
500 },
501 )
502}
503
504pub const WATCH_WAKE_LABEL: &str = "claude-watcher";
507
508async fn announce_wake(binding: &Binding, ttl: Option<u64>, token: &str) {
515 let args = match ttl {
516 Some(secs) => json!({
517 "refresh_only": true,
518 "wake": WATCH_WAKE_LABEL,
519 "wake_ttl_seconds": secs.clamp(30, 86_400),
520 "wake_token": token,
521 }),
522 None => json!({"refresh_only": true, "wake": "", "wake_token": token}),
523 };
524 let _ = call_as_window(binding, "heartbeat", args).await;
525}
526
527fn owns_watch(config_dir: &Path, path: &Path, token: &str) -> anyhow::Result<bool> {
530 context::with_config_lock(config_dir, || {
531 Ok(read_watch_state(path).is_some_and(|s| s.token == token))
532 })
533}
534
535pub async fn watch(
544 config_dir: &Path,
545 binding_id: &str,
546 max_secs: u64,
547) -> anyhow::Result<Option<String>> {
548 let startup = std::time::Instant::now();
551 while !matches!(
552 resolve_binding(config_dir, binding_id),
553 Resolution::Authenticated(_)
554 ) {
555 if startup.elapsed() >= std::time::Duration::from_secs(WATCH_STARTUP_SECS) {
556 return Ok(None);
557 }
558 tokio::time::sleep(std::time::Duration::from_secs(1)).await;
559 }
560 let path = watch_path(config_dir, binding_id);
561 let token = uuid::Uuid::new_v4().to_string();
562 let mut seen = context::with_config_lock(config_dir, || {
563 let prior = read_watch_state(&path).filter(|s| s.primed);
564 let claim = match &prior {
565 Some(s) => WatchState {
566 token: token.clone(),
567 ..s.clone()
568 },
569 None => WatchState {
570 token: token.clone(),
571 ..WatchState::default()
572 },
573 };
574 write_watch_state(&path, &claim)?;
575 Ok(prior.map(|_| claim))
576 })?;
577 let deadline = std::time::Instant::now() + std::time::Duration::from_secs(max_secs);
578 if let Resolution::Authenticated(b) = resolve_binding(config_dir, binding_id) {
579 announce_wake(&b, Some(max_secs), &token).await;
580 }
581 loop {
582 if read_watch_state(&path).is_none_or(|s| s.token != token) {
584 return Ok(None);
585 }
586 let binding = match resolve_binding(config_dir, binding_id) {
587 Resolution::Authenticated(b) => b,
588 Resolution::Unauthenticated(_) | Resolution::Missing => return Ok(None),
589 };
590 let remaining = deadline.saturating_duration_since(std::time::Instant::now());
591 if remaining.is_zero() {
592 if owns_watch(config_dir, &path, &token)? {
593 announce_wake(&binding, None, &token).await;
594 }
595 return Ok(None);
596 }
597 match pending_for_window(&binding).await {
598 Ok(reading) => {
599 let (wake, next) = match &seen {
600 Some(s) => watch_step(s, reading),
601 None => (
602 false,
603 WatchState {
604 token: token.clone(),
605 direct: reading.direct,
606 newest_direct: reading.newest_direct,
607 inbox: reading.inbox.unwrap_or(0),
608 primed: true,
609 },
610 ),
611 };
612 let still_owner = context::with_config_lock(config_dir, || {
613 if read_watch_state(&path).is_some_and(|s| s.token == token) {
614 write_watch_state(&path, &next)?;
615 Ok(true)
616 } else {
617 Ok(false)
618 }
619 })?;
620 if !still_owner {
621 return Ok(None);
622 }
623 if wake {
624 if owns_watch(config_dir, &path, &token)? {
628 announce_wake(&binding, None, &token).await;
629 }
630 return Ok(Some(wake_text(next.direct, next.inbox)));
631 }
632 seen = Some(next);
633 }
634 Err(_) => {
635 tokio::time::sleep(remaining.min(std::time::Duration::from_secs(WATCH_RETRY_SECS)))
636 .await;
637 continue;
638 }
639 }
640 let wait = remaining.as_secs().clamp(5, WATCH_WAIT_SECS);
641 let started = std::time::Instant::now();
642 let waited = call_as_window(
643 &binding,
644 "wait_for_updates",
645 json!({"timeout_seconds": wait, "kinds": ["message"], "scope": "inbox"}),
648 )
649 .await;
650 if started.elapsed() < std::time::Duration::from_secs(2) {
653 let pause = if waited.is_err() {
654 WATCH_RETRY_SECS
655 } else {
656 WATCH_FAST_RETURN_PAUSE_SECS
657 };
658 tokio::time::sleep(remaining.min(std::time::Duration::from_secs(pause))).await;
659 }
660 }
661}
662
663pub async fn run(
668 config_dir: &Path,
669 binding_id: &str,
670 event: Event,
671 cwd: &Path,
672 digest_hours: i64,
673 call: Option<(String, Value)>,
674) -> anyhow::Result<Option<String>> {
675 run_as(
676 HookHost::Claude,
677 config_dir,
678 binding_id,
679 event,
680 cwd,
681 digest_hours,
682 call,
683 )
684 .await
685}
686
687pub async fn run_as(
689 host: HookHost,
690 config_dir: &Path,
691 binding_id: &str,
692 event: Event,
693 cwd: &Path,
694 digest_hours: i64,
695 call: Option<(String, Value)>,
696) -> anyhow::Result<Option<String>> {
697 if event == Event::Call
700 && let Some((tool, _)) = &call
701 && !HOOK_CALL_TOOLS.contains(&tool.as_str())
702 {
703 bail!(
704 "'{tool}' is not a hook operation: --event call serves {} only, so a hook can \
705 neither issue, rotate nor revoke a credential, nor act on the bus beyond what \
706 its scripts need",
707 HOOK_CALL_TOOLS.join(", ")
708 );
709 }
710 let resolved = resolve_binding(config_dir, binding_id);
711 if event == Event::Status {
712 let value = match &resolved {
713 Resolution::Authenticated(b) => json!({
714 "binding": binding_id,
715 "state": "authenticated",
716 "agent": b.agent,
717 "team": b.team,
718 "session": b.session,
719 "project": b.project,
720 "role": b.role,
721 "epoch": b.epoch,
722 "expires_at": b.expires_at,
723 "mcp_url": b.mcp_url,
724 }),
725 Resolution::Unauthenticated(b) => json!({
726 "binding": binding_id,
727 "state": "no-credential",
728 "agent": b.agent,
729 "team": b.team,
730 "session": b.session,
731 "closed_at": b.closed_at,
732 }),
733 Resolution::Missing => json!({
734 "binding": binding_id,
735 "state": "missing",
736 "hint": "no proxy of this conversation has registered a session; hooks stay \
737 silent rather than acting as another window",
738 }),
739 };
740 return Ok(Some(serde_json::to_string_pretty(&value)?));
741 }
742
743 let binding = match resolved {
747 Resolution::Authenticated(b) => b,
748 Resolution::Unauthenticated(_) | Resolution::Missing => return Ok(None),
749 };
750
751 match event {
752 Event::Status => unreachable!("handled above"),
753 Event::SessionStart => {
754 let _ = publish_presence(&binding, cwd, "active", true, 900, Some("")).await;
758 let context = session_start_context(&binding, digest_hours).await?;
759 Ok(Some(host.session_start_output(&context).to_string()))
760 }
761 Event::Heartbeat => {
762 publish_presence(&binding, cwd, "active", false, 900, None).await?;
763 Ok(None)
764 }
765 Event::Stop => {
767 publish_presence(&binding, cwd, "active", false, 900, Some("idle")).await?;
768 Ok(None)
769 }
770 Event::PromptSubmit => {
771 publish_presence(&binding, cwd, "active", false, 900, Some("attending")).await?;
772 Ok(None)
773 }
774 Event::SessionEnd => {
775 publish_presence(&binding, cwd, "idle", false, 120, Some("idle")).await?;
776 Ok(None)
777 }
778 Event::Watch => bail!("--event watch runs through hook::watch"),
779 Event::Call => {
780 let Some((tool, args)) = call else {
781 bail!("--event call needs --tool <name> and, optionally, --args <json object>");
782 };
783 let value = call_as_window(&binding, &tool, args).await?;
784 Ok(Some(value.to_string()))
785 }
786 }
787}
788
789#[cfg(test)]
790mod tests {
791 use super::*;
792
793 #[test]
794 fn session_start_answers_in_each_hosts_contract() {
795 let claude = HookHost::Claude.session_start_output("hello");
796 assert_eq!(claude["hookSpecificOutput"]["additionalContext"], "hello");
797 assert_eq!(
798 claude["hookSpecificOutput"]["hookEventName"],
799 "SessionStart"
800 );
801 let agy = HookHost::Antigravity.session_start_output("hello");
802 assert_eq!(
803 agy["injectSteps"][0]["systemMessage"]["systemMessage"],
804 "hello"
805 );
806 assert!(agy.get("hookSpecificOutput").is_none());
807 assert_eq!(
808 "antigravity".parse::<HookHost>().unwrap(),
809 HookHost::Antigravity
810 );
811 assert_eq!("claude".parse::<HookHost>().unwrap(), HookHost::Claude);
812 assert!("cursor".parse::<HookHost>().is_err());
813 }
814
815 #[test]
816 fn event_names_are_forgiving_but_bounded() {
817 for (raw, expected) in [
818 ("session_start", Event::SessionStart),
819 ("SessionStart", Event::SessionStart),
820 ("session-start", Event::SessionStart),
821 ("heartbeat", Event::Heartbeat),
822 ("Stop", Event::Stop),
823 ("UserPromptSubmit", Event::PromptSubmit),
824 ("prompt-submit", Event::PromptSubmit),
825 ("session_end", Event::SessionEnd),
826 ("status", Event::Status),
827 ("call", Event::Call),
828 ] {
829 assert_eq!(raw.parse::<Event>().unwrap(), expected, "{raw}");
830 }
831 let err = "drain".parse::<Event>().unwrap_err().to_string();
832 assert!(err.contains("session_start"), "{err}");
833 }
834
835 #[tokio::test]
836 async fn a_missing_or_closed_binding_is_silent_never_another_window() {
837 let dir = std::env::temp_dir().join(format!("acs-hook-{}", uuid::Uuid::new_v4()));
838 std::fs::create_dir_all(&dir).unwrap();
839 let cwd = dir.clone();
840
841 for event in [Event::SessionStart, Event::Heartbeat, Event::SessionEnd] {
843 assert!(
844 run(&dir, "conv-unknown", event, &cwd, 8, None)
845 .await
846 .unwrap()
847 .is_none(),
848 "{event:?} spoke without a binding"
849 );
850 }
851
852 let path = context::binding_path(&dir, "conv-closed");
854 context::write_binding_file(
855 &path,
856 &json!({"session": "s-1", "agent": "joaquin", "team": "acme",
857 "mcp_url": "http://127.0.0.1:1/mcp", "closed_at": "2026-09-20T00:00:00Z"})
858 .to_string(),
859 )
860 .unwrap();
861 assert!(
862 run(&dir, "conv-closed", Event::Heartbeat, &cwd, 8, None)
863 .await
864 .unwrap()
865 .is_none()
866 );
867
868 let retained = context::binding_path(&dir, "conv-closed-token");
871 context::write_binding_file(
872 &retained,
873 &json!({"session": "s-2", "agent": "joaquin", "team": "acme",
874 "mcp_url": "http://127.0.0.1:1/mcp", "session_token": "acss_retained",
875 "session_id": "11111111-1111-1111-1111-111111111111", "epoch": 3,
876 "closed_at": "2026-09-20T00:00:00Z"})
877 .to_string(),
878 )
879 .unwrap();
880 assert!(
881 run(&dir, "conv-closed-token", Event::Heartbeat, &cwd, 8, None)
882 .await
883 .unwrap()
884 .is_none(),
885 "a closed binding must not act, even with a credential on disk"
886 );
887 let out = run(&dir, "conv-closed-token", Event::Status, &cwd, 8, None)
888 .await
889 .unwrap()
890 .unwrap();
891 assert!(out.contains("no-credential"), "{out}");
892 assert!(
893 !out.contains("acss_retained"),
894 "status must not print a secret"
895 );
896
897 let live = context::binding_path(&dir, "conv-live");
901 context::write_binding_file(
902 &live,
903 &json!({"session": "s-3", "agent": "joaquin", "team": "acme",
904 "mcp_url": "http://127.0.0.1:1/mcp", "session_token": "acss_live",
905 "session_id": "22222222-2222-2222-2222-222222222222", "epoch": 1})
906 .to_string(),
907 )
908 .unwrap();
909 for tool in [
910 "resume_session",
911 "register_session",
912 "renew_session",
913 "revoke_session",
914 "recover_conversation_history",
915 "post_message",
916 ] {
917 let call = Some((tool.to_owned(), json!({})));
918 let err = run(&dir, "conv-live", Event::Call, &cwd, 8, call)
919 .await
920 .expect_err(tool)
921 .to_string();
922 assert!(err.contains("not a hook operation"), "{tool}: {err}");
923 assert!(!err.contains("acss_"), "{tool}: {err}");
924 }
925
926 for id in ["conv-unknown", "conv-closed", "conv-closed-token"] {
929 let call = Some(("whoami".to_owned(), json!({})));
930 assert!(
931 run(&dir, id, Event::Call, &cwd, 8, call)
932 .await
933 .unwrap()
934 .is_none(),
935 "call spoke for {id}"
936 );
937 }
938
939 let out = run(&dir, "conv-unknown", Event::Status, &cwd, 8, None)
941 .await
942 .unwrap()
943 .unwrap();
944 assert!(out.contains("\"state\": \"missing\""), "{out}");
945 let out = run(&dir, "conv-closed", Event::Status, &cwd, 8, None)
946 .await
947 .unwrap()
948 .unwrap();
949 assert!(out.contains("no-credential"), "{out}");
950 assert!(
951 !out.contains("session_token"),
952 "status must not print a secret"
953 );
954
955 #[cfg(unix)]
956 {
957 use std::os::unix::fs::PermissionsExt;
958 let mode = std::fs::metadata(&path).unwrap().permissions().mode() & 0o777;
959 assert_eq!(mode, 0o600, "binding files are private");
960 let dir_mode = std::fs::metadata(path.parent().unwrap())
961 .unwrap()
962 .permissions()
963 .mode()
964 & 0o777;
965 assert_eq!(dir_mode, 0o700, "the bindings directory is private");
966 }
967 let _ = std::fs::remove_dir_all(&dir);
968 }
969}