1use std::collections::BTreeMap;
2use std::io::{BufRead, BufReader, Read, Write};
3use std::net::{TcpStream, ToSocketAddrs};
4use std::path::{Path, PathBuf};
5use std::sync::{Arc, RwLock};
6use std::time::Duration;
7
8use serde::de::DeserializeOwned;
9use serde_json::{json, Map, Value};
10
11use crate::{
12 discover_endpoint, ContextDetail, DaemonEndpoint, DaemonRequest, DaemonResponse, Error,
13 MessageHistoryMode, MessageMode, PingResult, ReplyMessageMode, Result, TerminalHistoryOptions,
14 TerminalHistoryResult, TerminalResizeResult, TerminalSinceOptions, TerminalSinceResult,
15 TerminalSnapshotOptions, TerminalSnapshotResult, WebModelDeliveryMode,
16 WebModelDeliveryPreferencesResult, WebModelRuntimeRecoverTurnResult,
17};
18
19const DEFAULT_TIMEOUT: Duration = Duration::from_secs(30);
20const MAX_REQUEST_BYTES: usize = 2_000_000;
21const MAX_RESPONSE_BYTES: usize = 4_000_000;
22
23enum CallAttemptError {
24 Connect(Error),
25 Exchange(Error),
26}
27
28#[derive(Clone, Debug, Default)]
30pub struct CompatibilityRequirements<'a> {
31 pub minimum_ipc_version: u32,
32 pub capabilities: BTreeMap<&'a str, bool>,
33 pub operations: Vec<&'a str>,
34}
35
36#[derive(Clone, Debug)]
38pub struct CCCCClient {
39 endpoint: DaemonEndpoint,
40 rediscovered_endpoint: Arc<RwLock<Option<DaemonEndpoint>>>,
41 timeout: Duration,
42 discovery_home: Option<PathBuf>,
43 rediscover_on_connect_failure: bool,
44}
45
46impl CCCCClient {
47 pub fn discover() -> Result<Self> {
49 Self::discover_in(None)
50 }
51
52 pub fn discover_in(cccc_home: Option<&Path>) -> Result<Self> {
54 Ok(Self {
55 endpoint: discover_endpoint(cccc_home)?,
56 rediscovered_endpoint: Arc::new(RwLock::new(None)),
57 timeout: DEFAULT_TIMEOUT,
58 discovery_home: cccc_home.map(Path::to_path_buf),
59 rediscover_on_connect_failure: true,
60 })
61 }
62
63 pub fn new(endpoint: DaemonEndpoint) -> Self {
64 Self {
65 endpoint,
66 rediscovered_endpoint: Arc::new(RwLock::new(None)),
67 timeout: DEFAULT_TIMEOUT,
68 discovery_home: None,
69 rediscover_on_connect_failure: false,
70 }
71 }
72
73 pub fn with_timeout(mut self, timeout: Duration) -> Self {
74 self.timeout = timeout;
75 self
76 }
77
78 pub fn endpoint(&self) -> &DaemonEndpoint {
80 &self.endpoint
81 }
82
83 pub fn current_endpoint(&self) -> DaemonEndpoint {
86 self.rediscovered_endpoint
87 .read()
88 .unwrap_or_else(|poisoned| poisoned.into_inner())
89 .clone()
90 .unwrap_or_else(|| self.endpoint.clone())
91 }
92
93 pub fn call(&self, op: &str, args: Map<String, Value>) -> Result<Map<String, Value>> {
95 let response = self.call_raw(op, args)?;
96 if response.v != 1 {
97 return Err(Error::UnsupportedIpcVersion(response.v));
98 }
99 if response.ok {
100 return Ok(response.result);
101 }
102 Err(Error::Daemon(response.error.unwrap_or_else(|| {
103 crate::DaemonError {
104 code: "error".into(),
105 message: "daemon returned ok=false without an error".into(),
106 details: json!({}),
107 }
108 })))
109 }
110
111 pub fn call_raw(&self, op: &str, args: Map<String, Value>) -> Result<DaemonResponse> {
113 if op.trim().is_empty() {
114 return Err(Error::Incompatible("operation name cannot be empty".into()));
115 }
116 let request = DaemonRequest {
117 v: 1,
118 op,
119 args: &args,
120 };
121 let mut encoded = serde_json::to_vec(&request)?;
122 encoded.push(b'\n');
123 if encoded.len() > MAX_REQUEST_BYTES {
124 return Err(Error::RequestTooLarge(MAX_REQUEST_BYTES));
125 }
126
127 let endpoint = self.current_endpoint();
128 let response = match self.call_at(&endpoint, &encoded) {
129 Ok(response) => Ok(response),
130 Err(CallAttemptError::Connect(_)) if self.rediscover_on_connect_failure => {
131 let endpoint = discover_endpoint(self.discovery_home.as_deref())?;
132 *self
133 .rediscovered_endpoint
134 .write()
135 .unwrap_or_else(|poisoned| poisoned.into_inner()) = Some(endpoint.clone());
136 self.call_at(&endpoint, &encoded)
137 .map_err(|retry_error| finish_attempt_error(op, retry_error))
138 }
139 Err(error) => Err(finish_attempt_error(op, error)),
140 }?;
141 if response.v != 1 {
142 return Err(Error::UnsupportedIpcVersion(response.v));
143 }
144 Ok(response)
145 }
146
147 fn call_at(
148 &self,
149 endpoint: &DaemonEndpoint,
150 encoded: &[u8],
151 ) -> std::result::Result<DaemonResponse, CallAttemptError> {
152 match endpoint {
153 DaemonEndpoint::Tcp { host, port } => {
154 let address = (host.as_str(), *port)
155 .to_socket_addrs()
156 .map_err(|error| CallAttemptError::Connect(error.into()))?
157 .next()
158 .ok_or_else(|| Error::InvalidEndpoint(format!("cannot resolve {host}:{port}")))
159 .map_err(CallAttemptError::Connect)?;
160 let mut stream = TcpStream::connect_timeout(&address, self.timeout)
161 .map_err(|error| CallAttemptError::Connect(error.into()))?;
162 stream
163 .set_read_timeout(Some(self.timeout))
164 .map_err(|error| CallAttemptError::Connect(error.into()))?;
165 stream
166 .set_write_timeout(Some(self.timeout))
167 .map_err(|error| CallAttemptError::Connect(error.into()))?;
168 exchange(&mut stream, encoded).map_err(CallAttemptError::Exchange)
169 }
170 DaemonEndpoint::Unix(path) => self.call_unix(path, encoded),
171 }
172 }
173
174 #[cfg(unix)]
175 fn call_unix(
176 &self,
177 path: &Path,
178 encoded: &[u8],
179 ) -> std::result::Result<DaemonResponse, CallAttemptError> {
180 use std::os::unix::net::UnixStream;
181
182 let mut stream =
183 UnixStream::connect(path).map_err(|error| CallAttemptError::Connect(error.into()))?;
184 stream
185 .set_read_timeout(Some(self.timeout))
186 .map_err(|error| CallAttemptError::Connect(error.into()))?;
187 stream
188 .set_write_timeout(Some(self.timeout))
189 .map_err(|error| CallAttemptError::Connect(error.into()))?;
190 exchange(&mut stream, encoded).map_err(CallAttemptError::Exchange)
191 }
192
193 #[cfg(not(unix))]
194 fn call_unix(
195 &self,
196 _path: &Path,
197 _encoded: &[u8],
198 ) -> std::result::Result<DaemonResponse, CallAttemptError> {
199 Err(CallAttemptError::Connect(Error::UnixSocketUnsupported))
200 }
201
202 pub fn ping(&self) -> Result<PingResult> {
203 self.call_typed("ping", Map::new())
204 }
205
206 pub fn groups(&self) -> Result<Map<String, Value>> {
207 self.call("groups", Map::new())
208 }
209
210 pub fn group_show(&self, group_id: &str) -> Result<Map<String, Value>> {
211 self.call("group_show", object([("group_id", json!(group_id))]))
212 }
213
214 pub fn send(
215 &self,
216 group_id: &str,
217 text: &str,
218 message_mode: MessageMode,
219 by: &str,
220 ) -> Result<Map<String, Value>> {
221 self.call(
222 "send",
223 object([
224 ("group_id", json!(group_id)),
225 ("text", json!(text)),
226 ("message_mode", json!(message_mode.as_str())),
227 ("by", json!(by)),
228 ]),
229 )
230 }
231
232 pub fn reply(
233 &self,
234 group_id: &str,
235 reply_to: &str,
236 text: &str,
237 by: &str,
238 ) -> Result<Map<String, Value>> {
239 self.reply_with_mode(group_id, reply_to, text, ReplyMessageMode::Send, by)
240 }
241
242 pub fn reply_with_mode(
243 &self,
244 group_id: &str,
245 reply_to: &str,
246 text: &str,
247 message_mode: ReplyMessageMode,
248 by: &str,
249 ) -> Result<Map<String, Value>> {
250 self.call(
251 "reply",
252 object([
253 ("group_id", json!(group_id)),
254 ("reply_to", json!(reply_to)),
255 ("text", json!(text)),
256 ("message_mode", json!(message_mode.as_str())),
257 ("by", json!(by)),
258 ]),
259 )
260 }
261
262 pub fn reply_request_cancel(
263 &self,
264 group_id: &str,
265 source_event_id: &str,
266 by: &str,
267 ) -> Result<Map<String, Value>> {
268 self.call(
269 "reply_request_cancel",
270 object([
271 ("group_id", json!(group_id)),
272 ("source_event_id", json!(source_event_id)),
273 ("by", json!(by)),
274 ]),
275 )
276 }
277
278 pub fn message_deliver(
279 &self,
280 group_id: &str,
281 source_event_id: &str,
282 actor_ids: &[&str],
283 by: &str,
284 force_ambiguous: bool,
285 ) -> Result<Map<String, Value>> {
286 if actor_ids.is_empty() || actor_ids.iter().any(|actor_id| actor_id.trim().is_empty()) {
287 return Err(Error::InvalidArgument(
288 "message_deliver requires one or more non-empty actor_ids".into(),
289 ));
290 }
291 self.call(
292 "message_deliver",
293 object([
294 ("group_id", json!(group_id)),
295 ("source_event_id", json!(source_event_id)),
296 ("actor_ids", json!(actor_ids)),
297 ("by", json!(by)),
298 ("force_ambiguous", json!(force_ambiguous)),
299 ]),
300 )
301 }
302
303 pub fn inbox_peek(
304 &self,
305 group_id: &str,
306 actor_id: &str,
307 by: &str,
308 limit: Option<u32>,
309 ) -> Result<Map<String, Value>> {
310 let mut args = object([
311 ("group_id", json!(group_id)),
312 ("actor_id", json!(actor_id)),
313 ("by", json!(by)),
314 ]);
315 if let Some(limit) = limit {
316 args.insert("limit".into(), json!(limit));
317 }
318 self.call("inbox_peek", args)
319 }
320
321 pub fn inbox_read(
322 &self,
323 group_id: &str,
324 actor_id: &str,
325 by: &str,
326 limit: Option<u32>,
327 ) -> Result<Map<String, Value>> {
328 let mut args = object([
329 ("group_id", json!(group_id)),
330 ("actor_id", json!(actor_id)),
331 ("by", json!(by)),
332 ]);
333 if let Some(limit) = limit {
334 args.insert("limit".into(), json!(limit));
335 }
336 self.call("inbox_read", args)
337 }
338
339 #[allow(clippy::too_many_arguments)]
341 pub fn message_history(
342 &self,
343 group_id: &str,
344 actor_id: &str,
345 by: &str,
346 mode: MessageHistoryMode,
347 query: Option<&str>,
348 before_event_id: Option<&str>,
349 limit: Option<u32>,
350 ) -> Result<Map<String, Value>> {
351 let mut args = object([
352 ("group_id", json!(group_id)),
353 ("actor_id", json!(actor_id)),
354 ("by", json!(by)),
355 ("mode", json!(mode.as_str())),
356 ]);
357 if let Some(query) = query.filter(|value| !value.is_empty()) {
358 args.insert("query".into(), json!(query));
359 }
360 if let Some(before_event_id) = before_event_id.filter(|value| !value.is_empty()) {
361 args.insert("before_event_id".into(), json!(before_event_id));
362 }
363 if let Some(limit) = limit {
364 args.insert("limit".into(), json!(limit));
365 }
366 self.call("message_history", args)
367 }
368
369 pub fn context_get(&self, group_id: &str) -> Result<Map<String, Value>> {
370 self.context_get_with_detail(group_id, ContextDetail::Full)
371 }
372
373 pub fn context_get_with_detail(
374 &self,
375 group_id: &str,
376 detail: ContextDetail,
377 ) -> Result<Map<String, Value>> {
378 self.call(
379 "context_get",
380 object([
381 ("group_id", json!(group_id)),
382 ("detail", json!(detail.as_str())),
383 ]),
384 )
385 }
386
387 pub fn context_sync(
388 &self,
389 group_id: &str,
390 by: &str,
391 operations: Vec<Value>,
392 ) -> Result<Map<String, Value>> {
393 self.context_sync_checked(group_id, by, operations, None, false)
394 }
395
396 pub fn context_sync_checked(
397 &self,
398 group_id: &str,
399 by: &str,
400 operations: Vec<Value>,
401 if_version: Option<&str>,
402 dry_run: bool,
403 ) -> Result<Map<String, Value>> {
404 let mut args = object([
405 ("group_id", json!(group_id)),
406 ("by", json!(by)),
407 ("ops", Value::Array(operations)),
408 ("dry_run", json!(dry_run)),
409 ]);
410 if let Some(version) = if_version {
411 args.insert("if_version".into(), json!(version));
412 }
413 self.call("context_sync", args)
414 }
415
416 pub fn terminal_history(
417 &self,
418 group_id: &str,
419 actor_id: &str,
420 options: &TerminalHistoryOptions,
421 ) -> Result<TerminalHistoryResult> {
422 let mut args = object([("group_id", json!(group_id)), ("actor_id", json!(actor_id))]);
423 if let Some(render_before) = options.render_before {
424 args.insert("render_before".into(), json!(render_before));
425 }
426 if let Some(before) = options.before {
427 args.insert("before".into(), json!(before));
428 }
429 if let Some(limit_bytes) = options.limit_bytes {
430 args.insert("limit_bytes".into(), json!(limit_bytes));
431 }
432 if let Some(strip_ansi) = options.strip_ansi {
433 args.insert("strip_ansi".into(), json!(strip_ansi));
434 }
435 if let Some(compact) = options.compact {
436 args.insert("compact".into(), json!(compact));
437 }
438 if let Some(by) = options.by.as_deref() {
439 args.insert("by".into(), json!(by));
440 }
441 self.call_typed("terminal_history", args)
442 }
443
444 pub fn terminal_since(
445 &self,
446 group_id: &str,
447 actor_id: &str,
448 after: u64,
449 options: &TerminalSinceOptions,
450 ) -> Result<TerminalSinceResult> {
451 let mut args = object([
452 ("group_id", json!(group_id)),
453 ("actor_id", json!(actor_id)),
454 ("after", json!(after)),
455 ]);
456 if let Some(limit_bytes) = options.limit_bytes {
457 args.insert("limit_bytes".into(), json!(limit_bytes));
458 }
459 if let Some(by) = options.by.as_deref() {
460 args.insert("by".into(), json!(by));
461 }
462 self.call_typed("terminal_since", args)
463 }
464
465 pub fn terminal_snapshot(
466 &self,
467 group_id: &str,
468 actor_id: &str,
469 options: &TerminalSnapshotOptions,
470 ) -> Result<TerminalSnapshotResult> {
471 let mut args = object([("group_id", json!(group_id)), ("actor_id", json!(actor_id))]);
472 if let Some(limit_bytes) = options.limit_bytes {
473 args.insert("limit_bytes".into(), json!(limit_bytes));
474 }
475 if let Some(by) = options.by.as_deref() {
476 args.insert("by".into(), json!(by));
477 }
478 self.call_typed("terminal_snapshot", args)
479 }
480
481 pub fn term_resize(
484 &self,
485 group_id: &str,
486 actor_id: &str,
487 cols: u32,
488 rows: u32,
489 ) -> Result<TerminalResizeResult> {
490 let args = object([
491 ("group_id", json!(group_id)),
492 ("actor_id", json!(actor_id)),
493 ("cols", json!(cols)),
494 ("rows", json!(rows)),
495 ]);
496 match self.call_typed("term_resize", args.clone()) {
497 Err(Error::Daemon(error)) if error.code == "unknown_op" => {
498 let legacy = self.call("terminal_resize", args)?;
499 let resolved_cols = legacy
500 .get("cols")
501 .and_then(Value::as_u64)
502 .and_then(|value| u32::try_from(value).ok())
503 .unwrap_or(cols);
504 let resolved_rows = legacy
505 .get("rows")
506 .and_then(Value::as_u64)
507 .and_then(|value| u32::try_from(value).ok())
508 .unwrap_or(rows);
509 Ok(TerminalResizeResult {
510 group_id: group_id.to_owned(),
511 actor_id: actor_id.to_owned(),
512 cols: resolved_cols,
513 rows: resolved_rows,
514 })
515 }
516 result => result,
517 }
518 }
519
520 pub fn web_model_delivery_preferences_get(
521 &self,
522 group_id: &str,
523 actor_id: &str,
524 ) -> Result<WebModelDeliveryPreferencesResult> {
525 self.call_typed(
526 "web_model_delivery_preferences_get",
527 object([("group_id", json!(group_id)), ("actor_id", json!(actor_id))]),
528 )
529 }
530
531 pub fn web_model_delivery_preferences_update(
532 &self,
533 group_id: &str,
534 actor_id: &str,
535 mode: WebModelDeliveryMode,
536 by: &str,
537 ) -> Result<WebModelDeliveryPreferencesResult> {
538 self.call_typed(
539 "web_model_delivery_preferences_update",
540 object([
541 ("group_id", json!(group_id)),
542 ("actor_id", json!(actor_id)),
543 ("mode", json!(mode)),
544 ("by", json!(by)),
545 ]),
546 )
547 }
548
549 pub fn web_model_runtime_recover_turn(
550 &self,
551 group_id: &str,
552 actor_id: &str,
553 event_ids: &[String],
554 ) -> Result<WebModelRuntimeRecoverTurnResult> {
555 if event_ids.is_empty() || event_ids.iter().any(|event_id| event_id.trim().is_empty()) {
556 return Err(Error::InvalidArgument(
557 "event_ids must contain at least one non-empty event id".into(),
558 ));
559 }
560 self.call_typed(
561 "web_model_runtime_recover_turn",
562 object([
563 ("group_id", json!(group_id)),
564 ("actor_id", json!(actor_id)),
565 ("event_ids", json!(event_ids)),
566 ]),
567 )
568 }
569
570 pub fn assert_compatible(
572 &self,
573 requirements: &CompatibilityRequirements<'_>,
574 ) -> Result<PingResult> {
575 let ping = self.ping()?;
576 let minimum = requirements.minimum_ipc_version.max(1);
577 if ping.ipc_v < minimum {
578 return Err(Error::Incompatible(format!(
579 "daemon ipc_v={} is below required ipc_v={minimum}",
580 ping.ipc_v
581 )));
582 }
583 for (name, required) in &requirements.capabilities {
584 if *required && ping.capabilities.get(*name).and_then(Value::as_bool) != Some(true) {
585 return Err(Error::Incompatible(format!(
586 "daemon capability {name}=true is required"
587 )));
588 }
589 }
590 for operation in &requirements.operations {
591 if *operation == "ping" {
592 continue;
593 }
594 let advertised = ping.capabilities.get(*operation).and_then(Value::as_bool);
595 if advertised == Some(false) {
596 return Err(Error::Incompatible(format!(
597 "daemon does not support operation {operation}"
598 )));
599 }
600 if !operation_probe_is_safe(operation) {
601 if advertised == Some(true) {
602 continue;
603 }
604 return Err(Error::Incompatible(format!("cannot safely verify operation {operation}; no advertised capability or safe probe")));
605 }
606 match self.call(operation, Map::new()) {
607 Err(Error::Daemon(error)) if error.code == "unknown_op" => {
608 if *operation == "term_resize" {
609 match self.call("terminal_resize", Map::new()) {
610 Err(Error::Daemon(alias_error)) if alias_error.code == "unknown_op" => {
611 }
612 Err(Error::Daemon(_)) | Ok(_) => continue,
613 Err(error) => return Err(error),
614 }
615 }
616 return Err(Error::Incompatible(format!(
617 "daemon does not support operation {operation}"
618 )));
619 }
620 Err(Error::Daemon(_)) | Ok(_) => {}
621 Err(error) => return Err(error),
622 }
623 }
624 Ok(ping)
625 }
626
627 fn call_typed<T: DeserializeOwned>(&self, op: &str, args: Map<String, Value>) -> Result<T> {
628 let result = self.call(op, args)?;
629 Ok(serde_json::from_value(Value::Object(result))?)
630 }
631}
632
633fn operation_probe_is_safe(operation: &str) -> bool {
635 matches!(
636 operation,
637 "groups"
638 | "group_show"
639 | "group_preamble_get"
640 | "group_preamble_set"
641 | "group_preamble_reset"
642 | "send"
643 | "tracked_send"
644 | "send_files"
645 | "reply"
646 | "inbox_peek"
647 | "inbox_read"
648 | "context_get"
649 | "context_sync"
650 | "message_deliver"
651 | "message_history"
652 | "reply_request_cancel"
653 | "send_cross_group"
654 | "memory_search"
655 | "memory_get"
656 | "memory_write"
657 | "memory_profile_get"
658 | "memory_health"
659 | "actor_new_session"
660 | "group_reset"
661 | "group_copy_export_file"
662 | "terminal_history"
663 | "terminal_since"
664 | "terminal_snapshot"
665 | "term_resize"
666 | "web_model_delivery_preferences_get"
667 | "web_model_delivery_preferences_update"
668 | "web_model_runtime_recover_turn"
669 | "events_stream"
670 | "connect_catalog"
671 | "connect_send"
672 | "connect_send_files"
673 )
674}
675
676fn finish_attempt_error(op: &str, error: CallAttemptError) -> Error {
677 match error {
678 CallAttemptError::Connect(error) => error,
679 CallAttemptError::Exchange(error) => Error::OutcomeUnknown {
680 op: op.to_owned(),
681 message: error.to_string(),
682 },
683 }
684}
685
686fn object<const N: usize>(entries: [(&str, Value); N]) -> Map<String, Value> {
687 entries
688 .into_iter()
689 .map(|(key, value)| (key.to_owned(), value))
690 .collect()
691}
692
693fn exchange<S: Read + Write>(stream: &mut S, request: &[u8]) -> Result<DaemonResponse> {
694 stream.write_all(request)?;
695 stream.flush()?;
696
697 let mut reader = BufReader::new(stream);
698 let mut response = Vec::new();
699 let read = reader
700 .by_ref()
701 .take((MAX_RESPONSE_BYTES + 1) as u64)
702 .read_until(b'\n', &mut response)?;
703 if read == 0 {
704 return Err(Error::EmptyResponse);
705 }
706 if response.len() > MAX_RESPONSE_BYTES {
707 return Err(Error::ResponseTooLarge(MAX_RESPONSE_BYTES));
708 }
709 Ok(serde_json::from_slice(&response)?)
710}
711
712#[cfg(test)]
713mod tests {
714 use super::*;
715 use std::env;
716 use std::fs;
717 use std::net::TcpListener;
718 use std::thread;
719 use std::time::{SystemTime, UNIX_EPOCH};
720
721 fn server_once(response: &'static str) -> (DaemonEndpoint, thread::JoinHandle<String>) {
722 let listener = TcpListener::bind("127.0.0.1:0").expect("bind test server");
723 let address = listener.local_addr().expect("local address");
724 let handle = thread::spawn(move || {
725 let (mut stream, _) = listener.accept().expect("accept client");
726 let mut request = String::new();
727 BufReader::new(&mut stream)
728 .read_line(&mut request)
729 .expect("read request");
730 stream
731 .write_all(response.as_bytes())
732 .expect("write response");
733 request
734 });
735 (
736 DaemonEndpoint::Tcp {
737 host: "127.0.0.1".into(),
738 port: address.port(),
739 },
740 handle,
741 )
742 }
743
744 fn server_sequence(
745 responses: Vec<&'static str>,
746 ) -> (DaemonEndpoint, thread::JoinHandle<Vec<String>>) {
747 let listener = TcpListener::bind("127.0.0.1:0").expect("bind test server");
748 let address = listener.local_addr().expect("local address");
749 let handle = thread::spawn(move || {
750 responses
751 .into_iter()
752 .map(|response| {
753 let (mut stream, _) = listener.accept().expect("accept client");
754 let mut request = String::new();
755 BufReader::new(&mut stream)
756 .read_line(&mut request)
757 .expect("read request");
758 stream
759 .write_all(response.as_bytes())
760 .expect("write response");
761 request
762 })
763 .collect()
764 });
765 (
766 DaemonEndpoint::Tcp {
767 host: "127.0.0.1".into(),
768 port: address.port(),
769 },
770 handle,
771 )
772 }
773
774 fn temp_home(label: &str) -> PathBuf {
775 let nonce = SystemTime::now()
776 .duration_since(UNIX_EPOCH)
777 .expect("clock")
778 .as_nanos();
779 env::temp_dir().join(format!(
780 "cccc-sdk-client-{label}-{}-{nonce}",
781 std::process::id()
782 ))
783 }
784
785 fn write_tcp_descriptor(home: &Path, port: u16) {
786 let daemon = home.join("daemon");
787 fs::create_dir_all(&daemon).expect("create daemon directory");
788 fs::write(
789 daemon.join("ccccd.addr.json"),
790 format!(r#"{{"v":1,"transport":"tcp","host":"127.0.0.1","port":{port}}}"#),
791 )
792 .expect("write descriptor");
793 }
794
795 #[test]
796 fn sends_a_valid_ping_envelope() {
797 let (endpoint, server) = server_once(
798 "{\"v\":1,\"ok\":true,\"result\":{\"version\":\"0.4.33\",\"implementation\":\"rust\",\"ipc_v\":1,\"capabilities\":{}}}\n",
799 );
800 let ping = CCCCClient::new(endpoint).ping().expect("ping");
801 assert_eq!(ping.version, "0.4.33");
802 assert_eq!(ping.ipc_v, 1);
803
804 let request: Value =
805 serde_json::from_str(&server.join().expect("server thread")).expect("request JSON");
806 assert_eq!(request, json!({"v": 1, "op": "ping", "args": {}}));
807 }
808
809 #[test]
810 fn preserves_structured_daemon_errors() {
811 let (endpoint, server) = server_once(
812 "{\"v\":1,\"ok\":false,\"result\":{},\"error\":{\"code\":\"unknown_op\",\"message\":\"unknown\",\"details\":{}}}\n",
813 );
814 let error = CCCCClient::new(endpoint)
815 .call("future_op", Map::new())
816 .expect_err("daemon error");
817 assert!(matches!(
818 error,
819 Error::Daemon(crate::DaemonError { ref code, .. }) if code == "unknown_op"
820 ));
821 server.join().expect("server thread");
822 }
823
824 #[test]
825 fn maps_current_message_delivery_and_inbox_operations() {
826 let response = "{\"v\":1,\"ok\":true,\"result\":{}}\n";
827 let (endpoint, server) = server_sequence(vec![response; 7]);
828 let client = CCCCClient::new(endpoint);
829
830 client
831 .send("g_1", "FYI", MessageMode::Mail, "user")
832 .expect("send Mail");
833 client
834 .reply_with_mode(
835 "g_1",
836 "e_request",
837 "quiet follow-up",
838 ReplyMessageMode::Mail,
839 "peer-1",
840 )
841 .expect("reply with Mail");
842 client
843 .reply_request_cancel("g_1", "e_request", "user")
844 .expect("cancel reply request");
845 client
846 .message_deliver("g_1", "e_mail", &["peer-1"], "user", true)
847 .expect("deliver existing message");
848 client
849 .inbox_peek("g_1", "peer-1", "user", Some(5))
850 .expect("peek Inbox");
851 client
852 .inbox_read("g_1", "peer-1", "peer-1", Some(3))
853 .expect("read Inbox");
854 client
855 .message_history(
856 "g_1",
857 "peer-1",
858 "user",
859 MessageHistoryMode::Send,
860 Some("decision"),
861 Some("e_before"),
862 Some(7),
863 )
864 .expect("read message history");
865
866 let requests: Vec<Value> = server
867 .join()
868 .expect("server thread")
869 .iter()
870 .map(|request| serde_json::from_str(request).expect("request JSON"))
871 .collect();
872 assert_eq!(requests[0]["args"]["message_mode"], "mail");
873 assert_eq!(requests[1]["op"], "reply");
874 assert_eq!(requests[1]["args"]["message_mode"], "mail");
875 assert_eq!(requests[2]["op"], "reply_request_cancel");
876 assert_eq!(requests[3]["args"]["actor_ids"], json!(["peer-1"]));
877 assert_eq!(requests[3]["args"]["force_ambiguous"], true);
878 assert_eq!(requests[4]["op"], "inbox_peek");
879 assert_eq!(requests[5]["op"], "inbox_read");
880 assert_eq!(requests[6]["op"], "message_history");
881 assert_eq!(requests[6]["args"]["mode"], "send");
882 assert_eq!(requests[6]["args"]["query"], "decision");
883 assert_eq!(requests[6]["args"]["before_event_id"], "e_before");
884 assert_eq!(requests[6]["args"]["limit"], 7);
885 assert!(matches!(
886 client.message_deliver("g_1", "e_mail", &[], "user", false),
887 Err(Error::InvalidArgument(_))
888 ));
889 }
890
891 #[test]
892 fn context_get_uses_the_current_projection_contract() {
893 let (endpoint, server) = server_once("{\"v\":1,\"ok\":true,\"result\":{}}\n");
894 CCCCClient::new(endpoint)
895 .context_get_with_detail("g_1", ContextDetail::Summary)
896 .expect("summary context");
897 let request: Value =
898 serde_json::from_str(&server.join().expect("server thread")).expect("request JSON");
899 assert_eq!(
900 request,
901 json!({"v": 1, "op": "context_get", "args": {"group_id": "g_1", "detail": "summary"}})
902 );
903 }
904
905 #[test]
906 fn never_probes_destructive_or_streaming_operations() {
907 assert!(!operation_probe_is_safe("shutdown"));
908 assert!(!operation_probe_is_safe("group_create"));
909 assert!(!operation_probe_is_safe("remote_access_start"));
910 assert!(!operation_probe_is_safe("term_attach"));
911 assert!(operation_probe_is_safe("group_show"));
912 }
913
914 #[test]
915 fn call_raw_rejects_an_unsupported_response_version() {
916 let (endpoint, server) = server_once("{\"v\":2,\"ok\":true,\"result\":{}}\n");
917 let error = CCCCClient::new(endpoint)
918 .call_raw("ping", Map::new())
919 .expect_err("unsupported version");
920 assert!(matches!(error, Error::UnsupportedIpcVersion(2)));
921 server.join().expect("server thread");
922 }
923
924 #[test]
925 fn maps_current_terminal_and_web_model_operations() {
926 let preference = "{\"v\":1,\"ok\":true,\"result\":{\"group_id\":\"g_1\",\"actor_id\":\"web-1\",\"preference\":{\"mode\":\"image_compat\",\"updated_at\":\"now\",\"updated_by\":\"user\"}}}\n";
927 let (endpoint, server) = server_sequence(vec![
928 "{\"v\":1,\"ok\":true,\"result\":{\"data\":\"screen\",\"start_cursor\":1,\"end_cursor\":7}}\n",
929 preference,
930 preference,
931 "{\"v\":1,\"ok\":true,\"result\":{\"status\":\"recovered\",\"turn\":{\"turn_id\":\"turn-1\",\"group_id\":\"g_1\",\"actor_id\":\"web-1\",\"event_ids\":[\"e_1\"],\"latest_event_id\":\"e_1\",\"latest_ts\":\"now\",\"messages\":[],\"coalesced_text\":\"hello\",\"system_prompt\":\"system\",\"delivery\":{\"mode\":\"recovery_no_cursor_mutation\",\"cursor_committed\":true,\"web_model_mode\":\"image_compat\"}}}}\n",
932 ]);
933 let client = CCCCClient::new(endpoint);
934
935 let snapshot = client
936 .terminal_snapshot(
937 "g_1",
938 "web-1",
939 &TerminalSnapshotOptions {
940 limit_bytes: Some(4096),
941 by: Some("user".into()),
942 },
943 )
944 .expect("terminal snapshot");
945 assert_eq!(snapshot.end_cursor, 7);
946 let preference = client
947 .web_model_delivery_preferences_get("g_1", "web-1")
948 .expect("preference get");
949 assert_eq!(
950 preference.preference.mode,
951 WebModelDeliveryMode::ImageCompat
952 );
953 client
954 .web_model_delivery_preferences_update(
955 "g_1",
956 "web-1",
957 WebModelDeliveryMode::ImageCompat,
958 "user",
959 )
960 .expect("preference update");
961 let recovered = client
962 .web_model_runtime_recover_turn("g_1", "web-1", &["e_1".into()])
963 .expect("recover turn");
964 assert_eq!(recovered.status, "recovered");
965
966 let requests: Vec<Value> = server
967 .join()
968 .expect("server thread")
969 .iter()
970 .map(|request| serde_json::from_str(request).expect("request JSON"))
971 .collect();
972 assert_eq!(
973 requests
974 .iter()
975 .map(|request| request["op"].as_str().expect("op"))
976 .collect::<Vec<_>>(),
977 vec![
978 "terminal_snapshot",
979 "web_model_delivery_preferences_get",
980 "web_model_delivery_preferences_update",
981 "web_model_runtime_recover_turn",
982 ]
983 );
984 assert_eq!(requests[0]["args"]["limit_bytes"], 4096);
985 assert_eq!(requests[2]["args"]["mode"], "image_compat");
986 assert_eq!(requests[3]["args"]["event_ids"], json!(["e_1"]));
987 }
988
989 #[test]
990 fn term_resize_prefers_the_standard_op_and_falls_back_for_unknown_op() {
991 let (endpoint, server) = server_sequence(vec![
992 "{\"v\":1,\"ok\":false,\"result\":{},\"error\":{\"code\":\"unknown_op\",\"message\":\"unknown\",\"details\":{}}}\n",
993 "{\"v\":1,\"ok\":true,\"result\":{\"resized\":true,\"cols\":120,\"rows\":40}}\n",
994 ]);
995 let resized = CCCCClient::new(endpoint)
996 .term_resize("g_1", "a_1", 120, 40)
997 .expect("resize");
998 assert_eq!(resized.group_id, "g_1");
999 assert_eq!(resized.actor_id, "a_1");
1000 assert_eq!(resized.cols, 120);
1001 let requests = server.join().expect("server thread");
1002 let operations: Vec<String> = requests
1003 .iter()
1004 .map(|request| {
1005 serde_json::from_str::<Value>(request).expect("request JSON")["op"]
1006 .as_str()
1007 .expect("op")
1008 .to_owned()
1009 })
1010 .collect();
1011 assert_eq!(operations, vec!["term_resize", "terminal_resize"]);
1012 }
1013
1014 #[test]
1015 fn compatibility_probe_accepts_the_bounded_resize_alias() {
1016 let (endpoint, server) = server_sequence(vec![
1017 "{\"v\":1,\"ok\":true,\"result\":{\"version\":\"test\",\"implementation\":\"rust\",\"ipc_v\":1,\"capabilities\":{}}}\n",
1018 "{\"v\":1,\"ok\":false,\"result\":{},\"error\":{\"code\":\"unknown_op\",\"message\":\"unknown\",\"details\":{}}}\n",
1019 "{\"v\":1,\"ok\":false,\"result\":{},\"error\":{\"code\":\"invalid_request\",\"message\":\"missing args\",\"details\":{}}}\n",
1020 ]);
1021 let requirements = CompatibilityRequirements {
1022 minimum_ipc_version: 1,
1023 operations: vec!["term_resize"],
1024 ..Default::default()
1025 };
1026 CCCCClient::new(endpoint)
1027 .assert_compatible(&requirements)
1028 .expect("legacy alias is usable through the SDK");
1029 let operations: Vec<String> = server
1030 .join()
1031 .expect("server thread")
1032 .iter()
1033 .map(|request| {
1034 serde_json::from_str::<Value>(request).expect("request JSON")["op"]
1035 .as_str()
1036 .expect("op")
1037 .to_owned()
1038 })
1039 .collect();
1040 assert_eq!(operations, vec!["ping", "term_resize", "terminal_resize"]);
1041 }
1042
1043 #[test]
1044 fn unknown_or_side_effectful_probes_fail_without_dispatch() {
1045 for op in [
1046 "connect_direct_configure",
1047 "membership_logout",
1048 "future_mutation",
1049 ] {
1050 let (endpoint, server) = server_sequence(vec![
1051 "{\"v\":1,\"ok\":true,\"result\":{\"implementation\":\"rust\",\"ipc_v\":1,\"capabilities\":{}}}\n",
1052 ]);
1053 let requirements = CompatibilityRequirements {
1054 operations: vec![op],
1055 ..Default::default()
1056 };
1057 let error = CCCCClient::new(endpoint)
1058 .assert_compatible(&requirements)
1059 .unwrap_err();
1060 assert!(matches!(error, Error::Incompatible(_)));
1061 assert!(error.to_string().contains("cannot safely verify"));
1062 assert_eq!(server.join().unwrap().len(), 1);
1063 }
1064 }
1065
1066 #[test]
1067 fn connect_and_checked_context_keep_identity_and_conflict_tokens() {
1068 let (endpoint, server) = server_sequence(vec![
1069 "{\"v\":1,\"ok\":true,\"result\":{\"accepted\":true}}\n",
1070 "{\"v\":1,\"ok\":false,\"error\":{\"code\":\"version_conflict\",\"message\":\"changed\",\"details\":{}}}\n",
1071 ]);
1072 let client = CCCCClient::new(endpoint);
1073 let mut options = crate::ConnectSendOptions {
1074 group_id: "local".into(),
1075 instance_id: "peer".into(),
1076 target_group_id: "remote".into(),
1077 client_id: "key".into(),
1078 text: "hello".into(),
1079 message_mode: MessageMode::Mail,
1080 by: None,
1081 to: None,
1082 format: None,
1083 insight: None,
1084 attachments: None,
1085 };
1086 client.connect_send(&options).unwrap();
1087 let error = client
1088 .context_sync_checked("local", "user", vec![], Some("version"), false)
1089 .unwrap_err();
1090 assert!(matches!(error, Error::Daemon(ref e) if e.code == "version_conflict"));
1091 for key in ["".to_owned(), "界".repeat(43)] {
1092 options.client_id = key;
1093 assert!(matches!(
1094 client.connect_send(&options),
1095 Err(Error::InvalidArgument(_))
1096 ));
1097 }
1098 let calls: Vec<Value> = server
1099 .join()
1100 .unwrap()
1101 .iter()
1102 .map(|line| serde_json::from_str(line).unwrap())
1103 .collect();
1104 assert_eq!(calls[0]["args"]["instance_id"], "peer");
1105 assert_eq!(calls[0]["args"]["target_group_id"], "remote");
1106 assert_eq!(calls[0]["args"]["client_id"], "key");
1107 assert_eq!(calls[0]["args"]["message_mode"], "mail");
1108 assert_eq!(calls[1]["args"]["if_version"], "version");
1109 }
1110
1111 #[test]
1112 fn rediscovers_endpoint_after_connect_failure_before_exchange() {
1113 let home = temp_home("rediscover");
1114 let stale = TcpListener::bind("127.0.0.1:0").expect("bind stale port");
1115 let stale_port = stale.local_addr().expect("stale address").port();
1116 drop(stale);
1117 write_tcp_descriptor(&home, stale_port);
1118 let client = CCCCClient::discover_in(Some(&home)).expect("discover stale endpoint");
1119
1120 let listener = TcpListener::bind("127.0.0.1:0").expect("bind live server");
1121 let live_port = listener.local_addr().expect("live address").port();
1122 write_tcp_descriptor(&home, live_port);
1123 let server = thread::spawn(move || {
1124 let (mut stream, _) = listener.accept().expect("accept client");
1125 let mut request = String::new();
1126 BufReader::new(&mut stream)
1127 .read_line(&mut request)
1128 .expect("read request");
1129 stream
1130 .write_all(b"{\"v\":1,\"ok\":true,\"result\":{\"implementation\":\"rust\",\"ipc_v\":1,\"capabilities\":{}}}\n")
1131 .expect("write response");
1132 });
1133
1134 assert_eq!(client.ping().expect("ping").ipc_v, 1);
1135 assert_eq!(
1136 client.current_endpoint(),
1137 DaemonEndpoint::Tcp {
1138 host: "127.0.0.1".into(),
1139 port: live_port,
1140 }
1141 );
1142 server.join().expect("server thread");
1143 fs::remove_dir_all(home).expect("cleanup");
1144 }
1145
1146 #[test]
1147 fn exchange_failure_is_reported_as_outcome_unknown_without_replay() {
1148 let listener = TcpListener::bind("127.0.0.1:0").expect("bind test server");
1149 let address = listener.local_addr().expect("local address");
1150 let server = thread::spawn(move || {
1151 let (mut stream, _) = listener.accept().expect("accept client");
1152 let mut request = String::new();
1153 BufReader::new(&mut stream)
1154 .read_line(&mut request)
1155 .expect("read request");
1156 });
1157 let client = CCCCClient::new(DaemonEndpoint::Tcp {
1158 host: "127.0.0.1".into(),
1159 port: address.port(),
1160 });
1161 assert!(matches!(
1162 client.call("non_idempotent_write", Map::new()),
1163 Err(Error::OutcomeUnknown { ref op, .. }) if op == "non_idempotent_write"
1164 ));
1165 server.join().expect("server thread");
1166 }
1167}