1use std::collections::{BTreeMap, BTreeSet, VecDeque};
4use std::path::Path;
5use std::process::Stdio;
6use std::sync::Arc;
7use std::time::{Duration, Instant};
8
9use anyhow::{Context, Result, anyhow, bail};
10use base64::Engine as _;
11use base64::engine::general_purpose::STANDARD as BASE64;
12use tokio::io::{AsyncBufRead, AsyncBufReadExt, AsyncWriteExt, BufReader};
13use tokio::process::{Child, ChildStdin, ChildStdout, Command};
14use tokio::sync::{mpsc, watch};
15
16use crate::targets::{
17 CommandSpec, SSH_RETRY_ATTEMPTS, SshAdmission, SshPermit, is_transport_rejection,
18};
19use mj_core::config::harness_authentication_marker;
20use mj_core::credentials::{
21 CredentialSnapshot, CredentialSyncAction, CredentialSyncHandle, CredentialSyncOutcome,
22 CredentialSyncResult, CredentialSyncTarget, SYNC_INTERVAL, SyncAction, SyncTrigger, enqueue,
23 profiles_with_targets, read_credential_file, reconcile, validate_credential_payload,
24 write_credential_file,
25};
26use mj_core::elicitation::ElicitationResponse;
27use mj_core::relay::{
28 MAX_FRAME_BYTES, RELAY_EVENT_GENESIS_DIGEST, RELAY_MIN_PROTOCOL_VERSION,
29 RELAY_PROTOCOL_VERSION, RelayCommand, RelayCursor, RelayErrorCode, RelayEvent,
30 RelayOperationalState, RelayProtocolError, RelayRequest, RelayRequestEnvelope,
31 RelayResponseBody, RelayResponseEnvelope, RelayResponsePayload, RelayVersionRange,
32 ReviewerRequest, validate_relay_event,
33};
34
35pub use mj_client::session::{RelayAttachment, StartedReviewer};
36use mj_core::worker_launch::ReviewerLaunchConfig;
37
38const RELAY_RPC_TIMEOUT: Duration = Duration::from_secs(15);
39const RELAY_SLOW_OPERATION_WARNING: Duration = Duration::from_secs(5);
40const RELAY_HANDSHAKE_TIMEOUT: Duration = Duration::from_secs(300);
44const RELAY_HISTORY_TIMEOUT: Duration = Duration::from_secs(900);
48const RELAY_ACKNOWLEDGE_TIMEOUT: Duration = Duration::from_secs(300);
52const REVIEW_CAPTURE_TIMEOUT: Duration = Duration::from_secs(300);
55const REVIEW_ANALYSIS_TIMEOUT: Duration = Duration::from_secs(660);
59const RELAY_PROXY_DETACH_GRACE: Duration = Duration::from_millis(500);
60const RELAY_PROXY_REAP_POLL: Duration = Duration::from_millis(10);
61
62const RELAY_PROXY_STDERR_TAIL: usize = 10;
64
65async fn drain_proxy_stderr(
73 errors: tokio::process::ChildStderr,
74 purpose: String,
75 session_id: String,
76) -> VecDeque<String> {
77 let mut tail: VecDeque<String> = VecDeque::new();
78 let mut lines = BufReader::new(errors).lines();
79 loop {
80 match lines.next_line().await {
81 Ok(Some(line)) if line.trim().is_empty() => continue,
82 Ok(Some(line)) => {
83 tracing::warn!(%session_id, %purpose, %line, "relay proxy stderr");
84 if tail.len() == RELAY_PROXY_STDERR_TAIL {
85 tail.pop_front();
86 }
87 tail.push_back(line);
88 }
89 Ok(None) => return tail,
90 Err(error) => {
91 tracing::warn!(%session_id, %purpose, %error, "read relay proxy stderr");
92 return tail;
93 }
94 }
95 }
96}
97
98#[derive(Debug, Clone)]
102pub struct RelayEventPage {
103 pub events: Vec<RelayEvent>,
104 pub through_ordinal: u64,
105 pub through_digest: String,
106}
107
108#[derive(Debug, Clone)]
109pub struct RelayCatchUp {
110 pub state: RelayOperationalState,
111 pub frontier: RelayCursor,
112 pub first_page: RelayEventPage,
113}
114
115#[derive(Debug)]
116pub struct RelayRejected(pub RelayProtocolError);
117
118impl std::fmt::Display for RelayRejected {
119 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
120 write!(
121 formatter,
122 "relay rejected request ({:?}): {}",
123 self.0.code, self.0.message
124 )
125 }
126}
127
128impl std::error::Error for RelayRejected {}
129
130impl RelayRejected {
131 pub fn is_desynchronized(&self) -> bool {
132 self.0.code == RelayErrorCode::Desynchronized
133 }
134
135 pub fn is_retryable(&self) -> bool {
138 self.0.retryable
139 }
140}
141
142#[derive(Debug)]
149pub struct RelayTransportDead {
150 message: String,
151 handshake_failed: bool,
152}
153
154impl std::fmt::Display for RelayTransportDead {
155 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
156 formatter.write_str(&self.message)
157 }
158}
159
160impl std::error::Error for RelayTransportDead {}
161
162impl RelayTransportDead {
163 pub fn new(message: impl Into<String>) -> Self {
164 Self {
165 message: message.into(),
166 handshake_failed: false,
167 }
168 }
169
170 fn from_io(error: std::io::Error, kind: ExchangeKind) -> Self {
173 Self::during_exchange(error.to_string(), kind)
174 }
175
176 fn during_exchange(message: impl Into<String>, kind: ExchangeKind) -> Self {
177 Self {
178 message: message.into(),
179 handshake_failed: kind == ExchangeKind::Handshake,
180 }
181 }
182
183 pub fn marks(error: &anyhow::Error) -> bool {
185 error.downcast_ref::<Self>().is_some()
186 }
187
188 pub fn marks_failed_handshake(error: &anyhow::Error) -> bool {
193 error
194 .downcast_ref::<Self>()
195 .is_some_and(|failure| failure.handshake_failed)
196 }
197}
198
199#[derive(Clone, Copy, PartialEq, Eq)]
206enum ExchangeKind {
207 Handshake,
208 Call,
209}
210
211struct ConnectFailure {
214 error: anyhow::Error,
215 transport_rejected: bool,
216}
217
218impl ConnectFailure {
219 fn plain(error: anyhow::Error) -> Self {
220 Self {
221 error,
222 transport_rejected: false,
223 }
224 }
225}
226
227impl From<anyhow::Error> for ConnectFailure {
228 fn from(error: anyhow::Error) -> Self {
229 Self::plain(error)
230 }
231}
232
233pub struct RelayClient {
239 child: Option<Child>,
240 input: Option<ChildStdin>,
241 output: BufReader<ChildStdout>,
242 request_timeout: Duration,
243 abandoned: Option<String>,
246 next_request: u64,
247 connection_nonce: u64,
248 protocol_version: u32,
249 session_id: String,
250 relay_version: String,
251 worker_build: Option<String>,
254 latest_ordinal: u64,
255 latest_digest: String,
256}
257
258impl RelayClient {
259 pub async fn connect(spec: &CommandSpec, expected_session_id: &str) -> Result<Self> {
260 Self::connect_with_timeouts(
261 spec,
262 expected_session_id,
263 RELAY_RPC_TIMEOUT,
264 RELAY_HANDSHAKE_TIMEOUT,
265 )
266 .await
267 }
268
269 #[cfg(all(test, unix))]
270 async fn connect_with_timeout(
271 spec: &CommandSpec,
272 expected_session_id: &str,
273 request_timeout: Duration,
274 ) -> Result<Self> {
275 Self::connect_with_timeouts(spec, expected_session_id, request_timeout, request_timeout)
276 .await
277 }
278
279 async fn connect_with_timeouts(
287 spec: &CommandSpec,
288 expected_session_id: &str,
289 request_timeout: Duration,
290 handshake_timeout: Duration,
291 ) -> Result<Self> {
292 for attempt in 1..=SSH_RETRY_ATTEMPTS {
293 let outcome = Self::connect_attempt(
294 spec,
295 expected_session_id,
296 request_timeout,
297 handshake_timeout,
298 )
299 .await;
300 let error = match outcome {
301 Ok(client) => return Ok(client),
302 Err(ConnectFailure {
303 error,
304 transport_rejected,
305 }) => {
306 if attempt == SSH_RETRY_ATTEMPTS || !transport_rejected {
307 return Err(error);
308 }
309 error
310 }
311 };
312 let delay = mj_core::targets::ssh_retry_delay(attempt);
313 tracing::warn!(
314 session_id = %expected_session_id,
315 destination = spec.ssh_destination.as_deref().unwrap_or_default(),
316 purpose = %spec.purpose,
317 attempt,
318 attempts = SSH_RETRY_ATTEMPTS,
319 delay_ms = delay.as_millis() as u64,
320 error = %error,
321 "relay proxy was refused by the SSH server before authentication; retrying"
322 );
323 tokio::time::sleep(delay).await;
324 }
325 unreachable!("the final attempt always returns");
326 }
327
328 async fn connect_attempt(
335 spec: &CommandSpec,
336 expected_session_id: &str,
337 request_timeout: Duration,
338 handshake_timeout: Duration,
339 ) -> std::result::Result<Self, ConnectFailure> {
340 let permit = match spec.ssh_destination.clone() {
341 Some(destination) => {
342 match tokio::task::spawn_blocking(move || SshAdmission::acquire(&destination)).await
343 {
344 Ok(permit) => Some(permit),
345 Err(error) => {
346 return Err(ConnectFailure::plain(anyhow!(
347 "SSH admission for the relay proxy was cancelled: {error}"
348 )));
349 }
350 }
351 }
352 None => None,
353 };
354 Self::spawn_and_handshake(
355 spec,
356 expected_session_id,
357 request_timeout,
358 handshake_timeout,
359 permit,
360 )
361 .await
362 }
363
364 async fn spawn_and_handshake(
365 spec: &CommandSpec,
366 expected_session_id: &str,
367 request_timeout: Duration,
368 handshake_timeout: Duration,
369 permit: Option<SshPermit>,
370 ) -> std::result::Result<Self, ConnectFailure> {
371 let mut child = Command::new(&spec.program)
372 .args(&spec.args)
373 .envs(&spec.env)
374 .stdin(Stdio::piped())
375 .stdout(Stdio::piped())
376 .stderr(Stdio::piped())
380 .kill_on_drop(true)
381 .spawn()
382 .with_context(|| format!("start session relay proxy for {}", spec.purpose))
383 .map_err(|error| {
384 tracing::warn!(
385 session_id = %expected_session_id,
386 operation = "connect",
387 purpose = %spec.purpose,
388 error = %error,
389 "could not start relay proxy"
390 );
391 error
392 })?;
393 let stderr_tail = child.stderr.take().map(|errors| {
394 let purpose = spec.purpose.clone();
395 let session_id = expected_session_id.to_owned();
396 tokio::spawn(drain_proxy_stderr(errors, purpose, session_id))
397 });
398 let input = child
399 .stdin
400 .take()
401 .context("relay proxy stdin unavailable")
402 .map_err(|error| {
403 tracing::warn!(
404 session_id = %expected_session_id,
405 operation = "connect",
406 purpose = %spec.purpose,
407 error = %error,
408 "relay proxy did not provide stdin"
409 );
410 error
411 })?;
412 let output = child
413 .stdout
414 .take()
415 .context("relay proxy stdout unavailable")
416 .map_err(|error| {
417 tracing::warn!(
418 session_id = %expected_session_id,
419 operation = "connect",
420 purpose = %spec.purpose,
421 error = %error,
422 "relay proxy did not provide stdout"
423 );
424 error
425 })?;
426 let mut nonce_bytes = [0_u8; 8];
427 getrandom::fill(&mut nonce_bytes).map_err(|error| {
428 let error = anyhow!("generate relay request nonce: {error}");
429 tracing::warn!(
430 session_id = %expected_session_id,
431 operation = "connect",
432 error = %error,
433 "could not initialize relay request nonce"
434 );
435 error
436 })?;
437 let mut client = Self {
438 child: Some(child),
439 input: Some(input),
440 output: BufReader::new(output),
441 request_timeout,
442 abandoned: None,
443 next_request: 1,
444 connection_nonce: u64::from_le_bytes(nonce_bytes),
445 protocol_version: RELAY_PROTOCOL_VERSION,
446 session_id: expected_session_id.to_owned(),
450 relay_version: String::new(),
451 worker_build: None,
452 latest_ordinal: 0,
453 latest_digest: RELAY_EVENT_GENESIS_DIGEST.to_owned(),
454 };
455 match client
456 .complete_handshake(expected_session_id, handshake_timeout)
457 .await
458 {
459 Ok(()) => {
460 drop(permit);
463 Ok(client)
465 }
466 Err(error) => {
467 let status = match client.child.as_mut() {
471 Some(child) => {
472 match tokio::time::timeout(RELAY_PROXY_DETACH_GRACE, child.wait()).await {
473 Ok(Ok(status)) => status.code(),
474 _ => {
481 let _ = child.start_kill();
482 None
483 }
484 }
485 }
486 None => None,
487 };
488 let tail = Self::proxy_stderr_tail(stderr_tail).await;
489 let transport_rejected = permit.is_some()
490 && status
491 .is_some_and(|status| is_transport_rejection(status, &tail.join("\n")));
492 drop(permit);
493 Err(ConnectFailure {
494 error: Self::attach_proxy_stderr(error, tail),
495 transport_rejected,
496 })
497 }
498 }
499 }
500
501 async fn proxy_stderr_tail(
504 stderr_tail: Option<tokio::task::JoinHandle<VecDeque<String>>>,
505 ) -> Vec<String> {
506 let Some(handle) = stderr_tail else {
507 return Vec::new();
508 };
509 match tokio::time::timeout(RELAY_PROXY_DETACH_GRACE, handle).await {
510 Ok(Ok(lines)) => lines.into(),
511 _ => Vec::new(),
512 }
513 }
514
515 fn attach_proxy_stderr(error: anyhow::Error, lines: Vec<String>) -> anyhow::Error {
519 if lines.is_empty() {
520 return error;
521 }
522 error.context(format!(
523 "relay proxy stderr (last {} lines):\n{}",
524 lines.len(),
525 lines.join("\n")
526 ))
527 }
528
529 async fn complete_handshake(
531 &mut self,
532 expected_session_id: &str,
533 handshake_timeout: Duration,
534 ) -> Result<()> {
535 let response = self
536 .call_hello(
537 RelayRequest::Hello {
538 controller_version: env!("CARGO_PKG_VERSION").to_owned(),
539 supported: RelayVersionRange::CURRENT,
540 },
541 handshake_timeout,
542 )
543 .await?;
544 let RelayResponsePayload::Hello {
545 negotiated,
546 relay_version,
547 session_id,
548 worker_build,
549 } = response
550 else {
551 let error = anyhow!("relay returned an unexpected hello response");
552 log_relay_client_failure(self, "hello", "relay-hello", &error);
553 return Err(error);
554 };
555 if session_id != expected_session_id {
556 let error = anyhow!("relay belongs to session {session_id}, not {expected_session_id}");
557 log_relay_client_failure(self, "hello", "relay-hello", &error);
558 return Err(error);
559 }
560 if !RelayVersionRange::CURRENT.contains(negotiated) {
561 let error = anyhow!(
562 "relay negotiated unsupported protocol {negotiated}; this controller supports {}-{}",
563 RELAY_MIN_PROTOCOL_VERSION,
564 RELAY_PROTOCOL_VERSION
565 );
566 log_relay_client_failure(self, "hello", "relay-hello", &error);
567 return Err(error);
568 }
569 self.protocol_version = negotiated;
570 self.session_id = session_id;
571 self.relay_version = relay_version;
572 self.worker_build = worker_build;
573 Ok(())
574 }
575
576 pub fn session_id(&self) -> &str {
577 &self.session_id
578 }
579
580 pub const fn supports_project_memory_sync(&self) -> bool {
581 self.protocol_version >= 4
582 }
583
584 pub fn relay_version(&self) -> &str {
585 &self.relay_version
586 }
587
588 pub fn worker_build(&self) -> Option<&str> {
592 self.worker_build.as_deref()
593 }
594
595 pub fn protocol_version(&self) -> u32 {
596 self.protocol_version
597 }
598
599 pub fn latest_ordinal(&self) -> u64 {
600 self.latest_ordinal
601 }
602
603 pub fn latest_digest(&self) -> &str {
604 &self.latest_digest
605 }
606
607 pub async fn attach(
608 &mut self,
609 after_ordinal: u64,
610 after_digest: impl Into<String>,
611 ) -> Result<RelayAttachment> {
612 let after_digest = after_digest.into();
613 match self
614 .call_with_timeout(
615 RelayRequest::Attach {
616 after_ordinal,
617 after_digest: after_digest.clone(),
618 },
619 RELAY_HISTORY_TIMEOUT,
620 )
621 .await?
622 {
623 RelayResponsePayload::Attached {
624 state,
625 events,
626 through_ordinal,
627 through_digest,
628 } => {
629 let mut cursor = RelayCursor {
630 ordinal: after_ordinal,
631 digest: after_digest,
632 };
633 for event in &events {
634 validate_relay_event(cursor.ordinal, &cursor.digest, event)
635 .context("verify relay attachment event chain")?;
636 cursor.ordinal = event.ordinal;
637 cursor.digest.clone_from(&event.digest);
638 }
639 if cursor.ordinal != through_ordinal || cursor.digest != through_digest {
640 bail!("relay attachment frontier does not match its event chain");
641 }
642 self.latest_ordinal = state.latest_ordinal;
643 self.latest_digest = state.latest_digest.clone();
644 Ok(RelayAttachment {
645 state,
646 events,
647 through_ordinal,
648 through_digest,
649 })
650 }
651 _ => bail!("relay returned an unexpected attach response"),
652 }
653 }
654
655 pub async fn begin_catch_up(
660 &mut self,
661 after_ordinal: u64,
662 after_digest: impl Into<String>,
663 ) -> Result<RelayCatchUp> {
664 let after_digest = after_digest.into();
665 let first = self.attach(after_ordinal, after_digest.clone()).await?;
666 let frontier = RelayCursor {
667 ordinal: first.state.latest_ordinal,
668 digest: first.state.latest_digest.clone(),
669 };
670 let previous = RelayCursor {
671 ordinal: after_ordinal,
672 digest: after_digest,
673 };
674 let state = first.state.clone();
675 let first_page = clip_catch_up_page(first, &previous, &frontier)?;
676 Ok(RelayCatchUp {
677 state,
678 frontier,
679 first_page,
680 })
681 }
682
683 pub async fn next_catch_up_page(
687 &mut self,
688 previous: &RelayCursor,
689 frontier: &RelayCursor,
690 ) -> Result<RelayEventPage> {
691 if previous.ordinal >= frontier.ordinal {
692 bail!("relay catch-up is already at its fixed frontier");
693 }
694 let attachment = self
695 .attach(previous.ordinal, previous.digest.clone())
696 .await?;
697 clip_catch_up_page(attachment, previous, frontier)
698 }
699
700 pub async fn acknowledge(
701 &mut self,
702 through_ordinal: u64,
703 through_digest: impl Into<String>,
704 ) -> Result<RelayCursor> {
705 match self
706 .call_with_timeout(
707 RelayRequest::Acknowledge {
708 through_ordinal,
709 through_digest: through_digest.into(),
710 },
711 RELAY_ACKNOWLEDGE_TIMEOUT,
712 )
713 .await?
714 {
715 RelayResponsePayload::Acknowledged {
716 through_ordinal,
717 through_digest,
718 } => Ok(RelayCursor {
719 ordinal: through_ordinal,
720 digest: through_digest,
721 }),
722 _ => bail!("relay returned an unexpected acknowledgement response"),
723 }
724 }
725
726 pub async fn status(&mut self) -> Result<RelayOperationalState> {
727 match self.call(RelayRequest::Status).await? {
728 RelayResponsePayload::Status(status) => {
729 self.latest_ordinal = status.latest_ordinal;
730 self.latest_digest = status.latest_digest.clone();
731 Ok(status)
732 }
733 _ => bail!("relay returned an unexpected status response"),
734 }
735 }
736
737 pub async fn credential_state(&mut self) -> Result<CredentialSnapshot> {
740 credential_snapshot(self.call(RelayRequest::CredentialState).await?)
741 }
742
743 pub async fn read_credentials(&mut self) -> Result<Vec<u8>> {
746 match self.call(RelayRequest::ReadCredentials).await? {
747 RelayResponsePayload::Credentials { data } => BASE64
748 .decode(data.as_bytes())
749 .context("decode relay credential payload"),
750 _ => bail!("relay returned an unexpected credential response"),
751 }
752 }
753
754 pub async fn install_credentials(&mut self, bytes: &[u8]) -> Result<CredentialSnapshot> {
757 credential_snapshot(
758 self.call(RelayRequest::InstallCredentials {
759 data: BASE64.encode(bytes),
760 })
761 .await?,
762 )
763 }
764
765 pub async fn github_token_state(
766 &mut self,
767 ) -> Result<mj_core::credentials::GithubTokenSnapshot> {
768 github_token_snapshot(self.call(RelayRequest::GithubTokenState).await?)
769 }
770
771 pub async fn install_github_token(
772 &mut self,
773 token: &str,
774 ) -> Result<mj_core::credentials::GithubTokenSnapshot> {
775 github_token_snapshot(
776 self.call(RelayRequest::InstallGithubToken {
777 data: BASE64.encode(token.as_bytes()),
778 })
779 .await?,
780 )
781 }
782
783 pub async fn remove_github_token(
784 &mut self,
785 ) -> Result<mj_core::credentials::GithubTokenSnapshot> {
786 github_token_snapshot(self.call(RelayRequest::RemoveGithubToken).await?)
787 }
788
789 pub async fn skills_state(&mut self) -> Result<mj_core::skills::SkillsSyncState> {
792 skills_sync_state(self.call(RelayRequest::SkillsState).await?)
793 }
794
795 pub async fn install_prompt_context(&mut self, text: String) -> Result<()> {
798 let request = RelayRequest::InstallPromptContext { text };
799 if !request.supported_at(self.protocol_version) {
800 bail!(
801 "hidden prompt context requires relay protocol {}; this session negotiated {}",
802 request.minimum_protocol(),
803 self.protocol_version
804 );
805 }
806 match self.call(request).await? {
807 RelayResponsePayload::PromptContextInstalled => Ok(()),
808 _ => bail!("relay returned an unexpected prompt-context response"),
809 }
810 }
811
812 pub async fn project_memory_snapshot(
813 &mut self,
814 ) -> Result<(
815 mj_core::project_memory::ProjectMemorySnapshot,
816 mj_core::project_memory::ProjectMemorySnapshot,
817 )> {
818 let request = RelayRequest::ProjectMemorySnapshot;
819 if !request.supported_at(self.protocol_version) {
820 bail!(
821 "project memory synchronization requires relay protocol {}; this session negotiated {}",
822 request.minimum_protocol(),
823 self.protocol_version
824 );
825 }
826 match self.call(request).await? {
827 RelayResponsePayload::ProjectMemorySnapshot { baseline, replica } => {
828 Ok((baseline, replica))
829 }
830 _ => bail!("relay returned an unexpected project-memory response"),
831 }
832 }
833
834 pub async fn install_project_memory_snapshot(
835 &mut self,
836 snapshot: mj_core::project_memory::ProjectMemorySnapshot,
837 ) -> Result<()> {
838 let request = RelayRequest::InstallProjectMemorySnapshot { snapshot };
839 if !request.supported_at(self.protocol_version) {
840 bail!(
841 "project memory synchronization requires relay protocol {}; this session negotiated {}",
842 request.minimum_protocol(),
843 self.protocol_version
844 );
845 }
846 match self.call(request).await? {
847 RelayResponsePayload::ProjectMemorySnapshotInstalled => Ok(()),
848 _ => bail!("relay returned an unexpected project-memory install response"),
849 }
850 }
851
852 pub async fn install_skills(
856 &mut self,
857 archive_bytes: &[u8],
858 ) -> Result<mj_core::skills::SkillsSyncState> {
859 skills_sync_state(
860 self.call(RelayRequest::InstallSkills {
861 data: BASE64.encode(archive_bytes),
862 })
863 .await?,
864 )
865 }
866
867 pub async fn ensure_attachment(
869 &mut self,
870 reference: &mj_core::attachment::AttachmentRef,
871 ) -> Result<()> {
872 anyhow::ensure!(
873 self.protocol_version >= 8,
874 "photo attachments require an updated worker (protocol 8); upgrade the worker and retry"
875 );
876 match self
877 .call(RelayRequest::AttachmentPresent {
878 reference: reference.clone(),
879 })
880 .await?
881 {
882 RelayResponsePayload::AttachmentPresent { present: true } => return Ok(()),
883 RelayResponsePayload::AttachmentPresent { present: false } => {}
884 _ => bail!("unexpected image presence response"),
885 }
886 let store = mj_core::attachment::AttachmentStore::controller(&self.session_id)?;
887 let reference_copy = reference.clone();
888 let bytes = tokio::task::spawn_blocking(move || store.read(&reference_copy))
889 .await
890 .context("image loading task failed")??;
891 match self
892 .call(RelayRequest::InstallAttachment {
893 reference: reference.clone(),
894 data: BASE64.encode(bytes),
895 })
896 .await?
897 {
898 RelayResponsePayload::AttachmentInstalled => Ok(()),
899 _ => bail!("unexpected image upload response"),
900 }
901 }
902
903 pub async fn cache_attachment(
905 &mut self,
906 reference: &mj_core::attachment::AttachmentRef,
907 ) -> Result<()> {
908 let store = mj_core::attachment::AttachmentStore::controller(&self.session_id)?;
909 let local = store.clone();
910 let reference_copy = reference.clone();
911 if tokio::task::spawn_blocking(move || local.contains(&reference_copy))
912 .await
913 .context("image lookup task failed")??
914 {
915 return Ok(());
916 }
917 let RelayResponsePayload::AttachmentData { data } = self
918 .call(RelayRequest::ReadAttachment {
919 reference: reference.clone(),
920 })
921 .await?
922 else {
923 bail!("unexpected image download response")
924 };
925 let reference = reference.clone();
926 tokio::task::spawn_blocking(move || {
927 anyhow::ensure!(
928 data.len() <= mj_core::attachment::MAX_IMAGE_BYTES.div_ceil(3) * 4,
929 "image download is too large"
930 );
931 store.install(&reference, &BASE64.decode(data)?)
932 })
933 .await
934 .context("image caching task failed")?
935 }
936
937 pub async fn submit(
938 &mut self,
939 command_id: impl Into<String>,
940 command: RelayCommand,
941 ) -> Result<u64> {
942 let command_id = command_id.into();
943 if let RelayCommand::Prompt { prompt } = &command {
944 for reference in mj_core::attachment::references(prompt)? {
945 self.ensure_attachment(&reference).await?;
946 }
947 }
948 match self
949 .call(RelayRequest::Submit {
950 command_id: command_id.clone(),
951 command,
952 })
953 .await?
954 {
955 RelayResponsePayload::Accepted {
956 command_id: accepted_id,
957 ordinal,
958 } if accepted_id == command_id => Ok(ordinal),
959 RelayResponsePayload::Accepted {
960 command_id: accepted_id,
961 ..
962 } => bail!("relay accepted command under ID {accepted_id}, expected {command_id}"),
963 _ => bail!("relay returned an unexpected command response"),
964 }
965 }
966
967 pub async fn start_reviewer(
974 &mut self,
975 role: Option<&str>,
976 config: ReviewerLaunchConfig,
977 ) -> Result<StartedReviewer> {
978 let request = self.reviewer_request(
979 role,
980 ReviewerRequest::Start {
981 config: Box::new(config),
982 },
983 )?;
984 match self
985 .call_with_timeout(request, RELAY_HANDSHAKE_TIMEOUT)
986 .await?
987 {
988 RelayResponsePayload::ReviewerStarted {
989 native_session_id,
990 config_options,
991 reused,
992 state,
993 } => Ok(StartedReviewer {
994 native_session_id,
995 config_options,
996 reused,
997 state: *state,
998 }),
999 _ => bail!("relay returned an unexpected reviewer start response"),
1000 }
1001 }
1002
1003 pub async fn attach_reviewer(
1006 &mut self,
1007 role: Option<&str>,
1008 after_ordinal: u64,
1009 after_digest: impl Into<String>,
1010 ) -> Result<RelayAttachment> {
1011 let after_digest = after_digest.into();
1012 let request = self.reviewer_request(
1013 role,
1014 ReviewerRequest::Attach {
1015 after_ordinal,
1016 after_digest: after_digest.clone(),
1017 },
1018 )?;
1019 let payload = self
1020 .call_with_timeout(request, RELAY_HISTORY_TIMEOUT)
1021 .await?;
1022 let RelayResponsePayload::Attached {
1023 state,
1024 events,
1025 through_ordinal,
1026 through_digest,
1027 } = payload
1028 else {
1029 bail!("relay returned an unexpected reviewer attach response");
1030 };
1031 let mut cursor = RelayCursor {
1034 ordinal: after_ordinal,
1035 digest: after_digest,
1036 };
1037 for event in &events {
1038 validate_relay_event(cursor.ordinal, &cursor.digest, event)
1039 .context("verify reviewer attachment event chain")?;
1040 cursor.ordinal = event.ordinal;
1041 cursor.digest.clone_from(&event.digest);
1042 }
1043 if cursor.ordinal != through_ordinal || cursor.digest != through_digest {
1044 bail!("reviewer attachment frontier does not match its event chain");
1045 }
1046 Ok(RelayAttachment {
1047 state,
1048 events,
1049 through_ordinal,
1050 through_digest,
1051 })
1052 }
1053
1054 pub async fn acknowledge_reviewer(
1057 &mut self,
1058 role: Option<&str>,
1059 through_ordinal: u64,
1060 through_digest: impl Into<String>,
1061 ) -> Result<RelayCursor> {
1062 let request = self.reviewer_request(
1063 role,
1064 ReviewerRequest::Acknowledge {
1065 through_ordinal,
1066 through_digest: through_digest.into(),
1067 },
1068 )?;
1069 match self
1070 .call_with_timeout(request, RELAY_ACKNOWLEDGE_TIMEOUT)
1071 .await?
1072 {
1073 RelayResponsePayload::Acknowledged {
1074 through_ordinal,
1075 through_digest,
1076 } => Ok(RelayCursor {
1077 ordinal: through_ordinal,
1078 digest: through_digest,
1079 }),
1080 _ => bail!("relay returned an unexpected reviewer acknowledgement response"),
1081 }
1082 }
1083
1084 pub async fn submit_to_reviewer(
1086 &mut self,
1087 role: Option<&str>,
1088 command_id: impl Into<String>,
1089 command: RelayCommand,
1090 ) -> Result<u64> {
1091 let command_id = command_id.into();
1092 let request = self.reviewer_request(
1093 role,
1094 ReviewerRequest::Submit {
1095 command_id: command_id.clone(),
1096 command,
1097 },
1098 )?;
1099 match self.call(request).await? {
1100 RelayResponsePayload::Accepted {
1101 command_id: accepted_id,
1102 ordinal,
1103 } if accepted_id == command_id => Ok(ordinal),
1104 RelayResponsePayload::Accepted {
1105 command_id: accepted_id,
1106 ..
1107 } => bail!("reviewer accepted command under ID {accepted_id}, expected {command_id}"),
1108 _ => bail!("relay returned an unexpected reviewer command response"),
1109 }
1110 }
1111
1112 pub async fn reviewer_status(&mut self, role: Option<&str>) -> Result<RelayOperationalState> {
1113 let request = self.reviewer_request(role, ReviewerRequest::Status)?;
1114 match self.call(request).await? {
1115 RelayResponsePayload::Status(status) => Ok(status),
1116 _ => bail!("relay returned an unexpected reviewer status response"),
1117 }
1118 }
1119
1120 pub async fn respond_to_reviewer(
1122 &mut self,
1123 role: Option<&str>,
1124 elicitation_id: String,
1125 response: ElicitationResponse,
1126 ) -> Result<()> {
1127 let request = self.reviewer_request(
1128 role,
1129 ReviewerRequest::RespondElicitation {
1130 elicitation_id: elicitation_id.clone(),
1131 response,
1132 },
1133 )?;
1134 match self.call(request).await? {
1135 RelayResponsePayload::ElicitationResolved {
1136 elicitation_id: resolved,
1137 } if resolved == elicitation_id => Ok(()),
1138 RelayResponsePayload::ElicitationResolved {
1139 elicitation_id: resolved,
1140 } => bail!("reviewer resolved elicitation {resolved:?}, expected {elicitation_id:?}"),
1141 _ => bail!("relay returned an unexpected reviewer elicitation response"),
1142 }
1143 }
1144
1145 pub async fn pause_reviewer(&mut self, role: Option<&str>) -> Result<()> {
1148 let request = self.reviewer_request(role, ReviewerRequest::Pause)?;
1149 match self
1150 .call_with_timeout(request, RELAY_ACKNOWLEDGE_TIMEOUT)
1151 .await?
1152 {
1153 RelayResponsePayload::ReviewerPaused => Ok(()),
1154 _ => bail!("relay returned an unexpected reviewer pause response"),
1155 }
1156 }
1157
1158 pub async fn capture_review_delta(
1161 &mut self,
1162 role: Option<&str>,
1163 baselines: std::collections::BTreeMap<std::path::PathBuf, String>,
1164 ) -> Result<Vec<mj_core::relay::RepoDelta>> {
1165 let request = self.reviewer_request(role, ReviewerRequest::CaptureDelta { baselines })?;
1166 match self
1167 .call_with_timeout(request, REVIEW_CAPTURE_TIMEOUT)
1168 .await?
1169 {
1170 RelayResponsePayload::ReviewDelta { repositories } => Ok(repositories),
1171 _ => bail!("relay returned an unexpected review capture response"),
1172 }
1173 }
1174
1175 pub async fn advance_review_baseline(
1178 &mut self,
1179 role: Option<&str>,
1180 trees: std::collections::BTreeMap<std::path::PathBuf, String>,
1181 ) -> Result<()> {
1182 let request = self.reviewer_request(role, ReviewerRequest::AdvanceBaseline { trees })?;
1183 match self
1184 .call_with_timeout(request, REVIEW_CAPTURE_TIMEOUT)
1185 .await?
1186 {
1187 RelayResponsePayload::ReviewBaselineAdvanced => Ok(()),
1188 _ => bail!("relay returned an unexpected review baseline response"),
1189 }
1190 }
1191
1192 pub async fn analyze_review_delta(
1195 &mut self,
1196 role: Option<&str>,
1197 repositories: Vec<mj_core::relay::AnalyzeDeltaRepository>,
1198 ) -> Result<String> {
1199 let request =
1200 self.reviewer_request(role, ReviewerRequest::AnalyzeDelta { repositories })?;
1201 match self
1202 .call_with_timeout(request, REVIEW_ANALYSIS_TIMEOUT)
1203 .await?
1204 {
1205 RelayResponsePayload::ReviewChangedFunctions { packet } => Ok(packet),
1206 _ => bail!("relay returned an unexpected review analysis response"),
1207 }
1208 }
1209
1210 pub async fn take_lane_dispatches(
1213 &mut self,
1214 ) -> Result<Vec<mj_core::review::lanes::ReviewSubagentRequest>> {
1215 let request = self.reviewer_request(None, ReviewerRequest::TakeLaneDispatches)?;
1216 match self.call(request).await? {
1217 RelayResponsePayload::LaneDispatches { requests } => Ok(requests),
1218 _ => bail!("relay returned an unexpected lane dispatch response"),
1219 }
1220 }
1221
1222 fn reviewer_request(
1225 &self,
1226 role: Option<&str>,
1227 request: ReviewerRequest,
1228 ) -> Result<RelayRequest> {
1229 let request = RelayRequest::Reviewer {
1230 role: role.map(str::to_owned),
1231 request,
1232 };
1233 if !request.supported_at(self.protocol_version) {
1234 bail!(
1235 "a second opinion requires relay protocol {}; this session negotiated {}",
1236 request.minimum_protocol(),
1237 self.protocol_version
1238 );
1239 }
1240 Ok(request)
1241 }
1242
1243 pub async fn respond_elicitation(
1246 &mut self,
1247 elicitation_id: String,
1248 response: ElicitationResponse,
1249 ) -> Result<()> {
1250 let request = RelayRequest::RespondElicitation {
1251 elicitation_id: elicitation_id.clone(),
1252 response,
1253 };
1254 if !request.supported_at(self.protocol_version) {
1255 bail!(
1256 "elicitation responses require relay protocol {}; this session negotiated {}",
1257 request.minimum_protocol(),
1258 self.protocol_version
1259 );
1260 }
1261 match self.call(request).await? {
1262 RelayResponsePayload::ElicitationResolved {
1263 elicitation_id: resolved,
1264 } if resolved == elicitation_id => Ok(()),
1265 RelayResponsePayload::ElicitationResolved {
1266 elicitation_id: resolved,
1267 } => bail!("relay resolved elicitation {resolved:?}, expected {elicitation_id:?}"),
1268 _ => bail!("relay returned an unexpected elicitation response"),
1269 }
1270 }
1271
1272 pub async fn stop_background_task(&mut self, background_task_id: String) -> Result<()> {
1274 let request = RelayRequest::StopBackgroundTask {
1275 background_task_id: background_task_id.clone(),
1276 };
1277 if !request.supported_at(self.protocol_version) {
1278 bail!(
1279 "background task controls require relay protocol {}; this session negotiated {}",
1280 request.minimum_protocol(),
1281 self.protocol_version
1282 );
1283 }
1284 match self.call(request).await? {
1285 RelayResponsePayload::BackgroundTaskStopRequested {
1286 background_task_id: stopped,
1287 } if stopped == background_task_id => Ok(()),
1288 RelayResponsePayload::BackgroundTaskStopRequested {
1289 background_task_id: stopped,
1290 } => {
1291 bail!("relay stopped background task {stopped:?}, expected {background_task_id:?}")
1292 }
1293 _ => bail!("relay returned an unexpected background task stop response"),
1294 }
1295 }
1296
1297 pub async fn subagent_requests(
1298 &mut self,
1299 ) -> Result<(
1300 Vec<mj_core::subagent::SubagentToolRequest>,
1301 Vec<mj_core::subagent::SubagentToolResult>,
1302 )> {
1303 let request = RelayRequest::SubagentRequests;
1304 if !request.supported_at(self.protocol_version) {
1305 return Ok((Vec::new(), Vec::new()));
1306 }
1307 match self.call(request).await? {
1308 RelayResponsePayload::SubagentRequests { requests, results } => Ok((requests, results)),
1309 _ => bail!("relay returned an unexpected sub-agent request response"),
1310 }
1311 }
1312
1313 pub async fn complete_subagent_request(
1314 &mut self,
1315 result: mj_core::subagent::SubagentToolResult,
1316 ) -> Result<()> {
1317 let request = RelayRequest::CompleteSubagentRequest { result };
1318 if !request.supported_at(self.protocol_version) {
1319 bail!(
1320 "sub-agent tools require relay protocol {}",
1321 request.minimum_protocol()
1322 );
1323 }
1324 match self.call(request).await? {
1325 RelayResponsePayload::SubagentRequestCompleted => Ok(()),
1326 _ => bail!("relay returned an unexpected sub-agent completion response"),
1327 }
1328 }
1329
1330 pub async fn detach(mut self) -> Result<()> {
1331 self.input
1332 .take()
1333 .expect("connected relay owns proxy stdin")
1334 .shutdown()
1335 .await
1336 .context("close relay proxy stdin")?;
1337 let mut child = self.child.take().expect("connected relay owns proxy child");
1338 match tokio::time::timeout(RELAY_PROXY_DETACH_GRACE, child.wait()).await {
1339 Ok(status) => {
1340 status.context("wait for relay proxy")?;
1341 }
1342 Err(_) => {
1343 if let Err(error) = child.start_kill().context("stop relay proxy") {
1344 tracing::warn!(
1345 session_id = %self.session_id,
1346 operation = "detach",
1347 %error,
1348 "could not stop relay proxy after detach timeout"
1349 );
1350 return Err(error);
1351 }
1352 if let Err(error) = child.wait().await {
1353 tracing::warn!(
1354 session_id = %self.session_id,
1355 operation = "detach",
1356 %error,
1357 "could not reap relay proxy after stopping it"
1358 );
1359 }
1360 }
1361 }
1362 Ok(())
1363 }
1364
1365 async fn call(&mut self, request: RelayRequest) -> Result<RelayResponsePayload> {
1366 self.call_with_timeout(request, self.request_timeout).await
1367 }
1368
1369 async fn call_with_timeout(
1370 &mut self,
1371 request: RelayRequest,
1372 timeout: Duration,
1373 ) -> Result<RelayResponsePayload> {
1374 let operation = request.method_name();
1375 let request_id = self.request_id();
1376 let envelope = RelayRequestEnvelope {
1377 request_id: request_id.clone(),
1378 protocol_version: self.protocol_version,
1379 request,
1380 };
1381 let line = match self
1382 .exchange(&envelope, operation, timeout, ExchangeKind::Call)
1383 .await
1384 {
1385 Ok(line) => line,
1386 Err(error) => {
1387 log_relay_client_failure(self, operation, &request_id, &error);
1388 return Err(error);
1389 }
1390 };
1391 let result = decode_relay_response(&line, &request_id, self.protocol_version)
1392 .with_context(|| format!("relay {} could not perform {operation}", self.relay_version));
1393 if let Err(error) = &result {
1394 log_relay_client_failure(self, operation, &request_id, error);
1395 }
1396 result
1397 }
1398
1399 async fn call_hello(
1400 &mut self,
1401 request: RelayRequest,
1402 timeout: Duration,
1403 ) -> Result<RelayResponsePayload> {
1404 let operation = request.method_name();
1405 let request_id = self.request_id();
1406 let envelope = RelayRequestEnvelope {
1407 request_id: request_id.clone(),
1408 protocol_version: RELAY_PROTOCOL_VERSION,
1409 request,
1410 };
1411 let line = match self
1412 .exchange(&envelope, operation, timeout, ExchangeKind::Handshake)
1413 .await
1414 {
1415 Ok(line) => line,
1416 Err(error) => {
1417 log_relay_client_failure(self, operation, &request_id, &error);
1418 return Err(error);
1419 }
1420 };
1421 let result = decode_relay_hello_response(&line, &request_id);
1422 if let Err(error) = &result {
1423 log_relay_client_failure(self, operation, &request_id, error);
1424 }
1425 result
1426 }
1427
1428 async fn exchange(
1438 &mut self,
1439 envelope: &RelayRequestEnvelope,
1440 operation: &str,
1441 timeout: Duration,
1442 kind: ExchangeKind,
1443 ) -> Result<String> {
1444 if let Some(reason) = &self.abandoned {
1445 bail!("{reason}");
1446 }
1447 let mut frame = serde_json::to_vec(envelope)?;
1448 if frame.len() > MAX_FRAME_BYTES {
1449 bail!("relay {operation} request frame is too large");
1450 }
1451 frame.push(b'\n');
1452 let session_id = self.session_id.clone();
1453 let exchanged = tokio::time::timeout(timeout, async {
1454 self.input
1455 .as_mut()
1456 .expect("connected relay owns proxy stdin")
1457 .write_all(&frame)
1458 .await
1459 .map_err(|error| RelayTransportDead::from_io(error, kind))
1460 .with_context(|| format!("write relay {operation} request"))?;
1461 self.input
1462 .as_mut()
1463 .expect("connected relay owns proxy stdin")
1464 .flush()
1465 .await
1466 .map_err(|error| RelayTransportDead::from_io(error, kind))
1467 .with_context(|| format!("flush relay {operation} request"))?;
1468 let response = read_bounded_frame(&mut self.output, kind);
1469 tokio::pin!(response);
1470 let response = tokio::select! {
1471 response = &mut response => response,
1472 () = tokio::time::sleep(RELAY_SLOW_OPERATION_WARNING) => {
1473 tracing::warn!(
1474 %session_id,
1475 %operation,
1476 warning_after_seconds = RELAY_SLOW_OPERATION_WARNING.as_secs_f64(),
1477 timeout_seconds = timeout.as_secs_f64(),
1478 "relay operation is still waiting for its response"
1479 );
1480 response.await
1481 }
1482 };
1483 response
1484 .with_context(|| format!("read relay {operation} response"))?
1485 .ok_or_else(|| {
1486 anyhow::Error::new(RelayTransportDead::during_exchange(
1487 format!("relay proxy disconnected during {operation}"),
1488 kind,
1489 ))
1490 })
1491 })
1492 .await;
1493 match exchanged {
1494 Ok(line) => line,
1495 Err(_elapsed) => {
1496 let seconds = timeout.as_secs_f64();
1497 tracing::warn!(
1498 %session_id,
1499 %operation,
1500 timeout_seconds = seconds,
1501 "relay operation timed out; abandoning its sequential connection"
1502 );
1503 self.abandoned = Some(format!(
1504 "relay connection abandoned after {operation} timed out after {seconds} seconds"
1505 ));
1506 let timed_out = format!("relay {operation} timed out after {seconds} seconds");
1507 Err(anyhow!(timed_out))
1508 }
1509 }
1510 }
1511
1512 fn request_id(&mut self) -> String {
1513 let id = format!("relay-{:016x}-{}", self.connection_nonce, self.next_request);
1514 self.next_request = self.next_request.wrapping_add(1);
1515 id
1516 }
1517}
1518
1519fn log_relay_client_failure(
1524 client: &RelayClient,
1525 operation: &str,
1526 request_id: &str,
1527 error: &anyhow::Error,
1528) {
1529 let rejection = error.chain().find_map(|cause| {
1530 cause
1531 .downcast_ref::<RelayRejected>()
1532 .map(|rejected| &rejected.0)
1533 });
1534 let transport_dead = RelayTransportDead::marks(error);
1535 match rejection {
1536 Some(rejection) => tracing::warn!(
1537 session_id = %client.session_id,
1538 relay_version = %client.relay_version,
1539 %operation,
1540 %request_id,
1541 relay_error_code = ?rejection.code,
1542 relay_retryable = rejection.retryable,
1543 transport_dead,
1544 error = %error,
1545 "relay request rejected"
1546 ),
1547 None => tracing::warn!(
1548 session_id = %client.session_id,
1549 relay_version = %client.relay_version,
1550 %operation,
1551 %request_id,
1552 transport_dead,
1553 error = %error,
1554 "relay request failed"
1555 ),
1556 }
1557}
1558
1559impl Drop for RelayClient {
1560 fn drop(&mut self) {
1561 drop(self.input.take());
1566 let Some(child) = self.child.take() else {
1567 return;
1568 };
1569 let session_id = self.session_id.clone();
1570 if let Err(error) = std::thread::Builder::new()
1571 .name("hel-relay-reaper".into())
1572 .spawn(move || reap_dropped_relay_proxy(child, session_id))
1573 {
1574 tracing::warn!(
1575 session_id = %self.session_id,
1576 %error,
1577 "could not start dropped relay proxy reaper"
1578 );
1579 }
1580 }
1581}
1582
1583fn reap_dropped_relay_proxy(mut child: Child, session_id: String) {
1586 let deadline = Instant::now() + RELAY_PROXY_DETACH_GRACE;
1587 loop {
1588 match child.try_wait() {
1589 Ok(Some(status)) => {
1590 if !status.success() {
1591 tracing::warn!(
1592 %session_id,
1593 %status,
1594 "dropped relay proxy exited unsuccessfully"
1595 );
1596 }
1597 return;
1598 }
1599 Ok(None) if Instant::now() < deadline => {
1600 std::thread::sleep(RELAY_PROXY_REAP_POLL);
1601 }
1602 Ok(None) => break,
1603 Err(error) => {
1604 tracing::warn!(%session_id, %error, "could not reap dropped relay proxy");
1605 return;
1606 }
1607 }
1608 }
1609
1610 if let Err(error) = child.start_kill()
1611 && error.kind() != std::io::ErrorKind::NotFound
1612 {
1613 tracing::warn!(%session_id, %error, "could not stop dropped relay proxy");
1614 return;
1615 }
1616 let deadline = Instant::now() + RELAY_PROXY_DETACH_GRACE;
1617 loop {
1618 match child.try_wait() {
1619 Ok(Some(_)) => return,
1620 Ok(None) if Instant::now() < deadline => {
1621 std::thread::sleep(RELAY_PROXY_REAP_POLL);
1622 }
1623 Ok(None) => {
1624 tracing::warn!(%session_id, "stopped relay proxy could not be reaped in time");
1625 return;
1626 }
1627 Err(error) => {
1628 tracing::warn!(%session_id, %error, "could not reap stopped relay proxy");
1629 return;
1630 }
1631 }
1632 }
1633}
1634
1635fn credential_snapshot(payload: RelayResponsePayload) -> Result<CredentialSnapshot> {
1636 match payload {
1637 RelayResponsePayload::CredentialState {
1638 present,
1639 fingerprint,
1640 freshness_epoch_ms,
1641 } => Ok(CredentialSnapshot {
1642 present,
1643 fingerprint,
1644 freshness_epoch_ms,
1645 }),
1646 _ => bail!("relay returned an unexpected credential state response"),
1647 }
1648}
1649
1650fn skills_sync_state(payload: RelayResponsePayload) -> Result<mj_core::skills::SkillsSyncState> {
1651 match payload {
1652 RelayResponsePayload::SkillsState {
1653 present,
1654 fingerprint,
1655 } => Ok(mj_core::skills::SkillsSyncState {
1656 present,
1657 fingerprint,
1658 }),
1659 _ => bail!("relay returned an unexpected skills state response"),
1660 }
1661}
1662
1663fn github_token_snapshot(
1664 payload: RelayResponsePayload,
1665) -> Result<mj_core::credentials::GithubTokenSnapshot> {
1666 match payload {
1667 RelayResponsePayload::GithubTokenState {
1668 present,
1669 fingerprint,
1670 } => Ok(mj_core::credentials::GithubTokenSnapshot {
1671 present,
1672 fingerprint,
1673 }),
1674 _ => bail!("relay returned an unexpected GitHub token state response"),
1675 }
1676}
1677
1678async fn read_bounded_frame(
1679 reader: &mut (impl AsyncBufRead + Unpin),
1680 kind: ExchangeKind,
1681) -> Result<Option<String>> {
1682 read_bounded_frame_with_limit(reader, MAX_FRAME_BYTES, kind).await
1683}
1684
1685async fn read_bounded_frame_with_limit(
1686 reader: &mut (impl AsyncBufRead + Unpin),
1687 maximum_bytes: usize,
1688 kind: ExchangeKind,
1689) -> Result<Option<String>> {
1690 let mut frame = Vec::new();
1691 loop {
1692 let available = reader
1696 .fill_buf()
1697 .await
1698 .map_err(|error| RelayTransportDead::from_io(error, kind))?;
1699 if available.is_empty() {
1700 if frame.is_empty() {
1701 return Ok(None);
1702 }
1703 return Err(anyhow::Error::new(RelayTransportDead::during_exchange(
1704 "relay proxy disconnected in the middle of a response frame",
1705 kind,
1706 )));
1707 }
1708 let newline = available.iter().position(|byte| *byte == b'\n');
1709 let consumed = newline.map_or(available.len(), |position| position + 1);
1710 let payload = newline.map_or(available, |position| &available[..position]);
1711 if frame.len().saturating_add(payload.len()) > maximum_bytes {
1712 bail!("relay response frame is too large");
1713 }
1714 frame.extend_from_slice(payload);
1715 reader.consume(consumed);
1716 if newline.is_some() {
1717 if frame.last() == Some(&b'\r') {
1718 frame.pop();
1719 }
1720 return String::from_utf8(frame)
1721 .context("relay response is not UTF-8")
1722 .map(Some);
1723 }
1724 }
1725}
1726
1727fn clip_catch_up_page(
1728 page: RelayAttachment,
1729 previous: &RelayCursor,
1730 frontier: &RelayCursor,
1731) -> Result<RelayEventPage> {
1732 if previous.ordinal > frontier.ordinal {
1733 bail!("relay catch-up starts beyond its fixed frontier");
1734 }
1735 if previous.ordinal == frontier.ordinal {
1736 if previous != frontier {
1737 bail!("relay catch-up cursor digest differs from its fixed frontier");
1738 }
1739 if !page.events.is_empty() || page.through_ordinal != previous.ordinal {
1740 bail!("relay attachment advanced beyond its advertised frontier");
1741 }
1742 return Ok(RelayEventPage {
1743 events: Vec::new(),
1744 through_ordinal: previous.ordinal,
1745 through_digest: previous.digest.clone(),
1746 });
1747 }
1748 if page.through_ordinal <= previous.ordinal || page.events.is_empty() {
1749 bail!("relay catch-up page did not advance");
1750 }
1751 if page.through_ordinal <= frontier.ordinal {
1752 let through = RelayCursor {
1753 ordinal: page.through_ordinal,
1754 digest: page.through_digest.clone(),
1755 };
1756 if through.ordinal == frontier.ordinal && through != *frontier {
1757 bail!("relay catch-up page digest differs from its fixed frontier");
1758 }
1759 return Ok(RelayEventPage {
1760 events: page.events,
1761 through_ordinal: through.ordinal,
1762 through_digest: through.digest,
1763 });
1764 }
1765
1766 let events = page
1767 .events
1768 .into_iter()
1769 .take_while(|event| event.ordinal <= frontier.ordinal)
1770 .collect::<Vec<_>>();
1771 let reached = events
1772 .last()
1773 .map(|event| RelayCursor {
1774 ordinal: event.ordinal,
1775 digest: event.digest.clone(),
1776 })
1777 .ok_or_else(|| anyhow!("relay catch-up page skipped its fixed frontier"))?;
1778 if reached != *frontier {
1779 bail!("relay catch-up page does not contain its fixed frontier");
1780 }
1781 Ok(RelayEventPage {
1782 events,
1783 through_ordinal: reached.ordinal,
1784 through_digest: reached.digest,
1785 })
1786}
1787
1788fn decode_relay_response(
1789 line: &str,
1790 request_id: &str,
1791 protocol: u32,
1792) -> Result<RelayResponsePayload> {
1793 let response: RelayResponseEnvelope =
1794 serde_json::from_str(line).context("decode relay response")?;
1795 if response.request_id != request_id {
1796 bail!(
1797 "relay response ID mismatch: expected {request_id}, got {}",
1798 response.request_id
1799 );
1800 }
1801 if response.protocol_version != protocol {
1802 bail!(
1803 "relay response protocol mismatch: expected {protocol}, got {}",
1804 response.protocol_version
1805 );
1806 }
1807 match response.body {
1808 RelayResponseBody::Ok { payload } => Ok(payload),
1809 RelayResponseBody::Error { error } => Err(RelayRejected(error).into()),
1810 }
1811}
1812
1813fn decode_relay_hello_response(line: &str, request_id: &str) -> Result<RelayResponsePayload> {
1814 let response: RelayResponseEnvelope =
1815 serde_json::from_str(line).context("decode relay hello response")?;
1816 if response.request_id != request_id {
1817 bail!(
1818 "relay response ID mismatch: expected {request_id}, got {}",
1819 response.request_id
1820 );
1821 }
1822 match response.body {
1823 RelayResponseBody::Ok {
1824 payload: payload @ RelayResponsePayload::Hello { negotiated, .. },
1825 } => {
1826 if response.protocol_version != negotiated {
1827 bail!(
1828 "relay hello envelope uses protocol {}, negotiated {negotiated}",
1829 response.protocol_version
1830 );
1831 }
1832 Ok(payload)
1833 }
1834 RelayResponseBody::Ok { .. } => bail!("relay returned an unexpected hello response"),
1835 RelayResponseBody::Error { error } => Err(RelayRejected(error).into()),
1836 }
1837}
1838
1839pub struct CredentialSyncCoordinator {
1840 handle: CredentialSyncHandle,
1841 results: mpsc::UnboundedReceiver<CredentialSyncResult>,
1842}
1843
1844impl CredentialSyncCoordinator {
1845 pub fn spawn() -> Self {
1846 let (targets_tx, mut targets_rx) = watch::channel(Vec::new());
1847 let (triggers_tx, mut triggers_rx) = mpsc::unbounded_channel::<SyncTrigger>();
1848 let (completed_tx, mut completed_rx) = mpsc::unbounded_channel::<CredentialSyncResult>();
1849 let (results_tx, results_rx) = mpsc::unbounded_channel();
1850 tokio::spawn(async move {
1851 let mut tick = tokio::time::interval_at(
1852 tokio::time::Instant::now() + SYNC_INTERVAL,
1853 SYNC_INTERVAL,
1854 );
1855 tick.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
1856 let mut busy = BTreeSet::<String>::new();
1859 let mut queue = VecDeque::<SyncTrigger>::new();
1860 loop {
1861 tokio::select! {
1862 _ = tick.tick() => {
1863 for profile_id in profiles_with_targets(&targets_rx.borrow()) {
1864 enqueue(&mut queue, SyncTrigger { profile_id, cause: None });
1865 }
1866 }
1867 changed = targets_rx.changed() => {
1868 if changed.is_err() { break; }
1869 for profile_id in profiles_with_targets(&targets_rx.borrow()) {
1870 enqueue(&mut queue, SyncTrigger { profile_id, cause: None });
1871 }
1872 }
1873 trigger = triggers_rx.recv() => {
1874 let Some(trigger) = trigger else { break };
1875 enqueue(&mut queue, trigger);
1876 }
1877 completed = completed_rx.recv() => {
1878 let Some(result) = completed else { break };
1879 busy.remove(&result.profile_id);
1880 if result.trigger.is_some()
1881 || result.failure.is_some()
1882 || !result.outcomes.is_empty()
1883 {
1884 let profile_id = result.profile_id.clone();
1885 if results_tx.send(result).is_err() {
1886 tracing::debug!(
1887 %profile_id,
1888 operation = "credential_sync_result",
1889 "credential sync result receiver was already closed"
1890 );
1891 }
1892 }
1893 }
1894 }
1895
1896 let mut deferred = VecDeque::new();
1897 while let Some(trigger) = queue.pop_front() {
1898 if busy.contains(&trigger.profile_id) {
1899 deferred.push_back(trigger);
1900 continue;
1901 }
1902 let targets: Vec<_> = targets_rx
1903 .borrow()
1904 .iter()
1905 .filter(|target| target.profile_id == trigger.profile_id)
1906 .cloned()
1907 .collect();
1908 if targets.is_empty() {
1909 if trigger.cause.is_some() {
1910 let profile_id = trigger.profile_id.clone();
1911 if results_tx
1912 .send(CredentialSyncResult {
1913 profile_id: trigger.profile_id,
1914 trigger: trigger.cause,
1915 failure: None,
1916 outcomes: Vec::new(),
1917 })
1918 .is_err()
1919 {
1920 tracing::debug!(
1921 %profile_id,
1922 operation = "credential_sync_result",
1923 "credential sync result receiver was already closed"
1924 );
1925 }
1926 }
1927 continue;
1928 }
1929 busy.insert(trigger.profile_id.clone());
1930 let completed_tx = completed_tx.clone();
1931 let handle = tokio::runtime::Handle::current();
1932 tokio::spawn(async move {
1935 let joined = tokio::task::spawn_blocking(move || {
1936 handle.block_on(reconcile_profile(&targets))
1937 })
1938 .await;
1939 let (failure, outcomes) = match joined {
1940 Ok(outcomes) => (None, outcomes),
1941 Err(error) => (Some(format!("sync task stopped: {error}")), Vec::new()),
1942 };
1943 let profile_id = trigger.profile_id.clone();
1944 if completed_tx
1945 .send(CredentialSyncResult {
1946 profile_id: trigger.profile_id,
1947 trigger: trigger.cause,
1948 failure,
1949 outcomes,
1950 })
1951 .is_err()
1952 {
1953 tracing::debug!(
1954 %profile_id,
1955 operation = "credential_sync_completion",
1956 "credential sync coordinator stopped before receiving completion"
1957 );
1958 }
1959 });
1960 }
1961 queue = deferred;
1962 }
1963 });
1964 Self {
1965 handle: CredentialSyncHandle {
1966 targets: Arc::new(targets_tx),
1967 triggers: triggers_tx,
1968 },
1969 results: results_rx,
1970 }
1971 }
1972
1973 pub fn handle(&self) -> CredentialSyncHandle {
1974 self.handle.clone()
1975 }
1976
1977 pub fn try_result(&mut self) -> Option<CredentialSyncResult> {
1978 self.results.try_recv().ok()
1979 }
1980
1981 pub async fn result(&mut self) -> Option<CredentialSyncResult> {
1987 self.results.recv().await
1988 }
1989}
1990
1991async fn reconcile_profile(targets: &[CredentialSyncTarget]) -> Vec<CredentialSyncOutcome> {
1998 let github_token = targets
1999 .iter()
2000 .any(|target| target.sync_github_token)
2001 .then(crate::controller::controller_github_token)
2002 .flatten();
2003 let mut outcomes = BTreeMap::<String, CredentialSyncOutcome>::new();
2004 for pass in 0..2 {
2005 let mut pulled = false;
2006 for target in targets {
2007 match reconcile_session(target, github_token.as_deref()).await {
2008 Ok(actions) if actions.is_empty() => {}
2009 Ok(actions) => {
2010 pulled |= actions.contains(&CredentialSyncAction::Pulled);
2011 outcomes.insert(
2012 target.session_id.clone(),
2013 CredentialSyncOutcome {
2014 session_id: target.session_id.clone(),
2015 outcome: Ok(actions),
2016 },
2017 );
2018 }
2019 Err(error) => {
2020 tracing::warn!(
2021 session_id = %target.session_id,
2022 profile_id = %target.profile_id,
2023 pass = pass + 1,
2024 error = %error,
2025 "credential synchronization failed for relay session"
2026 );
2027 outcomes.insert(
2028 target.session_id.clone(),
2029 CredentialSyncOutcome {
2030 session_id: target.session_id.clone(),
2031 outcome: Err(format!("{error:#}")),
2032 },
2033 );
2034 }
2035 }
2036 }
2037 if !pulled || pass == 1 {
2038 break;
2039 }
2040 }
2041 outcomes.into_values().collect()
2042}
2043
2044async fn reconcile_session(
2046 target: &CredentialSyncTarget,
2047 github_token: Option<&str>,
2048) -> Result<Vec<CredentialSyncAction>> {
2049 let canonical_path = harness_authentication_marker(target.harness, &target.profile_home);
2050 let (canonical, canonical_bytes) = read_credential_file(target.harness, &canonical_path)?;
2051 let canonical_skills = mj_core::skills::collect_skills(target.harness, &target.profile_home)
2052 .with_context(|| {
2053 format!(
2054 "collect canonical skills for profile {} from {}",
2055 target.profile_id,
2056 target.profile_home.display()
2057 )
2058 })?;
2059 let mut client = RelayClient::connect(&target.spec, &target.session_id).await?;
2060 let result = reconcile_connected(
2061 &mut client,
2062 target,
2063 &canonical_path,
2064 &canonical,
2065 &canonical_bytes,
2066 &canonical_skills,
2067 github_token,
2068 )
2069 .await;
2070 if let Err(error) = client.detach().await {
2074 tracing::warn!(
2075 session_id = %target.session_id,
2076 "could not close the credential sync connection: {error:#}"
2077 );
2078 }
2079 result
2080}
2081
2082async fn reconcile_connected(
2083 client: &mut RelayClient,
2084 target: &CredentialSyncTarget,
2085 canonical_path: &Path,
2086 canonical: &CredentialSnapshot,
2087 canonical_bytes: &[u8],
2088 canonical_skills: &mj_core::skills::SkillsArchive,
2089 github_token: Option<&str>,
2090) -> Result<Vec<CredentialSyncAction>> {
2091 let mut actions = Vec::new();
2092 let session = client.credential_state().await?;
2093 match reconcile(canonical, &session) {
2094 SyncAction::None => {
2095 if canonical.present
2096 && session.present
2097 && canonical.fingerprint != session.fingerprint
2098 && canonical.freshness_epoch_ms.is_none()
2099 && session.freshness_epoch_ms.is_none()
2100 {
2101 tracing::warn!(
2102 session_id = %target.session_id,
2103 profile_id = %target.profile_id,
2104 "credential copies differ but neither reports a refresh time; leaving both alone"
2105 );
2106 }
2107 }
2108 SyncAction::Push => {
2109 client.install_credentials(canonical_bytes).await?;
2110 actions.push(CredentialSyncAction::Pushed);
2111 }
2112 SyncAction::Pull => {
2113 let bytes = client.read_credentials().await?;
2114 validate_credential_payload(target.harness, &bytes).with_context(|| {
2115 format!(
2116 "session {} returned an unusable credential file",
2117 target.session_id
2118 )
2119 })?;
2120 write_credential_file(target.harness, canonical_path, &bytes).with_context(|| {
2121 format!(
2122 "install fresher credentials from session {} for profile {}",
2123 target.session_id, target.profile_id
2124 )
2125 })?;
2126 actions.push(CredentialSyncAction::Pulled);
2127 }
2128 }
2129 if reconcile_skills(client, target, canonical_skills).await? {
2130 actions.push(CredentialSyncAction::SkillsPushed);
2131 }
2132 if target.sync_github_token
2133 && let Some(action) = reconcile_github_token(client, target, github_token).await?
2134 {
2135 actions.push(action);
2136 }
2137 Ok(actions)
2138}
2139
2140async fn reconcile_github_token(
2141 client: &mut RelayClient,
2142 target: &CredentialSyncTarget,
2143 canonical: Option<&str>,
2144) -> Result<Option<CredentialSyncAction>> {
2145 let session = match client.github_token_state().await {
2146 Ok(state) => state,
2147 Err(error) if sync_method_unsupported(&error) => {
2148 tracing::debug!(
2149 session_id = %target.session_id,
2150 profile_id = %target.profile_id,
2151 "worker predates GitHub token sync; skipping until the target is re-provisioned"
2152 );
2153 return Ok(None);
2154 }
2155 Err(error) => return Err(error),
2156 };
2157 match canonical {
2158 Some(token) => {
2159 let canonical = mj_core::credentials::GithubTokenSnapshot::of(token);
2160 if session == canonical {
2161 return Ok(None);
2162 }
2163 let installed = client.install_github_token(token).await?;
2164 if installed != canonical {
2165 bail!(
2166 "session {} GitHub token fingerprint does not match the controller after install",
2167 target.session_id
2168 );
2169 }
2170 Ok(Some(CredentialSyncAction::GithubTokenPushed))
2171 }
2172 None if session.present => {
2173 let removed = client.remove_github_token().await?;
2174 if removed.present {
2175 bail!(
2176 "session {} retained its GitHub token after removal",
2177 target.session_id
2178 );
2179 }
2180 Ok(Some(CredentialSyncAction::GithubTokenRemoved))
2181 }
2182 None => Ok(None),
2183 }
2184}
2185
2186async fn reconcile_skills(
2191 client: &mut RelayClient,
2192 target: &CredentialSyncTarget,
2193 canonical: &mj_core::skills::SkillsArchive,
2194) -> Result<bool> {
2195 let canonical_state = canonical.state();
2196 let session = match client.skills_state().await {
2197 Ok(state) => state,
2198 Err(error) if sync_method_unsupported(&error) => {
2199 tracing::debug!(
2200 session_id = %target.session_id,
2201 profile_id = %target.profile_id,
2202 "worker predates skills sync; skipping until the target is re-provisioned"
2203 );
2204 return Ok(false);
2205 }
2206 Err(error) => return Err(error),
2207 };
2208 if session == canonical_state {
2209 return Ok(false);
2210 }
2211 let installed = client.install_skills(&canonical.encode()).await?;
2212 if installed != canonical_state {
2213 bail!(
2214 "session {} skills fingerprint {} does not match the canonical {} after install",
2215 target.session_id,
2216 installed.fingerprint,
2217 canonical_state.fingerprint
2218 );
2219 }
2220 Ok(true)
2221}
2222
2223fn sync_method_unsupported(error: &anyhow::Error) -> bool {
2224 error
2225 .downcast_ref::<RelayRejected>()
2226 .is_some_and(|rejected| rejected.0.code == RelayErrorCode::InvalidRequest)
2227}
2228
2229#[cfg(test)]
2230mod tests {
2231 use super::*;
2232 use mj_core::relay::RelayObservation;
2233 use mj_worker::relay::DurableRelay;
2234 const SESSION_ID: &str = "018f9dd2-a3b4-7c8d-9000-123456789abc";
2235
2236 #[test]
2237 fn relay_decoder_preserves_explicit_desynchronization() {
2238 let response = RelayResponseEnvelope {
2239 request_id: "relay-1".into(),
2240 protocol_version: RELAY_PROTOCOL_VERSION,
2241 body: RelayResponseBody::Error {
2242 error: RelayProtocolError {
2243 code: RelayErrorCode::Desynchronized,
2244 message: "journal gap".into(),
2245 retryable: false,
2246 detail: None,
2247 },
2248 },
2249 };
2250 let encoded = serde_json::to_string(&response).unwrap();
2251 let error = decode_relay_response(&encoded, "relay-1", RELAY_PROTOCOL_VERSION).unwrap_err();
2252 assert!(
2253 error
2254 .downcast_ref::<RelayRejected>()
2255 .is_some_and(RelayRejected::is_desynchronized)
2256 );
2257 }
2258
2259 #[test]
2260 fn relay_decoder_rejects_crossed_request_ids() {
2261 let response = RelayResponseEnvelope {
2262 request_id: "other".into(),
2263 protocol_version: RELAY_PROTOCOL_VERSION,
2264 body: RelayResponseBody::Ok {
2265 payload: RelayResponsePayload::Acknowledged {
2266 through_ordinal: 4,
2267 through_digest: "a".repeat(64),
2268 },
2269 },
2270 };
2271 let encoded = serde_json::to_string(&response).unwrap();
2272 assert!(
2273 decode_relay_response(&encoded, "wanted", RELAY_PROTOCOL_VERSION)
2274 .unwrap_err()
2275 .to_string()
2276 .contains("ID mismatch")
2277 );
2278 }
2279
2280 #[test]
2281 fn command_spec_preserves_argv_boundaries() {
2282 let spec = CommandSpec::new("ssh", ["host", "hel worker proxy --root '/odd path'"]);
2283 assert_eq!(spec.program, "ssh");
2284 assert_eq!(spec.args.len(), 2);
2285 assert_eq!(spec.args[1], "hel worker proxy --root '/odd path'");
2286 }
2287
2288 #[test]
2289 fn relay_protocol_version_range_contains_current_version() {
2290 assert_eq!(
2291 RelayVersionRange::CURRENT.negotiate(RelayVersionRange::CURRENT),
2292 Some(RELAY_PROTOCOL_VERSION)
2293 );
2294 assert_eq!(
2295 RelayVersionRange::CURRENT.negotiate(RelayVersionRange { min: 1, max: 1 }),
2296 Some(1)
2297 );
2298 }
2299
2300 #[cfg(unix)]
2304 #[tokio::test]
2305 async fn a_relay_proxy_refused_by_sshd_is_retried_rather_than_reported_dead() {
2306 mj_core::targets::set_ssh_retry_backoff_for_test(Some(Duration::from_millis(5)));
2307 let directory = tempfile::tempdir().expect("temp dir");
2308 let counter = directory.path().join("attempts");
2309 let script = format!(
2310 r#"
2311count=$(cat {counter} 2>/dev/null || echo 0)
2312echo $((count + 1)) > {counter}
2313if [ "$count" -eq 0 ]; then
2314 echo 'kex_exchange_identification: read: Connection reset by peer' >&2
2315 exit 255
2316fi
2317IFS= read -r hello
2318id=$(printf '%s' "$hello" | sed -n 's/.*"request_id":"\([^"]*\)".*/\1/p')
2319printf '{{"request_id":"%s","protocol_version":1,"result":"ok","payload":{{"type":"hello","data":{{"negotiated":1,"relay_version":"retry-fixture","session_id":"{session}"}}}}}}\n' "$id"
2320sh -c 'while :; do sleep 30; done'
2321"#,
2322 counter = counter.display(),
2323 session = SESSION_ID
2324 );
2325 let spec = CommandSpec::new("sh", ["-c".to_owned(), script])
2326 .ssh_destination("build@10.0.0.1")
2327 .purpose("refused relay fixture");
2328
2329 let client = RelayClient::connect_with_timeout(&spec, SESSION_ID, Duration::from_secs(10))
2330 .await
2331 .expect("a refused connection must be retried, not reported as a dead relay");
2332
2333 assert_eq!(client.relay_version(), "retry-fixture");
2334 assert_eq!(
2335 std::fs::read_to_string(&counter)
2336 .expect("the fixture records its attempts")
2337 .trim(),
2338 "2"
2339 );
2340 mj_core::targets::set_ssh_retry_backoff_for_test(None);
2341 }
2342
2343 #[cfg(unix)]
2346 #[tokio::test]
2347 async fn a_relay_proxy_that_fails_for_another_reason_is_not_retried() {
2348 mj_core::targets::set_ssh_retry_backoff_for_test(Some(Duration::from_millis(5)));
2349 let directory = tempfile::tempdir().expect("temp dir");
2350 let counter = directory.path().join("attempts");
2351 let script = format!(
2352 r#"
2353count=$(cat {counter} 2>/dev/null || echo 0)
2354echo $((count + 1)) > {counter}
2355echo 'worker socket path is too long' >&2
2356exit 1
2357"#,
2358 counter = counter.display()
2359 );
2360 let spec = CommandSpec::new("sh", ["-c".to_owned(), script])
2361 .ssh_destination("build@10.0.0.1")
2362 .purpose("broken relay fixture");
2363
2364 let Err(error) =
2365 RelayClient::connect_with_timeout(&spec, SESSION_ID, Duration::from_secs(10)).await
2366 else {
2367 panic!("a proxy that exits 1 is a real failure");
2368 };
2369
2370 let reported = format!("{error:#}");
2371 assert!(
2372 reported.contains("relay proxy disconnected during hello"),
2373 "unexpected error: {reported}"
2374 );
2375 assert!(
2376 reported.contains("worker socket path is too long"),
2377 "the stderr tail must survive: {reported}"
2378 );
2379 assert_eq!(
2380 std::fs::read_to_string(&counter)
2381 .expect("the fixture records its attempts")
2382 .trim(),
2383 "1"
2384 );
2385 mj_core::targets::set_ssh_retry_backoff_for_test(None);
2386 }
2387
2388 #[cfg(unix)]
2389 #[tokio::test]
2390 async fn controller_accepts_negotiated_protocol_v1() {
2391 let script = format!(
2392 r#"python3 -c '
2393import json, sys
2394session = {session:?}
2395req = json.loads(sys.stdin.readline())
2396assert req["request"]["method"] == "hello"
2397supported = req["request"]["params"]["supported"]
2398assert supported["min"] <= 1 <= supported["max"]
2399print(json.dumps({{
2400 "request_id": req["request_id"],
2401 "protocol_version": 1,
2402 "result": "ok",
2403 "payload": {{
2404 "type": "hello",
2405 "data": {{
2406 "negotiated": 1,
2407 "relay_version": "v1-fixture",
2408 "session_id": session,
2409 }},
2410 }},
2411}}), flush=True)
2412sys.stdin.read()
2413'"#,
2414 session = SESSION_ID
2415 );
2416 let spec = CommandSpec::new("sh", ["-c", &script]).purpose("v1 relay fixture");
2417 let client = RelayClient::connect_with_timeout(&spec, SESSION_ID, Duration::from_secs(5))
2418 .await
2419 .expect("protocol v1 hello must be accepted");
2420 assert_eq!(client.protocol_version(), 1);
2421 assert_eq!(client.relay_version(), "v1-fixture");
2422 }
2423
2424 #[cfg(unix)]
2428 #[tokio::test]
2429 async fn a_hello_reports_the_worker_build_or_none_from_an_older_worker() {
2430 let hello = |build: Option<&str>| {
2431 let data = match build {
2432 Some(build) => format!(
2433 r#"{{"negotiated":1,"relay_version":"build-fixture","session_id":"%s","worker_build":"{build}"}}"#
2434 ),
2435 None => r#"{"negotiated":1,"relay_version":"build-fixture","session_id":"%s"}"#
2436 .to_owned(),
2437 };
2438 format!(
2439 r#"
2440IFS= read -r hello
2441id=$(printf '%s' "$hello" | sed -n 's/.*"request_id":"\([^"]*\)".*/\1/p')
2442printf '{{"request_id":"%s","protocol_version":1,"result":"ok","payload":{{"type":"hello","data":{data}}}}}
2443' "$id" "$1"
2444sh -c 'while :; do sleep 30; done'
2445"#
2446 )
2447 };
2448 for reported in [None, Some("a".repeat(64).as_str())] {
2449 let spec = CommandSpec::new(
2450 "sh",
2451 [
2452 "-c".to_owned(),
2453 hello(reported),
2454 "hel-relay-build-fixture".to_owned(),
2455 SESSION_ID.to_owned(),
2456 ],
2457 )
2458 .purpose("relay worker build fixture");
2459 let client =
2460 RelayClient::connect_with_timeout(&spec, SESSION_ID, Duration::from_secs(5))
2461 .await
2462 .expect("hello must be accepted with and without a worker build");
2463 assert_eq!(client.worker_build(), reported);
2464 }
2465 }
2466
2467 #[cfg(unix)]
2468 #[tokio::test]
2469 async fn dropping_a_client_delivers_eof_before_stopping_its_proxy_launcher() {
2470 let directory = tempfile::tempdir().unwrap();
2471 let eof = directory.path().join("proxy-saw-eof");
2472 let script = r#"
2473IFS= read -r hello
2474id=$(printf '%s' "$hello" | sed -n 's/.*"request_id":"\([^"]*\)".*/\1/p')
2475printf '{"request_id":"%s","protocol_version":1,"result":"ok","payload":{"type":"hello","data":{"negotiated":1,"relay_version":"eof-fixture","session_id":"%s"}}}\n' "$id" "$1"
2476if IFS= read -r _; then exit 9; fi
2477: > "$2"
2478"#;
2479 let spec = CommandSpec::new(
2480 "sh",
2481 [
2482 "-c".to_owned(),
2483 script.to_owned(),
2484 "hel-relay-eof-fixture".to_owned(),
2485 SESSION_ID.to_owned(),
2486 eof.to_string_lossy().into_owned(),
2487 ],
2488 )
2489 .purpose("relay proxy EOF fixture");
2490 let client = RelayClient::connect_with_timeout(&spec, SESSION_ID, Duration::from_secs(5))
2491 .await
2492 .unwrap();
2493
2494 drop(client);
2495 tokio::time::timeout(Duration::from_secs(2), async {
2496 while !eof.exists() {
2497 tokio::time::sleep(Duration::from_millis(10)).await;
2498 }
2499 })
2500 .await
2501 .expect("proxy launcher was killed before it observed stdin EOF");
2502 }
2503
2504 #[cfg(unix)]
2505 #[tokio::test]
2506 async fn controller_rejects_negotiated_protocol_outside_supported_range() {
2507 let future_protocol = RELAY_PROTOCOL_VERSION + 1;
2508 let script = format!(
2509 r#"python3 -c '
2510import json, sys
2511session = {session:?}
2512req = json.loads(sys.stdin.readline())
2513print(json.dumps({{
2514 "request_id": req["request_id"],
2515 "protocol_version": {future_protocol},
2516 "result": "ok",
2517 "payload": {{
2518 "type": "hello",
2519 "data": {{
2520 "negotiated": {future_protocol},
2521 "relay_version": "future",
2522 "session_id": session,
2523 }},
2524 }},
2525}}), flush=True)
2526sys.stdin.read()
2527'"#,
2528 session = SESSION_ID,
2529 future_protocol = future_protocol,
2530 );
2531 let spec = CommandSpec::new("sh", ["-c", &script]).purpose("future relay fixture");
2532 let error = RelayClient::connect_with_timeout(&spec, SESSION_ID, Duration::from_secs(5))
2533 .await
2534 .err()
2535 .expect("a future protocol hello must be rejected");
2536 assert!(
2537 error.to_string().contains(&format!(
2538 "negotiated unsupported protocol {future_protocol}"
2539 )),
2540 "{error:#}"
2541 );
2542 assert!(!RelayTransportDead::marks(&error), "{error:#}");
2545 }
2546
2547 #[cfg(unix)]
2550 #[tokio::test]
2551 async fn a_proxy_that_exits_before_hello_reports_a_dead_transport() {
2552 let spec = CommandSpec::new("sh", ["-c", "exit 1"]).purpose("exiting relay proxy");
2553
2554 let error = RelayClient::connect_with_timeout(&spec, SESSION_ID, Duration::from_secs(5))
2555 .await
2556 .err()
2557 .expect("a proxy that exits cannot complete hello");
2558
2559 assert!(RelayTransportDead::marks(&error), "{error:#}");
2560 assert!(RelayTransportDead::marks_failed_handshake(&error));
2561 }
2562
2563 #[cfg(unix)]
2567 #[tokio::test]
2568 async fn a_hello_failure_carries_the_proxy_stderr_tail() {
2569 const COMPLAINT: &str =
2570 "connect worker socket /x/control.sock: path must be shorter than SUN_LEN";
2571 let spec = CommandSpec::new("sh", ["-c", &format!("echo '{COMPLAINT}' >&2; exit 1")])
2572 .purpose("complaining relay proxy");
2573
2574 let error = RelayClient::connect_with_timeout(&spec, SESSION_ID, Duration::from_secs(5))
2575 .await
2576 .err()
2577 .expect("a proxy that exits cannot complete hello");
2578
2579 assert!(format!("{error:#}").contains(COMPLAINT), "{error:#}");
2580 assert!(RelayTransportDead::marks(&error), "{error:#}");
2582 assert!(RelayTransportDead::marks_failed_handshake(&error));
2583 }
2584
2585 #[cfg(unix)]
2586 #[tokio::test]
2587 async fn silent_proxy_handshake_has_a_bounded_deadline() {
2588 let spec = CommandSpec::new("sh", ["-c", "sleep 30"]).purpose("test silent relay proxy");
2589 let started = std::time::Instant::now();
2590
2591 let error = RelayClient::connect_with_timeout(&spec, SESSION_ID, Duration::from_millis(50))
2592 .await
2593 .err()
2594 .expect("silent relay must time out");
2595
2596 assert!(error.to_string().contains("relay hello timed out"));
2597 assert!(!RelayTransportDead::marks(&error), "{error:#}");
2601 assert!(!RelayTransportDead::marks_failed_handshake(&error));
2602 assert!(started.elapsed() < Duration::from_secs(2));
2603 }
2604
2605 #[cfg(unix)]
2608 const STALLING_RELAY: &str = r#"
2609IFS= read -r hello
2610id=$(printf '%s' "$hello" | sed -n 's/.*"request_id":"\([^"]*\)".*/\1/p')
2611printf '{"request_id":"%s","protocol_version":1,"result":"ok","payload":{"type":"hello","data":{"negotiated":1,"relay_version":"stalling-fixture","session_id":"%s"}}}\n' "$id" "$1"
2612IFS= read -r stalled
2613id=$(printf '%s' "$stalled" | sed -n 's/.*"request_id":"\([^"]*\)".*/\1/p')
2614sleep 5
2615printf '{"request_id":"%s","protocol_version":1,"result":"error","error":{"code":"internal","message":"late reply","retryable":false}}\n' "$id"
2616cat > /dev/null
2617"#;
2618
2619 #[cfg(unix)]
2620 #[tokio::test]
2621 async fn a_timed_out_call_abandons_the_connection_instead_of_desynchronizing_it() {
2622 let spec = CommandSpec::new(
2623 "sh",
2624 ["-c", STALLING_RELAY, "hel-relay-fixture", SESSION_ID],
2625 )
2626 .purpose("stalling relay fixture");
2627 let mut client =
2628 RelayClient::connect_with_timeout(&spec, SESSION_ID, Duration::from_millis(500))
2629 .await
2630 .expect("the fixture answers hello immediately");
2631
2632 let timed_out = client
2633 .status()
2634 .await
2635 .expect_err("the stalled status call must time out");
2636 assert!(
2637 format!("{timed_out:#}").contains("relay status timed out"),
2638 "{timed_out:#}"
2639 );
2640 assert!(!RelayTransportDead::marks(&timed_out), "{timed_out:#}");
2643
2644 let started = std::time::Instant::now();
2649 let subsequent = client
2650 .status()
2651 .await
2652 .expect_err("a call on an abandoned connection must fail");
2653 let elapsed = started.elapsed();
2654 assert!(
2655 format!("{subsequent:#}").contains("relay connection abandoned after status timed out"),
2656 "{subsequent:#}"
2657 );
2658 assert!(
2659 elapsed < Duration::from_millis(250),
2660 "an abandoned connection must fail fast, took {elapsed:?}"
2661 );
2662
2663 let repeated = client
2664 .status()
2665 .await
2666 .expect_err("the connection stays abandoned");
2667 assert!(
2668 format!("{repeated:#}").contains("relay connection abandoned after status timed out"),
2669 "{repeated:#}"
2670 );
2671 }
2672
2673 #[test]
2674 fn an_unsupported_method_answer_still_reads_as_missing_skills_sync() {
2675 let response = mj_core::relay::unsupported_relay_method_response(
2679 "relay-1".into(),
2680 RELAY_PROTOCOL_VERSION,
2681 "skills_state".into(),
2682 );
2683 let encoded = serde_json::to_string(&response).unwrap();
2684 let error = decode_relay_response(&encoded, "relay-1", RELAY_PROTOCOL_VERSION).unwrap_err();
2685 assert!(sync_method_unsupported(&error), "{error:#}");
2686 }
2687
2688 #[tokio::test]
2689 async fn publishing_new_targets_starts_reconciliation_without_waiting_for_the_tick() {
2690 let profile = tempfile::tempdir().unwrap();
2691 let mut coordinator = CredentialSyncCoordinator::spawn();
2692 coordinator.handle().set_targets(vec![CredentialSyncTarget {
2693 session_id: SESSION_ID.into(),
2694 profile_id: "work".into(),
2695 harness: mj_core::config::HarnessKind::Codex,
2696 profile_home: profile.path().to_path_buf(),
2697 sync_github_token: false,
2698 spec: CommandSpec::new("sh", ["-c", "exit 1"]),
2699 }]);
2700
2701 let result = tokio::time::timeout(Duration::from_secs(5), coordinator.result())
2702 .await
2703 .expect("target publication must not wait for the 60-second periodic tick")
2704 .expect("credential coordinator stopped");
2705 assert_eq!(result.profile_id, "work");
2706 assert_eq!(result.outcomes.len(), 1);
2707 assert!(result.outcomes[0].outcome.is_err());
2708 }
2709
2710 #[tokio::test]
2711 async fn response_frame_limit_is_enforced_before_newline() {
2712 let (mut writer, reader) = tokio::io::duplex(32);
2713 let write = tokio::spawn(async move {
2714 writer.write_all(b"123456789\n").await.unwrap();
2715 });
2716 let mut reader = BufReader::new(reader);
2717
2718 let error = read_bounded_frame_with_limit(&mut reader, 8, ExchangeKind::Call)
2719 .await
2720 .unwrap_err();
2721
2722 write.await.unwrap();
2723 assert!(error.to_string().contains("frame is too large"));
2724 assert!(!RelayTransportDead::marks(&error), "{error:#}");
2727 }
2728
2729 #[tokio::test]
2730 async fn a_half_written_response_frame_reports_a_dead_transport() {
2731 let (mut writer, reader) = tokio::io::duplex(32);
2732 writer.write_all(b"{\"partial\":").await.unwrap();
2733 drop(writer);
2734 let mut reader = BufReader::new(reader);
2735
2736 let error = read_bounded_frame(&mut reader, ExchangeKind::Call)
2737 .await
2738 .unwrap_err();
2739
2740 assert!(RelayTransportDead::marks(&error), "{error:#}");
2741 assert!(!RelayTransportDead::marks_failed_handshake(&error));
2742 }
2743
2744 #[test]
2745 fn catch_up_page_stops_at_the_frontier_captured_before_stream_growth() {
2746 let temp = tempfile::tempdir().unwrap();
2747 let mut relay = DurableRelay::open(temp.path(), SESSION_ID, "1.0.0").unwrap();
2748 for message in ["one", "two", "arrived concurrently"] {
2749 relay
2750 .record_observation(RelayObservation::Warning {
2751 message: message.into(),
2752 })
2753 .unwrap();
2754 }
2755 let all = relay.events_after(0, RELAY_EVENT_GENESIS_DIGEST).unwrap();
2756 let previous = RelayCursor {
2757 ordinal: all[0].ordinal,
2758 digest: all[0].digest.clone(),
2759 };
2760 let frontier = RelayCursor {
2761 ordinal: all[1].ordinal,
2762 digest: all[1].digest.clone(),
2763 };
2764 let page = RelayAttachment {
2765 state: relay.operational_state(),
2766 events: all[1..].to_vec(),
2767 through_ordinal: all[2].ordinal,
2768 through_digest: all[2].digest.clone(),
2769 };
2770 let clipped = clip_catch_up_page(page, &previous, &frontier).unwrap();
2771 assert_eq!(clipped.through_ordinal, frontier.ordinal);
2772 assert_eq!(clipped.through_digest, frontier.digest);
2773 assert_eq!(clipped.events.len(), 1);
2774 assert_eq!(clipped.events.last().unwrap().ordinal, 2);
2775 }
2776}