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 hel::hel_config::harness_authentication_marker;
17use hel::hel_credentials::{
18 CredentialSnapshot, CredentialSyncAction, CredentialSyncHandle, CredentialSyncOutcome,
19 CredentialSyncResult, CredentialSyncTarget, SYNC_INTERVAL, SyncAction, SyncTrigger, enqueue,
20 profiles_with_targets, read_credential_file, reconcile, validate_credential_payload,
21 write_credential_file,
22};
23use hel::hel_elicitation::ElicitationResponse;
24use hel::hel_targets::CommandSpec;
25use hel::hel_worker::{
26 MAX_FRAME_BYTES, RELAY_EVENT_GENESIS_DIGEST, RELAY_MIN_PROTOCOL_VERSION,
27 RELAY_PROTOCOL_VERSION, RelayCommand, RelayCursor, RelayErrorCode, RelayEvent,
28 RelayOperationalState, RelayProtocolError, RelayRequest, RelayRequestEnvelope,
29 RelayResponseBody, RelayResponseEnvelope, RelayResponsePayload, RelayVersionRange,
30 ReviewerRequest, validate_relay_event,
31};
32use hel::hel_worker_launch::ReviewerLaunchConfig;
33
34const RELAY_RPC_TIMEOUT: Duration = Duration::from_secs(15);
35const RELAY_SLOW_OPERATION_WARNING: Duration = Duration::from_secs(5);
36const RELAY_HANDSHAKE_TIMEOUT: Duration = Duration::from_secs(300);
40const RELAY_HISTORY_TIMEOUT: Duration = Duration::from_secs(900);
44const RELAY_ACKNOWLEDGE_TIMEOUT: Duration = Duration::from_secs(300);
48const REVIEW_CAPTURE_TIMEOUT: Duration = Duration::from_secs(300);
51const REVIEW_ANALYSIS_TIMEOUT: Duration = Duration::from_secs(660);
55const RELAY_PROXY_DETACH_GRACE: Duration = Duration::from_millis(500);
56const RELAY_PROXY_REAP_POLL: Duration = Duration::from_millis(10);
57
58async fn drain_proxy_stderr(
62 errors: tokio::process::ChildStderr,
63 purpose: String,
64 session_id: String,
65) {
66 let mut lines = BufReader::new(errors).lines();
67 loop {
68 match lines.next_line().await {
69 Ok(Some(line)) if line.trim().is_empty() => continue,
70 Ok(Some(line)) => {
71 tracing::warn!(%session_id, %purpose, %line, "relay proxy stderr")
72 }
73 Ok(None) => return,
74 Err(error) => {
75 tracing::warn!(%session_id, %purpose, %error, "read relay proxy stderr");
76 return;
77 }
78 }
79 }
80}
81
82#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
83pub struct RelayAttachment {
84 pub state: RelayOperationalState,
85 pub events: Vec<RelayEvent>,
86 pub through_ordinal: u64,
87 pub through_digest: String,
88}
89
90#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
92pub struct StartedReviewer {
93 pub native_session_id: Option<String>,
95 pub config_options: Vec<agent_client_protocol::schema::v1::SessionConfigOption>,
98 pub reused: bool,
100 pub state: RelayOperationalState,
101}
102
103#[derive(Debug, Clone)]
107pub struct RelayEventPage {
108 pub events: Vec<RelayEvent>,
109 pub through_ordinal: u64,
110 pub through_digest: String,
111}
112
113#[derive(Debug, Clone)]
114pub struct RelayCatchUp {
115 pub state: RelayOperationalState,
116 pub frontier: RelayCursor,
117 pub first_page: RelayEventPage,
118}
119
120#[derive(Debug)]
121pub struct RelayRejected(pub RelayProtocolError);
122
123impl std::fmt::Display for RelayRejected {
124 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
125 write!(
126 formatter,
127 "relay rejected request ({:?}): {}",
128 self.0.code, self.0.message
129 )
130 }
131}
132
133impl std::error::Error for RelayRejected {}
134
135impl RelayRejected {
136 pub fn is_desynchronized(&self) -> bool {
137 self.0.code == RelayErrorCode::Desynchronized
138 }
139
140 pub fn is_retryable(&self) -> bool {
143 self.0.retryable
144 }
145}
146
147#[derive(Debug)]
154pub struct RelayTransportDead {
155 message: String,
156 handshake_failed: bool,
157}
158
159impl std::fmt::Display for RelayTransportDead {
160 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
161 formatter.write_str(&self.message)
162 }
163}
164
165impl std::error::Error for RelayTransportDead {}
166
167impl RelayTransportDead {
168 pub fn new(message: impl Into<String>) -> Self {
169 Self {
170 message: message.into(),
171 handshake_failed: false,
172 }
173 }
174
175 fn from_io(error: std::io::Error, kind: ExchangeKind) -> Self {
178 Self::during_exchange(error.to_string(), kind)
179 }
180
181 fn during_exchange(message: impl Into<String>, kind: ExchangeKind) -> Self {
182 Self {
183 message: message.into(),
184 handshake_failed: kind == ExchangeKind::Handshake,
185 }
186 }
187
188 pub fn marks(error: &anyhow::Error) -> bool {
190 error.downcast_ref::<Self>().is_some()
191 }
192
193 pub fn marks_failed_handshake(error: &anyhow::Error) -> bool {
198 error
199 .downcast_ref::<Self>()
200 .is_some_and(|failure| failure.handshake_failed)
201 }
202}
203
204#[derive(Clone, Copy, PartialEq, Eq)]
211enum ExchangeKind {
212 Handshake,
213 Call,
214}
215
216pub struct RelayClient {
222 child: Option<Child>,
223 input: Option<ChildStdin>,
224 output: BufReader<ChildStdout>,
225 request_timeout: Duration,
226 abandoned: Option<String>,
229 next_request: u64,
230 connection_nonce: u64,
231 protocol_version: u32,
232 session_id: String,
233 relay_version: String,
234 worker_build: Option<String>,
237 latest_ordinal: u64,
238 latest_digest: String,
239}
240
241impl RelayClient {
242 pub async fn connect(spec: &CommandSpec, expected_session_id: &str) -> Result<Self> {
243 Self::connect_with_timeouts(
244 spec,
245 expected_session_id,
246 RELAY_RPC_TIMEOUT,
247 RELAY_HANDSHAKE_TIMEOUT,
248 )
249 .await
250 }
251
252 #[cfg(all(test, unix))]
253 async fn connect_with_timeout(
254 spec: &CommandSpec,
255 expected_session_id: &str,
256 request_timeout: Duration,
257 ) -> Result<Self> {
258 Self::connect_with_timeouts(spec, expected_session_id, request_timeout, request_timeout)
259 .await
260 }
261
262 async fn connect_with_timeouts(
263 spec: &CommandSpec,
264 expected_session_id: &str,
265 request_timeout: Duration,
266 handshake_timeout: Duration,
267 ) -> Result<Self> {
268 let mut child = Command::new(&spec.program)
269 .args(&spec.args)
270 .envs(&spec.env)
271 .stdin(Stdio::piped())
272 .stdout(Stdio::piped())
273 .stderr(Stdio::piped())
277 .kill_on_drop(true)
278 .spawn()
279 .with_context(|| format!("start session relay proxy for {}", spec.purpose))
280 .map_err(|error| {
281 tracing::warn!(
282 session_id = %expected_session_id,
283 operation = "connect",
284 purpose = %spec.purpose,
285 error = %error,
286 "could not start relay proxy"
287 );
288 error
289 })?;
290 if let Some(errors) = child.stderr.take() {
291 let purpose = spec.purpose.clone();
292 let session_id = expected_session_id.to_owned();
293 tokio::spawn(drain_proxy_stderr(errors, purpose, session_id));
294 }
295 let input = child
296 .stdin
297 .take()
298 .context("relay proxy stdin unavailable")
299 .map_err(|error| {
300 tracing::warn!(
301 session_id = %expected_session_id,
302 operation = "connect",
303 purpose = %spec.purpose,
304 error = %error,
305 "relay proxy did not provide stdin"
306 );
307 error
308 })?;
309 let output = child
310 .stdout
311 .take()
312 .context("relay proxy stdout unavailable")
313 .map_err(|error| {
314 tracing::warn!(
315 session_id = %expected_session_id,
316 operation = "connect",
317 purpose = %spec.purpose,
318 error = %error,
319 "relay proxy did not provide stdout"
320 );
321 error
322 })?;
323 let mut nonce_bytes = [0_u8; 8];
324 getrandom::fill(&mut nonce_bytes).map_err(|error| {
325 let error = anyhow!("generate relay request nonce: {error}");
326 tracing::warn!(
327 session_id = %expected_session_id,
328 operation = "connect",
329 error = %error,
330 "could not initialize relay request nonce"
331 );
332 error
333 })?;
334 let mut client = Self {
335 child: Some(child),
336 input: Some(input),
337 output: BufReader::new(output),
338 request_timeout,
339 abandoned: None,
340 next_request: 1,
341 connection_nonce: u64::from_le_bytes(nonce_bytes),
342 protocol_version: RELAY_PROTOCOL_VERSION,
343 session_id: expected_session_id.to_owned(),
347 relay_version: String::new(),
348 worker_build: None,
349 latest_ordinal: 0,
350 latest_digest: RELAY_EVENT_GENESIS_DIGEST.to_owned(),
351 };
352 let response = client
353 .call_hello(
354 RelayRequest::Hello {
355 controller_version: env!("CARGO_PKG_VERSION").to_owned(),
356 supported: RelayVersionRange::CURRENT,
357 },
358 handshake_timeout,
359 )
360 .await?;
361 let RelayResponsePayload::Hello {
362 negotiated,
363 relay_version,
364 session_id,
365 worker_build,
366 } = response
367 else {
368 let error = anyhow!("relay returned an unexpected hello response");
369 log_relay_client_failure(&client, "hello", "relay-hello", &error);
370 return Err(error);
371 };
372 if session_id != expected_session_id {
373 let error = anyhow!("relay belongs to session {session_id}, not {expected_session_id}");
374 log_relay_client_failure(&client, "hello", "relay-hello", &error);
375 return Err(error);
376 }
377 if !RelayVersionRange::CURRENT.contains(negotiated) {
378 let error = anyhow!(
379 "relay negotiated unsupported protocol {negotiated}; this controller supports {}-{}",
380 RELAY_MIN_PROTOCOL_VERSION,
381 RELAY_PROTOCOL_VERSION
382 );
383 log_relay_client_failure(&client, "hello", "relay-hello", &error);
384 return Err(error);
385 }
386 client.protocol_version = negotiated;
387 client.session_id = session_id;
388 client.relay_version = relay_version;
389 client.worker_build = worker_build;
390 Ok(client)
391 }
392
393 pub fn session_id(&self) -> &str {
394 &self.session_id
395 }
396
397 pub const fn supports_project_memory_sync(&self) -> bool {
398 self.protocol_version >= 4
399 }
400
401 pub fn relay_version(&self) -> &str {
402 &self.relay_version
403 }
404
405 pub fn worker_build(&self) -> Option<&str> {
409 self.worker_build.as_deref()
410 }
411
412 pub fn protocol_version(&self) -> u32 {
413 self.protocol_version
414 }
415
416 pub fn latest_ordinal(&self) -> u64 {
417 self.latest_ordinal
418 }
419
420 pub fn latest_digest(&self) -> &str {
421 &self.latest_digest
422 }
423
424 pub async fn attach(
425 &mut self,
426 after_ordinal: u64,
427 after_digest: impl Into<String>,
428 ) -> Result<RelayAttachment> {
429 let after_digest = after_digest.into();
430 match self
431 .call_with_timeout(
432 RelayRequest::Attach {
433 after_ordinal,
434 after_digest: after_digest.clone(),
435 },
436 RELAY_HISTORY_TIMEOUT,
437 )
438 .await?
439 {
440 RelayResponsePayload::Attached {
441 state,
442 events,
443 through_ordinal,
444 through_digest,
445 } => {
446 let mut cursor = RelayCursor {
447 ordinal: after_ordinal,
448 digest: after_digest,
449 };
450 for event in &events {
451 validate_relay_event(cursor.ordinal, &cursor.digest, event)
452 .context("verify relay attachment event chain")?;
453 cursor.ordinal = event.ordinal;
454 cursor.digest.clone_from(&event.digest);
455 }
456 if cursor.ordinal != through_ordinal || cursor.digest != through_digest {
457 bail!("relay attachment frontier does not match its event chain");
458 }
459 self.latest_ordinal = state.latest_ordinal;
460 self.latest_digest = state.latest_digest.clone();
461 Ok(RelayAttachment {
462 state,
463 events,
464 through_ordinal,
465 through_digest,
466 })
467 }
468 _ => bail!("relay returned an unexpected attach response"),
469 }
470 }
471
472 pub async fn begin_catch_up(
477 &mut self,
478 after_ordinal: u64,
479 after_digest: impl Into<String>,
480 ) -> Result<RelayCatchUp> {
481 let after_digest = after_digest.into();
482 let first = self.attach(after_ordinal, after_digest.clone()).await?;
483 let frontier = RelayCursor {
484 ordinal: first.state.latest_ordinal,
485 digest: first.state.latest_digest.clone(),
486 };
487 let previous = RelayCursor {
488 ordinal: after_ordinal,
489 digest: after_digest,
490 };
491 let state = first.state.clone();
492 let first_page = clip_catch_up_page(first, &previous, &frontier)?;
493 Ok(RelayCatchUp {
494 state,
495 frontier,
496 first_page,
497 })
498 }
499
500 pub async fn next_catch_up_page(
504 &mut self,
505 previous: &RelayCursor,
506 frontier: &RelayCursor,
507 ) -> Result<RelayEventPage> {
508 if previous.ordinal >= frontier.ordinal {
509 bail!("relay catch-up is already at its fixed frontier");
510 }
511 let attachment = self
512 .attach(previous.ordinal, previous.digest.clone())
513 .await?;
514 clip_catch_up_page(attachment, previous, frontier)
515 }
516
517 pub async fn acknowledge(
518 &mut self,
519 through_ordinal: u64,
520 through_digest: impl Into<String>,
521 ) -> Result<RelayCursor> {
522 match self
523 .call_with_timeout(
524 RelayRequest::Acknowledge {
525 through_ordinal,
526 through_digest: through_digest.into(),
527 },
528 RELAY_ACKNOWLEDGE_TIMEOUT,
529 )
530 .await?
531 {
532 RelayResponsePayload::Acknowledged {
533 through_ordinal,
534 through_digest,
535 } => Ok(RelayCursor {
536 ordinal: through_ordinal,
537 digest: through_digest,
538 }),
539 _ => bail!("relay returned an unexpected acknowledgement response"),
540 }
541 }
542
543 pub async fn status(&mut self) -> Result<RelayOperationalState> {
544 match self.call(RelayRequest::Status).await? {
545 RelayResponsePayload::Status(status) => {
546 self.latest_ordinal = status.latest_ordinal;
547 self.latest_digest = status.latest_digest.clone();
548 Ok(status)
549 }
550 _ => bail!("relay returned an unexpected status response"),
551 }
552 }
553
554 pub async fn credential_state(&mut self) -> Result<CredentialSnapshot> {
557 credential_snapshot(self.call(RelayRequest::CredentialState).await?)
558 }
559
560 pub async fn read_credentials(&mut self) -> Result<Vec<u8>> {
563 match self.call(RelayRequest::ReadCredentials).await? {
564 RelayResponsePayload::Credentials { data } => BASE64
565 .decode(data.as_bytes())
566 .context("decode relay credential payload"),
567 _ => bail!("relay returned an unexpected credential response"),
568 }
569 }
570
571 pub async fn install_credentials(&mut self, bytes: &[u8]) -> Result<CredentialSnapshot> {
574 credential_snapshot(
575 self.call(RelayRequest::InstallCredentials {
576 data: BASE64.encode(bytes),
577 })
578 .await?,
579 )
580 }
581
582 pub async fn github_token_state(
583 &mut self,
584 ) -> Result<hel::hel_credentials::GithubTokenSnapshot> {
585 github_token_snapshot(self.call(RelayRequest::GithubTokenState).await?)
586 }
587
588 pub async fn install_github_token(
589 &mut self,
590 token: &str,
591 ) -> Result<hel::hel_credentials::GithubTokenSnapshot> {
592 github_token_snapshot(
593 self.call(RelayRequest::InstallGithubToken {
594 data: BASE64.encode(token.as_bytes()),
595 })
596 .await?,
597 )
598 }
599
600 pub async fn remove_github_token(
601 &mut self,
602 ) -> Result<hel::hel_credentials::GithubTokenSnapshot> {
603 github_token_snapshot(self.call(RelayRequest::RemoveGithubToken).await?)
604 }
605
606 pub async fn skills_state(&mut self) -> Result<hel::hel_skills::SkillsSyncState> {
609 skills_sync_state(self.call(RelayRequest::SkillsState).await?)
610 }
611
612 pub async fn install_prompt_context(&mut self, text: String) -> Result<()> {
615 let request = RelayRequest::InstallPromptContext { text };
616 if !request.supported_at(self.protocol_version) {
617 bail!(
618 "hidden prompt context requires relay protocol {}; this session negotiated {}",
619 request.minimum_protocol(),
620 self.protocol_version
621 );
622 }
623 match self.call(request).await? {
624 RelayResponsePayload::PromptContextInstalled => Ok(()),
625 _ => bail!("relay returned an unexpected prompt-context response"),
626 }
627 }
628
629 pub async fn project_memory_snapshot(
630 &mut self,
631 ) -> Result<(
632 hel::hel_project_memory::ProjectMemorySnapshot,
633 hel::hel_project_memory::ProjectMemorySnapshot,
634 )> {
635 let request = RelayRequest::ProjectMemorySnapshot;
636 if !request.supported_at(self.protocol_version) {
637 bail!(
638 "project memory synchronization requires relay protocol {}; this session negotiated {}",
639 request.minimum_protocol(),
640 self.protocol_version
641 );
642 }
643 match self.call(request).await? {
644 RelayResponsePayload::ProjectMemorySnapshot { baseline, replica } => {
645 Ok((baseline, replica))
646 }
647 _ => bail!("relay returned an unexpected project-memory response"),
648 }
649 }
650
651 pub async fn install_project_memory_snapshot(
652 &mut self,
653 snapshot: hel::hel_project_memory::ProjectMemorySnapshot,
654 ) -> Result<()> {
655 let request = RelayRequest::InstallProjectMemorySnapshot { snapshot };
656 if !request.supported_at(self.protocol_version) {
657 bail!(
658 "project memory synchronization requires relay protocol {}; this session negotiated {}",
659 request.minimum_protocol(),
660 self.protocol_version
661 );
662 }
663 match self.call(request).await? {
664 RelayResponsePayload::ProjectMemorySnapshotInstalled => Ok(()),
665 _ => bail!("relay returned an unexpected project-memory install response"),
666 }
667 }
668
669 pub async fn install_skills(
673 &mut self,
674 archive_bytes: &[u8],
675 ) -> Result<hel::hel_skills::SkillsSyncState> {
676 skills_sync_state(
677 self.call(RelayRequest::InstallSkills {
678 data: BASE64.encode(archive_bytes),
679 })
680 .await?,
681 )
682 }
683
684 pub async fn ensure_attachment(
686 &mut self,
687 reference: &hel::hel_attachment::AttachmentRef,
688 ) -> Result<()> {
689 anyhow::ensure!(
690 self.protocol_version >= 8,
691 "photo attachments require an updated worker (protocol 8); upgrade the worker and retry"
692 );
693 match self
694 .call(RelayRequest::AttachmentPresent {
695 reference: reference.clone(),
696 })
697 .await?
698 {
699 RelayResponsePayload::AttachmentPresent { present: true } => return Ok(()),
700 RelayResponsePayload::AttachmentPresent { present: false } => {}
701 _ => bail!("unexpected image presence response"),
702 }
703 let store = hel::hel_attachment::AttachmentStore::controller(&self.session_id)?;
704 let reference_copy = reference.clone();
705 let bytes = tokio::task::spawn_blocking(move || store.read(&reference_copy))
706 .await
707 .context("image loading task failed")??;
708 match self
709 .call(RelayRequest::InstallAttachment {
710 reference: reference.clone(),
711 data: BASE64.encode(bytes),
712 })
713 .await?
714 {
715 RelayResponsePayload::AttachmentInstalled => Ok(()),
716 _ => bail!("unexpected image upload response"),
717 }
718 }
719
720 pub async fn cache_attachment(
722 &mut self,
723 reference: &hel::hel_attachment::AttachmentRef,
724 ) -> Result<()> {
725 let store = hel::hel_attachment::AttachmentStore::controller(&self.session_id)?;
726 let local = store.clone();
727 let reference_copy = reference.clone();
728 if tokio::task::spawn_blocking(move || local.contains(&reference_copy))
729 .await
730 .context("image lookup task failed")??
731 {
732 return Ok(());
733 }
734 let RelayResponsePayload::AttachmentData { data } = self
735 .call(RelayRequest::ReadAttachment {
736 reference: reference.clone(),
737 })
738 .await?
739 else {
740 bail!("unexpected image download response")
741 };
742 let reference = reference.clone();
743 tokio::task::spawn_blocking(move || {
744 anyhow::ensure!(
745 data.len() <= hel::hel_attachment::MAX_IMAGE_BYTES.div_ceil(3) * 4,
746 "image download is too large"
747 );
748 store.install(&reference, &BASE64.decode(data)?)
749 })
750 .await
751 .context("image caching task failed")?
752 }
753
754 pub async fn submit(
755 &mut self,
756 command_id: impl Into<String>,
757 command: RelayCommand,
758 ) -> Result<u64> {
759 let command_id = command_id.into();
760 if let RelayCommand::Prompt { prompt } = &command {
761 for reference in hel::hel_attachment::references(prompt)? {
762 self.ensure_attachment(&reference).await?;
763 }
764 }
765 match self
766 .call(RelayRequest::Submit {
767 command_id: command_id.clone(),
768 command,
769 })
770 .await?
771 {
772 RelayResponsePayload::Accepted {
773 command_id: accepted_id,
774 ordinal,
775 } if accepted_id == command_id => Ok(ordinal),
776 RelayResponsePayload::Accepted {
777 command_id: accepted_id,
778 ..
779 } => bail!("relay accepted command under ID {accepted_id}, expected {command_id}"),
780 _ => bail!("relay returned an unexpected command response"),
781 }
782 }
783
784 pub async fn start_reviewer(
791 &mut self,
792 role: Option<&str>,
793 config: ReviewerLaunchConfig,
794 ) -> Result<StartedReviewer> {
795 let request = self.reviewer_request(
796 role,
797 ReviewerRequest::Start {
798 config: Box::new(config),
799 },
800 )?;
801 match self
802 .call_with_timeout(request, RELAY_HANDSHAKE_TIMEOUT)
803 .await?
804 {
805 RelayResponsePayload::ReviewerStarted {
806 native_session_id,
807 config_options,
808 reused,
809 state,
810 } => Ok(StartedReviewer {
811 native_session_id,
812 config_options,
813 reused,
814 state: *state,
815 }),
816 _ => bail!("relay returned an unexpected reviewer start response"),
817 }
818 }
819
820 pub async fn attach_reviewer(
823 &mut self,
824 role: Option<&str>,
825 after_ordinal: u64,
826 after_digest: impl Into<String>,
827 ) -> Result<RelayAttachment> {
828 let after_digest = after_digest.into();
829 let request = self.reviewer_request(
830 role,
831 ReviewerRequest::Attach {
832 after_ordinal,
833 after_digest: after_digest.clone(),
834 },
835 )?;
836 let payload = self
837 .call_with_timeout(request, RELAY_HISTORY_TIMEOUT)
838 .await?;
839 let RelayResponsePayload::Attached {
840 state,
841 events,
842 through_ordinal,
843 through_digest,
844 } = payload
845 else {
846 bail!("relay returned an unexpected reviewer attach response");
847 };
848 let mut cursor = RelayCursor {
851 ordinal: after_ordinal,
852 digest: after_digest,
853 };
854 for event in &events {
855 validate_relay_event(cursor.ordinal, &cursor.digest, event)
856 .context("verify reviewer attachment event chain")?;
857 cursor.ordinal = event.ordinal;
858 cursor.digest.clone_from(&event.digest);
859 }
860 if cursor.ordinal != through_ordinal || cursor.digest != through_digest {
861 bail!("reviewer attachment frontier does not match its event chain");
862 }
863 Ok(RelayAttachment {
864 state,
865 events,
866 through_ordinal,
867 through_digest,
868 })
869 }
870
871 pub async fn acknowledge_reviewer(
874 &mut self,
875 role: Option<&str>,
876 through_ordinal: u64,
877 through_digest: impl Into<String>,
878 ) -> Result<RelayCursor> {
879 let request = self.reviewer_request(
880 role,
881 ReviewerRequest::Acknowledge {
882 through_ordinal,
883 through_digest: through_digest.into(),
884 },
885 )?;
886 match self
887 .call_with_timeout(request, RELAY_ACKNOWLEDGE_TIMEOUT)
888 .await?
889 {
890 RelayResponsePayload::Acknowledged {
891 through_ordinal,
892 through_digest,
893 } => Ok(RelayCursor {
894 ordinal: through_ordinal,
895 digest: through_digest,
896 }),
897 _ => bail!("relay returned an unexpected reviewer acknowledgement response"),
898 }
899 }
900
901 pub async fn submit_to_reviewer(
903 &mut self,
904 role: Option<&str>,
905 command_id: impl Into<String>,
906 command: RelayCommand,
907 ) -> Result<u64> {
908 let command_id = command_id.into();
909 let request = self.reviewer_request(
910 role,
911 ReviewerRequest::Submit {
912 command_id: command_id.clone(),
913 command,
914 },
915 )?;
916 match self.call(request).await? {
917 RelayResponsePayload::Accepted {
918 command_id: accepted_id,
919 ordinal,
920 } if accepted_id == command_id => Ok(ordinal),
921 RelayResponsePayload::Accepted {
922 command_id: accepted_id,
923 ..
924 } => bail!("reviewer accepted command under ID {accepted_id}, expected {command_id}"),
925 _ => bail!("relay returned an unexpected reviewer command response"),
926 }
927 }
928
929 pub async fn reviewer_status(&mut self, role: Option<&str>) -> Result<RelayOperationalState> {
930 let request = self.reviewer_request(role, ReviewerRequest::Status)?;
931 match self.call(request).await? {
932 RelayResponsePayload::Status(status) => Ok(status),
933 _ => bail!("relay returned an unexpected reviewer status response"),
934 }
935 }
936
937 pub async fn respond_to_reviewer(
939 &mut self,
940 role: Option<&str>,
941 elicitation_id: String,
942 response: ElicitationResponse,
943 ) -> Result<()> {
944 let request = self.reviewer_request(
945 role,
946 ReviewerRequest::RespondElicitation {
947 elicitation_id: elicitation_id.clone(),
948 response,
949 },
950 )?;
951 match self.call(request).await? {
952 RelayResponsePayload::ElicitationResolved {
953 elicitation_id: resolved,
954 } if resolved == elicitation_id => Ok(()),
955 RelayResponsePayload::ElicitationResolved {
956 elicitation_id: resolved,
957 } => bail!("reviewer resolved elicitation {resolved:?}, expected {elicitation_id:?}"),
958 _ => bail!("relay returned an unexpected reviewer elicitation response"),
959 }
960 }
961
962 pub async fn pause_reviewer(&mut self, role: Option<&str>) -> Result<()> {
965 let request = self.reviewer_request(role, ReviewerRequest::Pause)?;
966 match self
967 .call_with_timeout(request, RELAY_ACKNOWLEDGE_TIMEOUT)
968 .await?
969 {
970 RelayResponsePayload::ReviewerPaused => Ok(()),
971 _ => bail!("relay returned an unexpected reviewer pause response"),
972 }
973 }
974
975 pub async fn capture_review_delta(
978 &mut self,
979 role: Option<&str>,
980 baselines: std::collections::BTreeMap<std::path::PathBuf, String>,
981 ) -> Result<Vec<hel::hel_worker::RepoDelta>> {
982 let request = self.reviewer_request(role, ReviewerRequest::CaptureDelta { baselines })?;
983 match self
984 .call_with_timeout(request, REVIEW_CAPTURE_TIMEOUT)
985 .await?
986 {
987 RelayResponsePayload::ReviewDelta { repositories } => Ok(repositories),
988 _ => bail!("relay returned an unexpected review capture response"),
989 }
990 }
991
992 pub async fn advance_review_baseline(
995 &mut self,
996 role: Option<&str>,
997 trees: std::collections::BTreeMap<std::path::PathBuf, String>,
998 ) -> Result<()> {
999 let request = self.reviewer_request(role, ReviewerRequest::AdvanceBaseline { trees })?;
1000 match self
1001 .call_with_timeout(request, REVIEW_CAPTURE_TIMEOUT)
1002 .await?
1003 {
1004 RelayResponsePayload::ReviewBaselineAdvanced => Ok(()),
1005 _ => bail!("relay returned an unexpected review baseline response"),
1006 }
1007 }
1008
1009 pub async fn analyze_review_delta(
1012 &mut self,
1013 role: Option<&str>,
1014 repositories: Vec<hel::hel_worker::AnalyzeDeltaRepository>,
1015 ) -> Result<String> {
1016 let request =
1017 self.reviewer_request(role, ReviewerRequest::AnalyzeDelta { repositories })?;
1018 match self
1019 .call_with_timeout(request, REVIEW_ANALYSIS_TIMEOUT)
1020 .await?
1021 {
1022 RelayResponsePayload::ReviewChangedFunctions { packet } => Ok(packet),
1023 _ => bail!("relay returned an unexpected review analysis response"),
1024 }
1025 }
1026
1027 pub async fn take_lane_dispatches(
1030 &mut self,
1031 ) -> Result<Vec<hel::hel_review::lanes::ReviewSubagentRequest>> {
1032 let request = self.reviewer_request(None, ReviewerRequest::TakeLaneDispatches)?;
1033 match self.call(request).await? {
1034 RelayResponsePayload::LaneDispatches { requests } => Ok(requests),
1035 _ => bail!("relay returned an unexpected lane dispatch response"),
1036 }
1037 }
1038
1039 fn reviewer_request(
1042 &self,
1043 role: Option<&str>,
1044 request: ReviewerRequest,
1045 ) -> Result<RelayRequest> {
1046 let request = RelayRequest::Reviewer {
1047 role: role.map(str::to_owned),
1048 request,
1049 };
1050 if !request.supported_at(self.protocol_version) {
1051 bail!(
1052 "a second opinion requires relay protocol {}; this session negotiated {}",
1053 request.minimum_protocol(),
1054 self.protocol_version
1055 );
1056 }
1057 Ok(request)
1058 }
1059
1060 pub async fn respond_elicitation(
1063 &mut self,
1064 elicitation_id: String,
1065 response: ElicitationResponse,
1066 ) -> Result<()> {
1067 let request = RelayRequest::RespondElicitation {
1068 elicitation_id: elicitation_id.clone(),
1069 response,
1070 };
1071 if !request.supported_at(self.protocol_version) {
1072 bail!(
1073 "elicitation responses require relay protocol {}; this session negotiated {}",
1074 request.minimum_protocol(),
1075 self.protocol_version
1076 );
1077 }
1078 match self.call(request).await? {
1079 RelayResponsePayload::ElicitationResolved {
1080 elicitation_id: resolved,
1081 } if resolved == elicitation_id => Ok(()),
1082 RelayResponsePayload::ElicitationResolved {
1083 elicitation_id: resolved,
1084 } => bail!("relay resolved elicitation {resolved:?}, expected {elicitation_id:?}"),
1085 _ => bail!("relay returned an unexpected elicitation response"),
1086 }
1087 }
1088
1089 pub async fn detach(mut self) -> Result<()> {
1090 self.input
1091 .take()
1092 .expect("connected relay owns proxy stdin")
1093 .shutdown()
1094 .await
1095 .context("close relay proxy stdin")?;
1096 let mut child = self.child.take().expect("connected relay owns proxy child");
1097 match tokio::time::timeout(RELAY_PROXY_DETACH_GRACE, child.wait()).await {
1098 Ok(status) => {
1099 status.context("wait for relay proxy")?;
1100 }
1101 Err(_) => {
1102 if let Err(error) = child.start_kill().context("stop relay proxy") {
1103 tracing::warn!(
1104 session_id = %self.session_id,
1105 operation = "detach",
1106 %error,
1107 "could not stop relay proxy after detach timeout"
1108 );
1109 return Err(error);
1110 }
1111 if let Err(error) = child.wait().await {
1112 tracing::warn!(
1113 session_id = %self.session_id,
1114 operation = "detach",
1115 %error,
1116 "could not reap relay proxy after stopping it"
1117 );
1118 }
1119 }
1120 }
1121 Ok(())
1122 }
1123
1124 async fn call(&mut self, request: RelayRequest) -> Result<RelayResponsePayload> {
1125 self.call_with_timeout(request, self.request_timeout).await
1126 }
1127
1128 async fn call_with_timeout(
1129 &mut self,
1130 request: RelayRequest,
1131 timeout: Duration,
1132 ) -> Result<RelayResponsePayload> {
1133 let operation = request.method_name();
1134 let request_id = self.request_id();
1135 let envelope = RelayRequestEnvelope {
1136 request_id: request_id.clone(),
1137 protocol_version: self.protocol_version,
1138 request,
1139 };
1140 let line = match self
1141 .exchange(&envelope, operation, timeout, ExchangeKind::Call)
1142 .await
1143 {
1144 Ok(line) => line,
1145 Err(error) => {
1146 log_relay_client_failure(self, operation, &request_id, &error);
1147 return Err(error);
1148 }
1149 };
1150 let result = decode_relay_response(&line, &request_id, self.protocol_version)
1151 .with_context(|| format!("relay {} could not perform {operation}", self.relay_version));
1152 if let Err(error) = &result {
1153 log_relay_client_failure(self, operation, &request_id, error);
1154 }
1155 result
1156 }
1157
1158 async fn call_hello(
1159 &mut self,
1160 request: RelayRequest,
1161 timeout: Duration,
1162 ) -> Result<RelayResponsePayload> {
1163 let operation = request.method_name();
1164 let request_id = self.request_id();
1165 let envelope = RelayRequestEnvelope {
1166 request_id: request_id.clone(),
1167 protocol_version: RELAY_PROTOCOL_VERSION,
1168 request,
1169 };
1170 let line = match self
1171 .exchange(&envelope, operation, timeout, ExchangeKind::Handshake)
1172 .await
1173 {
1174 Ok(line) => line,
1175 Err(error) => {
1176 log_relay_client_failure(self, operation, &request_id, &error);
1177 return Err(error);
1178 }
1179 };
1180 let result = decode_relay_hello_response(&line, &request_id);
1181 if let Err(error) = &result {
1182 log_relay_client_failure(self, operation, &request_id, error);
1183 }
1184 result
1185 }
1186
1187 async fn exchange(
1197 &mut self,
1198 envelope: &RelayRequestEnvelope,
1199 operation: &str,
1200 timeout: Duration,
1201 kind: ExchangeKind,
1202 ) -> Result<String> {
1203 if let Some(reason) = &self.abandoned {
1204 bail!("{reason}");
1205 }
1206 let mut frame = serde_json::to_vec(envelope)?;
1207 if frame.len() > MAX_FRAME_BYTES {
1208 bail!("relay {operation} request frame is too large");
1209 }
1210 frame.push(b'\n');
1211 let session_id = self.session_id.clone();
1212 let exchanged = tokio::time::timeout(timeout, async {
1213 self.input
1214 .as_mut()
1215 .expect("connected relay owns proxy stdin")
1216 .write_all(&frame)
1217 .await
1218 .map_err(|error| RelayTransportDead::from_io(error, kind))
1219 .with_context(|| format!("write relay {operation} request"))?;
1220 self.input
1221 .as_mut()
1222 .expect("connected relay owns proxy stdin")
1223 .flush()
1224 .await
1225 .map_err(|error| RelayTransportDead::from_io(error, kind))
1226 .with_context(|| format!("flush relay {operation} request"))?;
1227 let response = read_bounded_frame(&mut self.output, kind);
1228 tokio::pin!(response);
1229 let response = tokio::select! {
1230 response = &mut response => response,
1231 () = tokio::time::sleep(RELAY_SLOW_OPERATION_WARNING) => {
1232 tracing::warn!(
1233 %session_id,
1234 %operation,
1235 warning_after_seconds = RELAY_SLOW_OPERATION_WARNING.as_secs_f64(),
1236 timeout_seconds = timeout.as_secs_f64(),
1237 "relay operation is still waiting for its response"
1238 );
1239 response.await
1240 }
1241 };
1242 response
1243 .with_context(|| format!("read relay {operation} response"))?
1244 .ok_or_else(|| {
1245 anyhow::Error::new(RelayTransportDead::during_exchange(
1246 format!("relay proxy disconnected during {operation}"),
1247 kind,
1248 ))
1249 })
1250 })
1251 .await;
1252 match exchanged {
1253 Ok(line) => line,
1254 Err(_elapsed) => {
1255 let seconds = timeout.as_secs_f64();
1256 tracing::warn!(
1257 %session_id,
1258 %operation,
1259 timeout_seconds = seconds,
1260 "relay operation timed out; abandoning its sequential connection"
1261 );
1262 self.abandoned = Some(format!(
1263 "relay connection abandoned after {operation} timed out after {seconds} seconds"
1264 ));
1265 let timed_out = format!("relay {operation} timed out after {seconds} seconds");
1266 Err(anyhow!(timed_out))
1267 }
1268 }
1269 }
1270
1271 fn request_id(&mut self) -> String {
1272 let id = format!("relay-{:016x}-{}", self.connection_nonce, self.next_request);
1273 self.next_request = self.next_request.wrapping_add(1);
1274 id
1275 }
1276}
1277
1278fn log_relay_client_failure(
1283 client: &RelayClient,
1284 operation: &str,
1285 request_id: &str,
1286 error: &anyhow::Error,
1287) {
1288 let rejection = error.chain().find_map(|cause| {
1289 cause
1290 .downcast_ref::<RelayRejected>()
1291 .map(|rejected| &rejected.0)
1292 });
1293 let transport_dead = RelayTransportDead::marks(error);
1294 match rejection {
1295 Some(rejection) => tracing::warn!(
1296 session_id = %client.session_id,
1297 relay_version = %client.relay_version,
1298 %operation,
1299 %request_id,
1300 relay_error_code = ?rejection.code,
1301 relay_retryable = rejection.retryable,
1302 transport_dead,
1303 error = %error,
1304 "relay request rejected"
1305 ),
1306 None => tracing::warn!(
1307 session_id = %client.session_id,
1308 relay_version = %client.relay_version,
1309 %operation,
1310 %request_id,
1311 transport_dead,
1312 error = %error,
1313 "relay request failed"
1314 ),
1315 }
1316}
1317
1318impl Drop for RelayClient {
1319 fn drop(&mut self) {
1320 drop(self.input.take());
1325 let Some(child) = self.child.take() else {
1326 return;
1327 };
1328 let session_id = self.session_id.clone();
1329 if let Err(error) = std::thread::Builder::new()
1330 .name("hel-relay-reaper".into())
1331 .spawn(move || reap_dropped_relay_proxy(child, session_id))
1332 {
1333 tracing::warn!(
1334 session_id = %self.session_id,
1335 %error,
1336 "could not start dropped relay proxy reaper"
1337 );
1338 }
1339 }
1340}
1341
1342fn reap_dropped_relay_proxy(mut child: Child, session_id: String) {
1345 let deadline = Instant::now() + RELAY_PROXY_DETACH_GRACE;
1346 loop {
1347 match child.try_wait() {
1348 Ok(Some(status)) => {
1349 if !status.success() {
1350 tracing::warn!(
1351 %session_id,
1352 %status,
1353 "dropped relay proxy exited unsuccessfully"
1354 );
1355 }
1356 return;
1357 }
1358 Ok(None) if Instant::now() < deadline => {
1359 std::thread::sleep(RELAY_PROXY_REAP_POLL);
1360 }
1361 Ok(None) => break,
1362 Err(error) => {
1363 tracing::warn!(%session_id, %error, "could not reap dropped relay proxy");
1364 return;
1365 }
1366 }
1367 }
1368
1369 if let Err(error) = child.start_kill()
1370 && error.kind() != std::io::ErrorKind::NotFound
1371 {
1372 tracing::warn!(%session_id, %error, "could not stop dropped relay proxy");
1373 return;
1374 }
1375 let deadline = Instant::now() + RELAY_PROXY_DETACH_GRACE;
1376 loop {
1377 match child.try_wait() {
1378 Ok(Some(_)) => return,
1379 Ok(None) if Instant::now() < deadline => {
1380 std::thread::sleep(RELAY_PROXY_REAP_POLL);
1381 }
1382 Ok(None) => {
1383 tracing::warn!(%session_id, "stopped relay proxy could not be reaped in time");
1384 return;
1385 }
1386 Err(error) => {
1387 tracing::warn!(%session_id, %error, "could not reap stopped relay proxy");
1388 return;
1389 }
1390 }
1391 }
1392}
1393
1394fn credential_snapshot(payload: RelayResponsePayload) -> Result<CredentialSnapshot> {
1395 match payload {
1396 RelayResponsePayload::CredentialState {
1397 present,
1398 fingerprint,
1399 freshness_epoch_ms,
1400 } => Ok(CredentialSnapshot {
1401 present,
1402 fingerprint,
1403 freshness_epoch_ms,
1404 }),
1405 _ => bail!("relay returned an unexpected credential state response"),
1406 }
1407}
1408
1409fn skills_sync_state(payload: RelayResponsePayload) -> Result<hel::hel_skills::SkillsSyncState> {
1410 match payload {
1411 RelayResponsePayload::SkillsState {
1412 present,
1413 fingerprint,
1414 } => Ok(hel::hel_skills::SkillsSyncState {
1415 present,
1416 fingerprint,
1417 }),
1418 _ => bail!("relay returned an unexpected skills state response"),
1419 }
1420}
1421
1422fn github_token_snapshot(
1423 payload: RelayResponsePayload,
1424) -> Result<hel::hel_credentials::GithubTokenSnapshot> {
1425 match payload {
1426 RelayResponsePayload::GithubTokenState {
1427 present,
1428 fingerprint,
1429 } => Ok(hel::hel_credentials::GithubTokenSnapshot {
1430 present,
1431 fingerprint,
1432 }),
1433 _ => bail!("relay returned an unexpected GitHub token state response"),
1434 }
1435}
1436
1437async fn read_bounded_frame(
1438 reader: &mut (impl AsyncBufRead + Unpin),
1439 kind: ExchangeKind,
1440) -> Result<Option<String>> {
1441 read_bounded_frame_with_limit(reader, MAX_FRAME_BYTES, kind).await
1442}
1443
1444async fn read_bounded_frame_with_limit(
1445 reader: &mut (impl AsyncBufRead + Unpin),
1446 maximum_bytes: usize,
1447 kind: ExchangeKind,
1448) -> Result<Option<String>> {
1449 let mut frame = Vec::new();
1450 loop {
1451 let available = reader
1455 .fill_buf()
1456 .await
1457 .map_err(|error| RelayTransportDead::from_io(error, kind))?;
1458 if available.is_empty() {
1459 if frame.is_empty() {
1460 return Ok(None);
1461 }
1462 return Err(anyhow::Error::new(RelayTransportDead::during_exchange(
1463 "relay proxy disconnected in the middle of a response frame",
1464 kind,
1465 )));
1466 }
1467 let newline = available.iter().position(|byte| *byte == b'\n');
1468 let consumed = newline.map_or(available.len(), |position| position + 1);
1469 let payload = newline.map_or(available, |position| &available[..position]);
1470 if frame.len().saturating_add(payload.len()) > maximum_bytes {
1471 bail!("relay response frame is too large");
1472 }
1473 frame.extend_from_slice(payload);
1474 reader.consume(consumed);
1475 if newline.is_some() {
1476 if frame.last() == Some(&b'\r') {
1477 frame.pop();
1478 }
1479 return String::from_utf8(frame)
1480 .context("relay response is not UTF-8")
1481 .map(Some);
1482 }
1483 }
1484}
1485
1486fn clip_catch_up_page(
1487 page: RelayAttachment,
1488 previous: &RelayCursor,
1489 frontier: &RelayCursor,
1490) -> Result<RelayEventPage> {
1491 if previous.ordinal > frontier.ordinal {
1492 bail!("relay catch-up starts beyond its fixed frontier");
1493 }
1494 if previous.ordinal == frontier.ordinal {
1495 if previous != frontier {
1496 bail!("relay catch-up cursor digest differs from its fixed frontier");
1497 }
1498 if !page.events.is_empty() || page.through_ordinal != previous.ordinal {
1499 bail!("relay attachment advanced beyond its advertised frontier");
1500 }
1501 return Ok(RelayEventPage {
1502 events: Vec::new(),
1503 through_ordinal: previous.ordinal,
1504 through_digest: previous.digest.clone(),
1505 });
1506 }
1507 if page.through_ordinal <= previous.ordinal || page.events.is_empty() {
1508 bail!("relay catch-up page did not advance");
1509 }
1510 if page.through_ordinal <= frontier.ordinal {
1511 let through = RelayCursor {
1512 ordinal: page.through_ordinal,
1513 digest: page.through_digest.clone(),
1514 };
1515 if through.ordinal == frontier.ordinal && through != *frontier {
1516 bail!("relay catch-up page digest differs from its fixed frontier");
1517 }
1518 return Ok(RelayEventPage {
1519 events: page.events,
1520 through_ordinal: through.ordinal,
1521 through_digest: through.digest,
1522 });
1523 }
1524
1525 let events = page
1526 .events
1527 .into_iter()
1528 .take_while(|event| event.ordinal <= frontier.ordinal)
1529 .collect::<Vec<_>>();
1530 let reached = events
1531 .last()
1532 .map(|event| RelayCursor {
1533 ordinal: event.ordinal,
1534 digest: event.digest.clone(),
1535 })
1536 .ok_or_else(|| anyhow!("relay catch-up page skipped its fixed frontier"))?;
1537 if reached != *frontier {
1538 bail!("relay catch-up page does not contain its fixed frontier");
1539 }
1540 Ok(RelayEventPage {
1541 events,
1542 through_ordinal: reached.ordinal,
1543 through_digest: reached.digest,
1544 })
1545}
1546
1547fn decode_relay_response(
1548 line: &str,
1549 request_id: &str,
1550 protocol: u32,
1551) -> Result<RelayResponsePayload> {
1552 let response: RelayResponseEnvelope =
1553 serde_json::from_str(line).context("decode relay response")?;
1554 if response.request_id != request_id {
1555 bail!(
1556 "relay response ID mismatch: expected {request_id}, got {}",
1557 response.request_id
1558 );
1559 }
1560 if response.protocol_version != protocol {
1561 bail!(
1562 "relay response protocol mismatch: expected {protocol}, got {}",
1563 response.protocol_version
1564 );
1565 }
1566 match response.body {
1567 RelayResponseBody::Ok { payload } => Ok(payload),
1568 RelayResponseBody::Error { error } => Err(RelayRejected(error).into()),
1569 }
1570}
1571
1572fn decode_relay_hello_response(line: &str, request_id: &str) -> Result<RelayResponsePayload> {
1573 let response: RelayResponseEnvelope =
1574 serde_json::from_str(line).context("decode relay hello response")?;
1575 if response.request_id != request_id {
1576 bail!(
1577 "relay response ID mismatch: expected {request_id}, got {}",
1578 response.request_id
1579 );
1580 }
1581 match response.body {
1582 RelayResponseBody::Ok {
1583 payload: payload @ RelayResponsePayload::Hello { negotiated, .. },
1584 } => {
1585 if response.protocol_version != negotiated {
1586 bail!(
1587 "relay hello envelope uses protocol {}, negotiated {negotiated}",
1588 response.protocol_version
1589 );
1590 }
1591 Ok(payload)
1592 }
1593 RelayResponseBody::Ok { .. } => bail!("relay returned an unexpected hello response"),
1594 RelayResponseBody::Error { error } => Err(RelayRejected(error).into()),
1595 }
1596}
1597
1598pub struct CredentialSyncCoordinator {
1599 handle: CredentialSyncHandle,
1600 results: mpsc::UnboundedReceiver<CredentialSyncResult>,
1601}
1602
1603impl CredentialSyncCoordinator {
1604 pub fn spawn() -> Self {
1605 let (targets_tx, mut targets_rx) = watch::channel(Vec::new());
1606 let (triggers_tx, mut triggers_rx) = mpsc::unbounded_channel::<SyncTrigger>();
1607 let (completed_tx, mut completed_rx) = mpsc::unbounded_channel::<CredentialSyncResult>();
1608 let (results_tx, results_rx) = mpsc::unbounded_channel();
1609 tokio::spawn(async move {
1610 let mut tick = tokio::time::interval_at(
1611 tokio::time::Instant::now() + SYNC_INTERVAL,
1612 SYNC_INTERVAL,
1613 );
1614 tick.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
1615 let mut busy = BTreeSet::<String>::new();
1618 let mut queue = VecDeque::<SyncTrigger>::new();
1619 loop {
1620 tokio::select! {
1621 _ = tick.tick() => {
1622 for profile_id in profiles_with_targets(&targets_rx.borrow()) {
1623 enqueue(&mut queue, SyncTrigger { profile_id, cause: None });
1624 }
1625 }
1626 changed = targets_rx.changed() => {
1627 if changed.is_err() { break; }
1628 for profile_id in profiles_with_targets(&targets_rx.borrow()) {
1629 enqueue(&mut queue, SyncTrigger { profile_id, cause: None });
1630 }
1631 }
1632 trigger = triggers_rx.recv() => {
1633 let Some(trigger) = trigger else { break };
1634 enqueue(&mut queue, trigger);
1635 }
1636 completed = completed_rx.recv() => {
1637 let Some(result) = completed else { break };
1638 busy.remove(&result.profile_id);
1639 if result.trigger.is_some()
1640 || result.failure.is_some()
1641 || !result.outcomes.is_empty()
1642 {
1643 let profile_id = result.profile_id.clone();
1644 if results_tx.send(result).is_err() {
1645 tracing::debug!(
1646 %profile_id,
1647 operation = "credential_sync_result",
1648 "credential sync result receiver was already closed"
1649 );
1650 }
1651 }
1652 }
1653 }
1654
1655 let mut deferred = VecDeque::new();
1656 while let Some(trigger) = queue.pop_front() {
1657 if busy.contains(&trigger.profile_id) {
1658 deferred.push_back(trigger);
1659 continue;
1660 }
1661 let targets: Vec<_> = targets_rx
1662 .borrow()
1663 .iter()
1664 .filter(|target| target.profile_id == trigger.profile_id)
1665 .cloned()
1666 .collect();
1667 if targets.is_empty() {
1668 if trigger.cause.is_some() {
1669 let profile_id = trigger.profile_id.clone();
1670 if results_tx
1671 .send(CredentialSyncResult {
1672 profile_id: trigger.profile_id,
1673 trigger: trigger.cause,
1674 failure: None,
1675 outcomes: Vec::new(),
1676 })
1677 .is_err()
1678 {
1679 tracing::debug!(
1680 %profile_id,
1681 operation = "credential_sync_result",
1682 "credential sync result receiver was already closed"
1683 );
1684 }
1685 }
1686 continue;
1687 }
1688 busy.insert(trigger.profile_id.clone());
1689 let completed_tx = completed_tx.clone();
1690 let handle = tokio::runtime::Handle::current();
1691 tokio::spawn(async move {
1694 let joined = tokio::task::spawn_blocking(move || {
1695 handle.block_on(reconcile_profile(&targets))
1696 })
1697 .await;
1698 let (failure, outcomes) = match joined {
1699 Ok(outcomes) => (None, outcomes),
1700 Err(error) => (Some(format!("sync task stopped: {error}")), Vec::new()),
1701 };
1702 let profile_id = trigger.profile_id.clone();
1703 if completed_tx
1704 .send(CredentialSyncResult {
1705 profile_id: trigger.profile_id,
1706 trigger: trigger.cause,
1707 failure,
1708 outcomes,
1709 })
1710 .is_err()
1711 {
1712 tracing::debug!(
1713 %profile_id,
1714 operation = "credential_sync_completion",
1715 "credential sync coordinator stopped before receiving completion"
1716 );
1717 }
1718 });
1719 }
1720 queue = deferred;
1721 }
1722 });
1723 Self {
1724 handle: CredentialSyncHandle {
1725 targets: Arc::new(targets_tx),
1726 triggers: triggers_tx,
1727 },
1728 results: results_rx,
1729 }
1730 }
1731
1732 pub fn handle(&self) -> CredentialSyncHandle {
1733 self.handle.clone()
1734 }
1735
1736 pub fn try_result(&mut self) -> Option<CredentialSyncResult> {
1737 self.results.try_recv().ok()
1738 }
1739
1740 pub async fn result(&mut self) -> Option<CredentialSyncResult> {
1746 self.results.recv().await
1747 }
1748}
1749
1750async fn reconcile_profile(targets: &[CredentialSyncTarget]) -> Vec<CredentialSyncOutcome> {
1757 let github_token = targets
1758 .iter()
1759 .any(|target| target.sync_github_token)
1760 .then(crate::hel_controller::controller_github_token)
1761 .flatten();
1762 let mut outcomes = BTreeMap::<String, CredentialSyncOutcome>::new();
1763 for pass in 0..2 {
1764 let mut pulled = false;
1765 for target in targets {
1766 match reconcile_session(target, github_token.as_deref()).await {
1767 Ok(actions) if actions.is_empty() => {}
1768 Ok(actions) => {
1769 pulled |= actions.contains(&CredentialSyncAction::Pulled);
1770 outcomes.insert(
1771 target.session_id.clone(),
1772 CredentialSyncOutcome {
1773 session_id: target.session_id.clone(),
1774 outcome: Ok(actions),
1775 },
1776 );
1777 }
1778 Err(error) => {
1779 tracing::warn!(
1780 session_id = %target.session_id,
1781 profile_id = %target.profile_id,
1782 pass = pass + 1,
1783 error = %error,
1784 "credential synchronization failed for relay session"
1785 );
1786 outcomes.insert(
1787 target.session_id.clone(),
1788 CredentialSyncOutcome {
1789 session_id: target.session_id.clone(),
1790 outcome: Err(format!("{error:#}")),
1791 },
1792 );
1793 }
1794 }
1795 }
1796 if !pulled || pass == 1 {
1797 break;
1798 }
1799 }
1800 outcomes.into_values().collect()
1801}
1802
1803async fn reconcile_session(
1805 target: &CredentialSyncTarget,
1806 github_token: Option<&str>,
1807) -> Result<Vec<CredentialSyncAction>> {
1808 let canonical_path = harness_authentication_marker(target.harness, &target.profile_home);
1809 let (canonical, canonical_bytes) = read_credential_file(target.harness, &canonical_path)?;
1810 let canonical_skills = hel::hel_skills::collect_skills(target.harness, &target.profile_home)
1811 .with_context(|| {
1812 format!(
1813 "collect canonical skills for profile {} from {}",
1814 target.profile_id,
1815 target.profile_home.display()
1816 )
1817 })?;
1818 let mut client = RelayClient::connect(&target.spec, &target.session_id).await?;
1819 let result = reconcile_connected(
1820 &mut client,
1821 target,
1822 &canonical_path,
1823 &canonical,
1824 &canonical_bytes,
1825 &canonical_skills,
1826 github_token,
1827 )
1828 .await;
1829 if let Err(error) = client.detach().await {
1833 tracing::warn!(
1834 session_id = %target.session_id,
1835 "could not close the credential sync connection: {error:#}"
1836 );
1837 }
1838 result
1839}
1840
1841async fn reconcile_connected(
1842 client: &mut RelayClient,
1843 target: &CredentialSyncTarget,
1844 canonical_path: &Path,
1845 canonical: &CredentialSnapshot,
1846 canonical_bytes: &[u8],
1847 canonical_skills: &hel::hel_skills::SkillsArchive,
1848 github_token: Option<&str>,
1849) -> Result<Vec<CredentialSyncAction>> {
1850 let mut actions = Vec::new();
1851 let session = client.credential_state().await?;
1852 match reconcile(canonical, &session) {
1853 SyncAction::None => {
1854 if canonical.present
1855 && session.present
1856 && canonical.fingerprint != session.fingerprint
1857 && canonical.freshness_epoch_ms.is_none()
1858 && session.freshness_epoch_ms.is_none()
1859 {
1860 tracing::warn!(
1861 session_id = %target.session_id,
1862 profile_id = %target.profile_id,
1863 "credential copies differ but neither reports a refresh time; leaving both alone"
1864 );
1865 }
1866 }
1867 SyncAction::Push => {
1868 client.install_credentials(canonical_bytes).await?;
1869 actions.push(CredentialSyncAction::Pushed);
1870 }
1871 SyncAction::Pull => {
1872 let bytes = client.read_credentials().await?;
1873 validate_credential_payload(target.harness, &bytes).with_context(|| {
1874 format!(
1875 "session {} returned an unusable credential file",
1876 target.session_id
1877 )
1878 })?;
1879 write_credential_file(target.harness, canonical_path, &bytes).with_context(|| {
1880 format!(
1881 "install fresher credentials from session {} for profile {}",
1882 target.session_id, target.profile_id
1883 )
1884 })?;
1885 actions.push(CredentialSyncAction::Pulled);
1886 }
1887 }
1888 if reconcile_skills(client, target, canonical_skills).await? {
1889 actions.push(CredentialSyncAction::SkillsPushed);
1890 }
1891 if target.sync_github_token
1892 && let Some(action) = reconcile_github_token(client, target, github_token).await?
1893 {
1894 actions.push(action);
1895 }
1896 Ok(actions)
1897}
1898
1899async fn reconcile_github_token(
1900 client: &mut RelayClient,
1901 target: &CredentialSyncTarget,
1902 canonical: Option<&str>,
1903) -> Result<Option<CredentialSyncAction>> {
1904 let session = match client.github_token_state().await {
1905 Ok(state) => state,
1906 Err(error) if sync_method_unsupported(&error) => {
1907 tracing::debug!(
1908 session_id = %target.session_id,
1909 profile_id = %target.profile_id,
1910 "worker predates GitHub token sync; skipping until the target is re-provisioned"
1911 );
1912 return Ok(None);
1913 }
1914 Err(error) => return Err(error),
1915 };
1916 match canonical {
1917 Some(token) => {
1918 let canonical = hel::hel_credentials::GithubTokenSnapshot::of(token);
1919 if session == canonical {
1920 return Ok(None);
1921 }
1922 let installed = client.install_github_token(token).await?;
1923 if installed != canonical {
1924 bail!(
1925 "session {} GitHub token fingerprint does not match the controller after install",
1926 target.session_id
1927 );
1928 }
1929 Ok(Some(CredentialSyncAction::GithubTokenPushed))
1930 }
1931 None if session.present => {
1932 let removed = client.remove_github_token().await?;
1933 if removed.present {
1934 bail!(
1935 "session {} retained its GitHub token after removal",
1936 target.session_id
1937 );
1938 }
1939 Ok(Some(CredentialSyncAction::GithubTokenRemoved))
1940 }
1941 None => Ok(None),
1942 }
1943}
1944
1945async fn reconcile_skills(
1950 client: &mut RelayClient,
1951 target: &CredentialSyncTarget,
1952 canonical: &hel::hel_skills::SkillsArchive,
1953) -> Result<bool> {
1954 let canonical_state = canonical.state();
1955 let session = match client.skills_state().await {
1956 Ok(state) => state,
1957 Err(error) if sync_method_unsupported(&error) => {
1958 tracing::debug!(
1959 session_id = %target.session_id,
1960 profile_id = %target.profile_id,
1961 "worker predates skills sync; skipping until the target is re-provisioned"
1962 );
1963 return Ok(false);
1964 }
1965 Err(error) => return Err(error),
1966 };
1967 if session == canonical_state {
1968 return Ok(false);
1969 }
1970 let installed = client.install_skills(&canonical.encode()).await?;
1971 if installed != canonical_state {
1972 bail!(
1973 "session {} skills fingerprint {} does not match the canonical {} after install",
1974 target.session_id,
1975 installed.fingerprint,
1976 canonical_state.fingerprint
1977 );
1978 }
1979 Ok(true)
1980}
1981
1982fn sync_method_unsupported(error: &anyhow::Error) -> bool {
1983 error
1984 .downcast_ref::<RelayRejected>()
1985 .is_some_and(|rejected| rejected.0.code == RelayErrorCode::InvalidRequest)
1986}
1987
1988#[cfg(test)]
1989mod tests {
1990 use super::*;
1991 use hel::hel_worker::{DurableRelay, RelayObservation};
1992 const SESSION_ID: &str = "018f9dd2-a3b4-7c8d-9000-123456789abc";
1993
1994 #[test]
1995 fn relay_decoder_preserves_explicit_desynchronization() {
1996 let response = RelayResponseEnvelope {
1997 request_id: "relay-1".into(),
1998 protocol_version: RELAY_PROTOCOL_VERSION,
1999 body: RelayResponseBody::Error {
2000 error: RelayProtocolError {
2001 code: RelayErrorCode::Desynchronized,
2002 message: "journal gap".into(),
2003 retryable: false,
2004 detail: None,
2005 },
2006 },
2007 };
2008 let encoded = serde_json::to_string(&response).unwrap();
2009 let error = decode_relay_response(&encoded, "relay-1", RELAY_PROTOCOL_VERSION).unwrap_err();
2010 assert!(
2011 error
2012 .downcast_ref::<RelayRejected>()
2013 .is_some_and(RelayRejected::is_desynchronized)
2014 );
2015 }
2016
2017 #[test]
2018 fn relay_decoder_rejects_crossed_request_ids() {
2019 let response = RelayResponseEnvelope {
2020 request_id: "other".into(),
2021 protocol_version: RELAY_PROTOCOL_VERSION,
2022 body: RelayResponseBody::Ok {
2023 payload: RelayResponsePayload::Acknowledged {
2024 through_ordinal: 4,
2025 through_digest: "a".repeat(64),
2026 },
2027 },
2028 };
2029 let encoded = serde_json::to_string(&response).unwrap();
2030 assert!(
2031 decode_relay_response(&encoded, "wanted", RELAY_PROTOCOL_VERSION)
2032 .unwrap_err()
2033 .to_string()
2034 .contains("ID mismatch")
2035 );
2036 }
2037
2038 #[test]
2039 fn command_spec_preserves_argv_boundaries() {
2040 let spec = CommandSpec::new("ssh", ["host", "hel worker proxy --root '/odd path'"]);
2041 assert_eq!(spec.program, "ssh");
2042 assert_eq!(spec.args.len(), 2);
2043 assert_eq!(spec.args[1], "hel worker proxy --root '/odd path'");
2044 }
2045
2046 #[test]
2047 fn relay_protocol_version_range_contains_current_version() {
2048 assert_eq!(
2049 RelayVersionRange::CURRENT.negotiate(RelayVersionRange::CURRENT),
2050 Some(RELAY_PROTOCOL_VERSION)
2051 );
2052 assert_eq!(
2053 RelayVersionRange::CURRENT.negotiate(RelayVersionRange { min: 1, max: 1 }),
2054 Some(1)
2055 );
2056 }
2057
2058 #[cfg(unix)]
2059 #[tokio::test]
2060 async fn controller_accepts_negotiated_protocol_v1() {
2061 let script = format!(
2062 r#"python3 -c '
2063import json, sys
2064session = {session:?}
2065req = json.loads(sys.stdin.readline())
2066assert req["request"]["method"] == "hello"
2067supported = req["request"]["params"]["supported"]
2068assert supported["min"] <= 1 <= supported["max"]
2069print(json.dumps({{
2070 "request_id": req["request_id"],
2071 "protocol_version": 1,
2072 "result": "ok",
2073 "payload": {{
2074 "type": "hello",
2075 "data": {{
2076 "negotiated": 1,
2077 "relay_version": "v1-fixture",
2078 "session_id": session,
2079 }},
2080 }},
2081}}), flush=True)
2082sys.stdin.read()
2083'"#,
2084 session = SESSION_ID
2085 );
2086 let spec = CommandSpec::new("sh", ["-c", &script]).purpose("v1 relay fixture");
2087 let client = RelayClient::connect_with_timeout(&spec, SESSION_ID, Duration::from_secs(5))
2088 .await
2089 .expect("protocol v1 hello must be accepted");
2090 assert_eq!(client.protocol_version(), 1);
2091 assert_eq!(client.relay_version(), "v1-fixture");
2092 }
2093
2094 #[cfg(unix)]
2098 #[tokio::test]
2099 async fn a_hello_reports_the_worker_build_or_none_from_an_older_worker() {
2100 let hello = |build: Option<&str>| {
2101 let data = match build {
2102 Some(build) => format!(
2103 r#"{{"negotiated":1,"relay_version":"build-fixture","session_id":"%s","worker_build":"{build}"}}"#
2104 ),
2105 None => r#"{"negotiated":1,"relay_version":"build-fixture","session_id":"%s"}"#
2106 .to_owned(),
2107 };
2108 format!(
2109 r#"
2110IFS= read -r hello
2111id=$(printf '%s' "$hello" | sed -n 's/.*"request_id":"\([^"]*\)".*/\1/p')
2112printf '{{"request_id":"%s","protocol_version":1,"result":"ok","payload":{{"type":"hello","data":{data}}}}}
2113' "$id" "$1"
2114sh -c 'while :; do sleep 30; done'
2115"#
2116 )
2117 };
2118 for reported in [None, Some("a".repeat(64).as_str())] {
2119 let spec = CommandSpec::new(
2120 "sh",
2121 [
2122 "-c".to_owned(),
2123 hello(reported),
2124 "hel-relay-build-fixture".to_owned(),
2125 SESSION_ID.to_owned(),
2126 ],
2127 )
2128 .purpose("relay worker build fixture");
2129 let client =
2130 RelayClient::connect_with_timeout(&spec, SESSION_ID, Duration::from_secs(5))
2131 .await
2132 .expect("hello must be accepted with and without a worker build");
2133 assert_eq!(client.worker_build(), reported);
2134 }
2135 }
2136
2137 #[cfg(unix)]
2138 #[tokio::test]
2139 async fn dropping_a_client_delivers_eof_before_stopping_its_proxy_launcher() {
2140 let directory = tempfile::tempdir().unwrap();
2141 let eof = directory.path().join("proxy-saw-eof");
2142 let script = r#"
2143IFS= read -r hello
2144id=$(printf '%s' "$hello" | sed -n 's/.*"request_id":"\([^"]*\)".*/\1/p')
2145printf '{"request_id":"%s","protocol_version":1,"result":"ok","payload":{"type":"hello","data":{"negotiated":1,"relay_version":"eof-fixture","session_id":"%s"}}}\n' "$id" "$1"
2146if IFS= read -r _; then exit 9; fi
2147: > "$2"
2148"#;
2149 let spec = CommandSpec::new(
2150 "sh",
2151 [
2152 "-c".to_owned(),
2153 script.to_owned(),
2154 "hel-relay-eof-fixture".to_owned(),
2155 SESSION_ID.to_owned(),
2156 eof.to_string_lossy().into_owned(),
2157 ],
2158 )
2159 .purpose("relay proxy EOF fixture");
2160 let client = RelayClient::connect_with_timeout(&spec, SESSION_ID, Duration::from_secs(5))
2161 .await
2162 .unwrap();
2163
2164 drop(client);
2165 tokio::time::timeout(Duration::from_secs(2), async {
2166 while !eof.exists() {
2167 tokio::time::sleep(Duration::from_millis(10)).await;
2168 }
2169 })
2170 .await
2171 .expect("proxy launcher was killed before it observed stdin EOF");
2172 }
2173
2174 #[cfg(unix)]
2175 #[tokio::test]
2176 async fn controller_rejects_negotiated_protocol_outside_supported_range() {
2177 let future_protocol = RELAY_PROTOCOL_VERSION + 1;
2178 let script = format!(
2179 r#"python3 -c '
2180import json, sys
2181session = {session:?}
2182req = json.loads(sys.stdin.readline())
2183print(json.dumps({{
2184 "request_id": req["request_id"],
2185 "protocol_version": {future_protocol},
2186 "result": "ok",
2187 "payload": {{
2188 "type": "hello",
2189 "data": {{
2190 "negotiated": {future_protocol},
2191 "relay_version": "future",
2192 "session_id": session,
2193 }},
2194 }},
2195}}), flush=True)
2196sys.stdin.read()
2197'"#,
2198 session = SESSION_ID,
2199 future_protocol = future_protocol,
2200 );
2201 let spec = CommandSpec::new("sh", ["-c", &script]).purpose("future relay fixture");
2202 let error = RelayClient::connect_with_timeout(&spec, SESSION_ID, Duration::from_secs(5))
2203 .await
2204 .err()
2205 .expect("a future protocol hello must be rejected");
2206 assert!(
2207 error.to_string().contains(&format!(
2208 "negotiated unsupported protocol {future_protocol}"
2209 )),
2210 "{error:#}"
2211 );
2212 assert!(!RelayTransportDead::marks(&error), "{error:#}");
2215 }
2216
2217 #[cfg(unix)]
2220 #[tokio::test]
2221 async fn a_proxy_that_exits_before_hello_reports_a_dead_transport() {
2222 let spec = CommandSpec::new("sh", ["-c", "exit 1"]).purpose("exiting relay proxy");
2223
2224 let error = RelayClient::connect_with_timeout(&spec, SESSION_ID, Duration::from_secs(5))
2225 .await
2226 .err()
2227 .expect("a proxy that exits cannot complete hello");
2228
2229 assert!(RelayTransportDead::marks(&error), "{error:#}");
2230 assert!(RelayTransportDead::marks_failed_handshake(&error));
2231 }
2232
2233 #[cfg(unix)]
2234 #[tokio::test]
2235 async fn silent_proxy_handshake_has_a_bounded_deadline() {
2236 let spec = CommandSpec::new("sh", ["-c", "sleep 30"]).purpose("test silent relay proxy");
2237 let started = std::time::Instant::now();
2238
2239 let error = RelayClient::connect_with_timeout(&spec, SESSION_ID, Duration::from_millis(50))
2240 .await
2241 .err()
2242 .expect("silent relay must time out");
2243
2244 assert!(error.to_string().contains("relay hello timed out"));
2245 assert!(!RelayTransportDead::marks(&error), "{error:#}");
2249 assert!(!RelayTransportDead::marks_failed_handshake(&error));
2250 assert!(started.elapsed() < Duration::from_secs(2));
2251 }
2252
2253 #[cfg(unix)]
2256 const STALLING_RELAY: &str = r#"
2257IFS= read -r hello
2258id=$(printf '%s' "$hello" | sed -n 's/.*"request_id":"\([^"]*\)".*/\1/p')
2259printf '{"request_id":"%s","protocol_version":1,"result":"ok","payload":{"type":"hello","data":{"negotiated":1,"relay_version":"stalling-fixture","session_id":"%s"}}}\n' "$id" "$1"
2260IFS= read -r stalled
2261id=$(printf '%s' "$stalled" | sed -n 's/.*"request_id":"\([^"]*\)".*/\1/p')
2262sleep 5
2263printf '{"request_id":"%s","protocol_version":1,"result":"error","error":{"code":"internal","message":"late reply","retryable":false}}\n' "$id"
2264cat > /dev/null
2265"#;
2266
2267 #[cfg(unix)]
2268 #[tokio::test]
2269 async fn a_timed_out_call_abandons_the_connection_instead_of_desynchronizing_it() {
2270 let spec = CommandSpec::new(
2271 "sh",
2272 ["-c", STALLING_RELAY, "hel-relay-fixture", SESSION_ID],
2273 )
2274 .purpose("stalling relay fixture");
2275 let mut client =
2276 RelayClient::connect_with_timeout(&spec, SESSION_ID, Duration::from_millis(500))
2277 .await
2278 .expect("the fixture answers hello immediately");
2279
2280 let timed_out = client
2281 .status()
2282 .await
2283 .expect_err("the stalled status call must time out");
2284 assert!(
2285 format!("{timed_out:#}").contains("relay status timed out"),
2286 "{timed_out:#}"
2287 );
2288 assert!(!RelayTransportDead::marks(&timed_out), "{timed_out:#}");
2291
2292 let started = std::time::Instant::now();
2297 let subsequent = client
2298 .status()
2299 .await
2300 .expect_err("a call on an abandoned connection must fail");
2301 let elapsed = started.elapsed();
2302 assert!(
2303 format!("{subsequent:#}").contains("relay connection abandoned after status timed out"),
2304 "{subsequent:#}"
2305 );
2306 assert!(
2307 elapsed < Duration::from_millis(250),
2308 "an abandoned connection must fail fast, took {elapsed:?}"
2309 );
2310
2311 let repeated = client
2312 .status()
2313 .await
2314 .expect_err("the connection stays abandoned");
2315 assert!(
2316 format!("{repeated:#}").contains("relay connection abandoned after status timed out"),
2317 "{repeated:#}"
2318 );
2319 }
2320
2321 #[test]
2322 fn an_unsupported_method_answer_still_reads_as_missing_skills_sync() {
2323 let response = hel::hel_worker::unsupported_relay_method_response(
2327 "relay-1".into(),
2328 RELAY_PROTOCOL_VERSION,
2329 "skills_state".into(),
2330 );
2331 let encoded = serde_json::to_string(&response).unwrap();
2332 let error = decode_relay_response(&encoded, "relay-1", RELAY_PROTOCOL_VERSION).unwrap_err();
2333 assert!(sync_method_unsupported(&error), "{error:#}");
2334 }
2335
2336 #[tokio::test]
2337 async fn publishing_new_targets_starts_reconciliation_without_waiting_for_the_tick() {
2338 let profile = tempfile::tempdir().unwrap();
2339 let mut coordinator = CredentialSyncCoordinator::spawn();
2340 coordinator.handle().set_targets(vec![CredentialSyncTarget {
2341 session_id: SESSION_ID.into(),
2342 profile_id: "work".into(),
2343 harness: hel::hel_config::HarnessKind::Codex,
2344 profile_home: profile.path().to_path_buf(),
2345 sync_github_token: false,
2346 spec: CommandSpec::new("sh", ["-c", "exit 1"]),
2347 }]);
2348
2349 let result = tokio::time::timeout(Duration::from_secs(5), coordinator.result())
2350 .await
2351 .expect("target publication must not wait for the 60-second periodic tick")
2352 .expect("credential coordinator stopped");
2353 assert_eq!(result.profile_id, "work");
2354 assert_eq!(result.outcomes.len(), 1);
2355 assert!(result.outcomes[0].outcome.is_err());
2356 }
2357
2358 #[tokio::test]
2359 async fn response_frame_limit_is_enforced_before_newline() {
2360 let (mut writer, reader) = tokio::io::duplex(32);
2361 let write = tokio::spawn(async move {
2362 writer.write_all(b"123456789\n").await.unwrap();
2363 });
2364 let mut reader = BufReader::new(reader);
2365
2366 let error = read_bounded_frame_with_limit(&mut reader, 8, ExchangeKind::Call)
2367 .await
2368 .unwrap_err();
2369
2370 write.await.unwrap();
2371 assert!(error.to_string().contains("frame is too large"));
2372 assert!(!RelayTransportDead::marks(&error), "{error:#}");
2375 }
2376
2377 #[tokio::test]
2378 async fn a_half_written_response_frame_reports_a_dead_transport() {
2379 let (mut writer, reader) = tokio::io::duplex(32);
2380 writer.write_all(b"{\"partial\":").await.unwrap();
2381 drop(writer);
2382 let mut reader = BufReader::new(reader);
2383
2384 let error = read_bounded_frame(&mut reader, ExchangeKind::Call)
2385 .await
2386 .unwrap_err();
2387
2388 assert!(RelayTransportDead::marks(&error), "{error:#}");
2389 assert!(!RelayTransportDead::marks_failed_handshake(&error));
2390 }
2391
2392 #[test]
2393 fn catch_up_page_stops_at_the_frontier_captured_before_stream_growth() {
2394 let temp = tempfile::tempdir().unwrap();
2395 let mut relay = DurableRelay::open(temp.path(), SESSION_ID, "1.0.0").unwrap();
2396 for message in ["one", "two", "arrived concurrently"] {
2397 relay
2398 .record_observation(RelayObservation::Warning {
2399 message: message.into(),
2400 })
2401 .unwrap();
2402 }
2403 let all = relay.events_after(0, RELAY_EVENT_GENESIS_DIGEST).unwrap();
2404 let previous = RelayCursor {
2405 ordinal: all[0].ordinal,
2406 digest: all[0].digest.clone(),
2407 };
2408 let frontier = RelayCursor {
2409 ordinal: all[1].ordinal,
2410 digest: all[1].digest.clone(),
2411 };
2412 let page = RelayAttachment {
2413 state: relay.operational_state(),
2414 events: all[1..].to_vec(),
2415 through_ordinal: all[2].ordinal,
2416 through_digest: all[2].digest.clone(),
2417 };
2418 let clipped = clip_catch_up_page(page, &previous, &frontier).unwrap();
2419 assert_eq!(clipped.through_ordinal, frontier.ordinal);
2420 assert_eq!(clipped.through_digest, frontier.digest);
2421 assert_eq!(clipped.events.len(), 1);
2422 assert_eq!(clipped.events.last().unwrap().ordinal, 2);
2423 }
2424}