1use std::ffi::OsStr;
8use std::fs::OpenOptions;
9use std::io;
10use std::path::{Path, PathBuf};
11use std::process::Stdio;
12use std::sync::OnceLock;
13use std::time::Duration;
14
15use proc_guard::JobObject;
16use serde_json::{json, Value};
17use tokio::io::AsyncReadExt;
18use tokio::process::Command;
19
20use super::{service_running, Lifecycle, Params, Transport, TransportError, WaitOutcome};
21use crate::error::{ErrorCode, ToolError};
22use crate::liveness::{ProcessProbe, SystemProbe};
23use crate::openapi::{validate_params, validate_session_id, Method, Operation};
24use crate::settings::Settings;
25
26pub const STDOUT_READ_CEILING: u64 = 32 * 1024 * 1024;
28
29const SIZE_POLL: Duration = Duration::from_millis(100);
30const STDERR_CAP: usize = 256 * 1024;
31const STDERR_DRAIN: Duration = Duration::from_secs(2);
32
33pub struct CliTransport {
34 bin: OnceLock<Result<PathBuf, TransportError>>,
35 bin_hint: Option<PathBuf>,
36 server_url: Option<String>,
37 stdout_dir: PathBuf,
38 call_timeout: Duration,
39 body_max_bytes: usize,
40 extra_env: Vec<(String, String)>,
41 removed_env: Vec<String>,
42 path: Option<String>,
43 user_home: PathBuf,
45}
46
47impl CliTransport {
48 pub fn new(settings: &Settings, home: &Path, user_home: &Path) -> Self {
50 let pid = std::process::id();
51 let start = SystemProbe
52 .starttime(pid)
53 .unwrap_or_else(|| "0".to_string());
54 Self {
55 bin: OnceLock::new(),
56 bin_hint: settings.opencode_bin.clone(),
57 server_url: settings.server_url.clone(),
58 stdout_dir: home.join("stdout").join(format!("{pid}-{start}")),
59 call_timeout: Duration::from_secs(settings.call_timeout_s),
60 body_max_bytes: settings.body_max_bytes,
61 extra_env: Vec::new(),
62 removed_env: Vec::new(),
63 path: None,
64 user_home: user_home.to_path_buf(),
65 }
66 }
67
68 pub fn with_extra_env(mut self, env: Vec<(String, String)>) -> Self {
70 self.extra_env = env;
71 self
72 }
73
74 pub fn with_env_removed(mut self, names: Vec<String>) -> Self {
76 self.removed_env = names;
77 self
78 }
79
80 pub fn with_path(mut self, path: Option<String>) -> Self {
82 self.path = path;
83 self
84 }
85
86 pub fn stdout_dir(&self) -> &Path {
88 &self.stdout_dir
89 }
90
91 fn bin(&self) -> Result<&Path, TransportError> {
92 let r = self.bin.get_or_init(|| {
93 resolve_binary(
94 self.bin_hint.as_deref(),
95 self.path.as_deref(),
96 &self.user_home,
97 )
98 .map_err(from_tool)
99 });
100 match r {
101 Ok(p) => Ok(p.as_path()),
102 Err(e) => Err(e.clone()),
103 }
104 }
105}
106
107fn from_tool(e: ToolError) -> TransportError {
108 TransportError {
109 code: e.code,
110 message: e.message,
111 hint: e.hint,
112 details: e.details,
113 }
114}
115
116pub fn npm_shim_exe(dir: &Path) -> Option<PathBuf> {
120 if !dir.join("opencode.cmd").is_file() {
121 return None;
122 }
123 let exe = dir.join("node_modules/@opencode/cli/bin/opencode.exe");
124 exe.is_file().then_some(exe)
125}
126
127pub fn resolve_binary(
131 explicit: Option<&Path>,
132 path: Option<&str>,
133 home_dir: &Path,
134) -> Result<PathBuf, ToolError> {
135 let name = if cfg!(windows) {
136 "opencode.exe"
137 } else {
138 "opencode"
139 };
140 if let Some(p) = explicit {
141 return Ok(p.to_path_buf());
142 }
143 if let Some(path) = path {
144 for dir in std::env::split_paths(OsStr::new(path)) {
145 if dir.as_os_str().is_empty() {
146 continue;
147 }
148 let cand = dir.join(name);
149 if cand.is_file() {
150 return Ok(cand);
151 }
152 if cfg!(windows) {
153 if let Some(exe) = npm_shim_exe(&dir) {
154 return Ok(exe);
155 }
156 }
157 }
158 }
159 let fallback = home_dir.join(".opencode").join("bin").join(name);
160 if fallback.is_file() {
161 return Ok(fallback);
162 }
163 Err(
164 ToolError::new(ErrorCode::OpencodeNotFound, "opencode executable not found")
165 .hint("install opencode or set OPENCODE_CTL_BIN"),
166 )
167}
168
169fn spawn_error(e: &io::Error) -> TransportError {
171 match e.kind() {
172 io::ErrorKind::NotFound | io::ErrorKind::PermissionDenied => TransportError {
173 code: ErrorCode::OpencodeNotFound,
174 message: format!("opencode executable could not be started: {e}"),
175 hint: Some("install opencode or set OPENCODE_CTL_BIN".to_string()),
176 details: None,
177 },
178 _ => TransportError {
179 code: ErrorCode::IoError,
180 message: format!("could not start opencode: {e}"),
181 hint: None,
182 details: None,
183 },
184 }
185}
186
187fn io_error(what: &str, e: &io::Error) -> TransportError {
188 TransportError {
189 code: ErrorCode::IoError,
190 message: format!("{what}: {e}"),
191 hint: None,
192 details: None,
193 }
194}
195
196pub enum Mapped {
198 Error(TransportError),
199 ProbeNeeded(TransportError),
201}
202
203fn err(code: ErrorCode, message: impl Into<String>) -> TransportError {
204 TransportError {
205 code,
206 message: message.into(),
207 hint: None,
208 details: None,
209 }
210}
211
212const AUTH_HINT: &str =
213 "compare `opencode service get password` (local service) or the remote's password; \
214the CLI reads OPENCODE_PASSWORD, then OPENCODE_SERVER_PASSWORD, from the environment";
215
216fn first_line(text: &str) -> Option<&str> {
217 text.lines().map(str::trim).find(|l| !l.is_empty())
218}
219
220fn exit_text(code: Option<i32>) -> String {
221 code.map_or_else(|| "by a signal".to_string(), |c| c.to_string())
222}
223
224pub fn map_cli_failure(status: Option<i32>, stdout: &str, stderr: &str) -> Mapped {
226 if let Ok(Value::Object(o)) = serde_json::from_str::<Value>(stdout.trim()) {
228 if let Some(tag) = o.get("_tag").and_then(Value::as_str) {
229 let message = o.get("message").and_then(Value::as_str).unwrap_or(tag);
230 let code = match tag {
231 "SessionNotFoundError" => ErrorCode::SessionNotFound,
232 "UnauthorizedError" => ErrorCode::AuthFailed,
233 "InvalidRequestError"
234 | "InvalidCursorError"
235 | "FormInvalidAnswerError"
236 | "MessageNotFoundError"
237 | "LocationNotFoundError" => ErrorCode::InvalidRequest,
238 "ServiceUnavailableError" => ErrorCode::ServiceDown,
239 "ConflictError"
240 | "SessionBusyError"
241 | "FormAlreadySettledError"
242 | "PermissionNotFoundError"
243 | "FormNotFoundError" => ErrorCode::Conflict,
244 _ => ErrorCode::CliError,
245 };
246 let mut e = err(code, message);
247 e.details = Some(json!({ "tag": tag }));
248 if code == ErrorCode::AuthFailed {
249 e.hint = Some(AUTH_HINT.to_string());
250 }
251 return Mapped::Error(e);
252 }
253 }
254 if stderr.contains("rejected the password") || stderr.contains("requires a password") {
256 let mut e = err(
257 ErrorCode::AuthFailed,
258 first_line(stderr).unwrap_or("the server rejected the password"),
259 );
260 e.hint = Some(AUTH_HINT.to_string());
261 return Mapped::Error(e);
262 }
263 if stderr.contains("Could not reach server at") {
265 let line = stderr
266 .lines()
267 .map(str::trim)
268 .find(|l| l.contains("Could not reach server at"))
269 .unwrap_or("Could not reach server");
270 let mut e = err(ErrorCode::ServiceDown, line);
271 e.hint = Some("start the service with service_start, or check server_url".to_string());
272 return Mapped::Error(e);
273 }
274 if stdout.trim().is_empty() {
276 let cause = stderr
277 .lines()
278 .map(str::trim)
279 .find_map(|l| l.strip_prefix("[cause]:"))
280 .map(str::trim);
281 if let Some(cause) = cause {
282 let msg = cause.strip_prefix("Error: ").unwrap_or(cause);
283 let code = if cause.contains("Operation not found") {
284 ErrorCode::UnknownOperation
285 } else {
286 ErrorCode::CliError
287 };
288 return Mapped::Error(err(code, msg));
289 }
290 }
291 let message = first_line(stderr).map_or_else(
293 || format!("opencode exited {} without output", exit_text(status)),
294 str::to_string,
295 );
296 Mapped::ProbeNeeded(err(ErrorCode::CliError, message))
297}
298
299fn deadline_error(what: &str, deadline: Duration, mutating: bool) -> TransportError {
300 let mut e = err(
301 ErrorCode::Deadline,
302 format!("{what} did not finish within {} s", deadline.as_secs()),
303 );
304 if mutating {
305 e.details = Some(json!({ "outcome": "unknown" }));
306 e.hint = Some(
307 "the call may have been applied: check the inbox and messages before repeating it"
308 .to_string(),
309 );
310 }
311 e
312}
313
314fn ceiling_error() -> TransportError {
315 let mut e = err(
316 ErrorCode::CliError,
317 "opencode output passed the 32 MiB read ceiling and was cut",
318 );
319 e.details = Some(json!({ "reason": "stdout_ceiling" }));
320 e
321}
322
323fn unparseable(mutating: bool) -> TransportError {
324 let mut e = err(
325 ErrorCode::CliError,
326 "opencode returned no parseable JSON on stdout",
327 );
328 let mut d = json!({ "reason": "unparseable stdout" });
329 if mutating {
330 d["outcome"] = json!("unknown");
331 e.hint = Some(
332 "the call may have been applied: check the inbox and messages before repeating it"
333 .to_string(),
334 );
335 }
336 e.details = Some(d);
337 e
338}
339
340struct StdoutFile {
342 path: PathBuf,
343}
344
345impl Drop for StdoutFile {
346 fn drop(&mut self) {
347 let _ = std::fs::remove_file(&self.path);
348 }
349}
350
351#[derive(Debug, PartialEq, Eq)]
352enum How {
353 Exited,
354 Deadline,
355 Ceiling,
356}
357
358struct Finished {
359 how: How,
360 code: Option<i32>,
361 success: bool,
362 stdout: String,
363 stderr: String,
364}
365
366impl CliTransport {
367 fn ensure_stdout_dir(&self) -> Result<(), TransportError> {
368 let mut b = std::fs::DirBuilder::new();
369 b.recursive(true);
370 #[cfg(unix)]
371 {
372 use std::os::unix::fs::DirBuilderExt;
373 b.mode(0o700);
374 }
375 b.create(&self.stdout_dir)
376 .map_err(|e| io_error("cannot create the stdout directory", &e))
377 }
378
379 fn server_args(&self, args: &mut Vec<String>) {
380 if let Some(url) = &self.server_url {
381 args.push("--server".to_string());
382 args.push(url.clone());
383 }
384 }
385
386 async fn run(&self, args: &[String], deadline: Duration) -> Result<Finished, TransportError> {
388 let bin = self.bin()?;
389 self.ensure_stdout_dir()?;
390 let path = self
391 .stdout_dir
392 .join(format!("{}.json", uuid::Uuid::new_v4()));
393 let mut opts = OpenOptions::new();
394 opts.write(true).create_new(true);
395 #[cfg(unix)]
396 {
397 use std::os::unix::fs::OpenOptionsExt;
398 opts.mode(0o600);
399 }
400 let file = opts
401 .open(&path)
402 .map_err(|e| io_error("cannot create the stdout file", &e))?;
403 let guard = StdoutFile { path };
404
405 let mut cmd = Command::new(bin);
406 cmd.args(args)
407 .envs(self.extra_env.iter().map(|(k, v)| (k, v)))
408 .stdin(Stdio::null())
409 .stdout(Stdio::from(file))
410 .stderr(Stdio::piped())
411 .kill_on_drop(true);
412 for k in &self.removed_env {
413 cmd.env_remove(k);
414 }
415 let mut job =
416 JobObject::new().map_err(|e| io_error("cannot create the process guard", &e))?;
417 #[cfg(unix)]
418 job.configure_command(cmd.as_std_mut());
419 let spawned = cmd.spawn();
420 drop(cmd); let mut child = spawned.map_err(|e| spawn_error(&e))?;
422 #[cfg(unix)]
423 if let Some(pid) = child.id() {
424 job.assign_pid(pid);
425 }
426 #[cfg(windows)]
427 if let Some(h) = child.raw_handle() {
428 if let Err(e) = job.assign_raw_handle(h) {
429 let _ = child.start_kill();
430 return Err(io_error("cannot assign the process guard", &e));
431 }
432 }
433
434 let mut stderr_pipe = child.stderr.take();
435 let stderr_task = tokio::spawn(async move {
436 let mut buf = Vec::new();
437 if let Some(p) = stderr_pipe.as_mut() {
438 let mut chunk = [0u8; 8192];
439 while let Ok(n) = p.read(&mut chunk).await {
440 if n == 0 {
441 break;
442 }
443 let room = STDERR_CAP.saturating_sub(buf.len());
444 buf.extend_from_slice(&chunk[..n.min(room)]);
445 }
446 }
447 buf
448 });
449
450 let sleep = tokio::time::sleep(deadline);
451 tokio::pin!(sleep);
452 let mut tick = tokio::time::interval(SIZE_POLL);
453 let mut how = How::Exited;
454 let mut status = None;
455 loop {
456 tokio::select! {
457 st = child.wait() => {
458 status = st.ok();
459 break;
460 }
461 _ = &mut sleep => {
462 how = How::Deadline;
463 break;
464 }
465 _ = tick.tick() => {
466 if std::fs::metadata(&guard.path).map_or(0, |m| m.len()) > STDOUT_READ_CEILING {
467 how = How::Ceiling;
468 break;
469 }
470 }
471 }
472 }
473 if how != How::Exited {
474 let _ = job.kill_group();
475 let _ = child.start_kill();
476 status = child.wait().await.ok();
477 }
478 #[cfg(windows)]
484 if how == How::Exited && job.release().is_ok() {
485 } else {
487 let _ = job.kill_group();
488 }
489 #[cfg(not(windows))]
490 let _ = job.kill_group();
491 let stderr_bytes = match tokio::time::timeout(STDERR_DRAIN, stderr_task).await {
492 Ok(Ok(b)) => b,
493 _ => Vec::new(),
494 };
495 if how == How::Exited
497 && std::fs::metadata(&guard.path).map_or(0, |m| m.len()) > STDOUT_READ_CEILING
498 {
499 how = How::Ceiling;
500 }
501 let stdout = if how == How::Exited {
502 let bytes = tokio::fs::read(&guard.path)
503 .await
504 .map_err(|e| io_error("cannot read the stdout file", &e))?;
505 String::from_utf8_lossy(&bytes).into_owned()
506 } else {
507 String::new()
508 };
509 Ok(Finished {
510 how,
511 code: status.and_then(|s| s.code()),
512 success: status.is_some_and(|s| s.success()),
513 stdout,
514 stderr: String::from_utf8_lossy(&stderr_bytes).into_owned(),
515 })
516 }
517
518 async fn probe(&self, carried: TransportError) -> TransportError {
521 let args = ["service".to_string(), "status".to_string()];
522 match self.run(&args, self.call_timeout).await {
523 Err(e) => e,
524 Ok(f) if f.how == How::Exited && (!f.success || !service_running(&f.stdout)) => {
525 let mut e = err(
526 ErrorCode::ServiceDown,
527 "the opencode service is not running",
528 );
529 e.hint = Some("start it with service_start (`opencode service start`)".to_string());
530 e
531 }
532 Ok(_) => carried,
533 }
534 }
535
536 async fn fail(&self, mapped: Mapped) -> TransportError {
538 match mapped {
539 Mapped::Error(mut e) => {
540 if let (ErrorCode::AuthFailed, Some(url)) = (e.code, &self.server_url) {
541 e.hint = Some(format!(
542 "wrong password for {url}: compare `opencode service get password` (local) or \
543 the remote's password with OPENCODE_PASSWORD / OPENCODE_SERVER_PASSWORD"
544 ));
545 }
546 e
547 }
548 Mapped::ProbeNeeded(e) if self.server_url.is_some() => e,
549 Mapped::ProbeNeeded(e) => self.probe(e).await,
550 }
551 }
552
553 async fn run_ok(
555 &self,
556 what: &str,
557 args: &[String],
558 deadline: Duration,
559 mutating: bool,
560 ) -> Result<Finished, TransportError> {
561 let f = self.run(args, deadline).await?;
562 match f.how {
563 How::Deadline => Err(deadline_error(what, deadline, mutating)),
564 How::Ceiling => Err(ceiling_error()),
565 How::Exited if f.success => Ok(f),
566 How::Exited => Err(self
567 .fail(map_cli_failure(f.code, &f.stdout, &f.stderr))
568 .await),
569 }
570 }
571}
572
573fn plain_failure(f: &Finished, what: &str) -> TransportError {
574 let message = first_line(&f.stderr).map_or_else(
575 || format!("{what} exited {} without output", exit_text(f.code)),
576 str::to_string,
577 );
578 err(ErrorCode::CliError, message)
579}
580
581fn verb(v: Lifecycle) -> &'static str {
582 match v {
583 Lifecycle::Start => "start",
584 Lifecycle::Stop => "stop",
585 Lifecycle::Restart => "restart",
586 Lifecycle::Status => "status",
587 }
588}
589
590impl Transport for CliTransport {
591 async fn call(
592 &self,
593 op: &Operation,
594 params: &Params,
595 body: Option<&Value>,
596 deadline: Duration,
597 ) -> Result<Value, TransportError> {
598 validate_params(op, params).map_err(from_tool)?;
599 for (name, value) in params {
600 if name == "sessionID" {
601 validate_session_id(value).map_err(from_tool)?;
602 }
603 }
604 let body_json = body.map(Value::to_string);
605 if body_json
606 .as_ref()
607 .is_some_and(|b| b.len() > self.body_max_bytes)
608 {
609 let mut e = err(
610 ErrorCode::BodyTooLarge,
611 format!(
612 "the request body is larger than {} bytes",
613 self.body_max_bytes
614 ),
615 );
616 e.hint = Some(
617 "shorten the prompt, or write it into the workspace and reference the path"
618 .to_string(),
619 );
620 return Err(e);
621 }
622 let mut args = vec!["api".to_string(), op.id.clone()];
623 for (k, v) in params {
624 args.push("--param".to_string());
625 args.push(format!("{k}={v}"));
626 }
627 if let Some(b) = body_json {
628 args.push("-d".to_string());
629 args.push(b);
630 }
631 self.server_args(&mut args);
632
633 let mutating = op.mutating();
634 let attempts = if op.method == Method::Get { 2 } else { 1 };
635 let what = format!("opencode api {}", op.id);
636 let mut last = unparseable(mutating);
637 for _ in 0..attempts {
638 let f = self.run_ok(&what, &args, deadline, mutating).await?;
639 let text = f.stdout.trim();
640 if text.is_empty() {
641 if op.success_status == 204 {
642 return Ok(json!({ "ok": true }));
643 }
644 } else if let Ok(v) = serde_json::from_str::<Value>(text) {
645 return Ok(v);
646 }
647 last = unparseable(mutating);
648 }
649 Err(last)
650 }
651
652 async fn lifecycle(&self, v: Lifecycle) -> Result<String, TransportError> {
653 let name = verb(v);
654 let args = ["service".to_string(), name.to_string()];
655 let what = format!("opencode service {name}");
656 let f = self.run(&args, self.call_timeout).await?;
657 match f.how {
658 How::Deadline => Err(deadline_error(
659 &what,
660 self.call_timeout,
661 v != Lifecycle::Status,
662 )),
663 How::Ceiling => Err(ceiling_error()),
664 How::Exited if f.success => Ok(f.stdout.trim().to_string()),
665 How::Exited => Err(plain_failure(&f, &what)),
667 }
668 }
669
670 async fn wait_idle(
671 &self,
672 session_id: &str,
673 deadline: Duration,
674 ) -> Result<WaitOutcome, TransportError> {
675 validate_session_id(session_id).map_err(from_tool)?;
676 let mut args = vec![
677 "api".to_string(),
678 "experimental.session.wait".to_string(),
679 "--param".to_string(),
680 format!("sessionID={session_id}"),
681 ];
682 self.server_args(&mut args);
683 let f = self.run(&args, deadline).await?;
684 match f.how {
685 How::Deadline => Ok(WaitOutcome::DeadlineHit),
686 How::Ceiling => Err(ceiling_error()),
687 How::Exited if f.success => Ok(WaitOutcome::Idle),
688 How::Exited => Err(self
689 .fail(map_cli_failure(f.code, &f.stdout, &f.stderr))
690 .await),
691 }
692 }
693
694 async fn fetch_openapi(&self) -> Result<Value, TransportError> {
695 let mut args = vec![
696 "api".to_string(),
697 "GET".to_string(),
698 "/openapi.json".to_string(),
699 ];
700 self.server_args(&mut args);
701 let f = self
702 .run_ok(
703 "opencode api GET /openapi.json",
704 &args,
705 self.call_timeout,
706 false,
707 )
708 .await?;
709 serde_json::from_str(f.stdout.trim()).map_err(|_| unparseable(false))
710 }
711
712 async fn cli_version(&self) -> Result<String, TransportError> {
713 let args = ["--version".to_string()];
714 let f = self.run(&args, self.call_timeout).await?;
715 match f.how {
716 How::Deadline => Err(deadline_error(
717 "opencode --version",
718 self.call_timeout,
719 false,
720 )),
721 How::Ceiling => Err(ceiling_error()),
722 How::Exited if f.success => Ok(f.stdout.trim().to_string()),
723 How::Exited => Err(plain_failure(&f, "opencode --version")),
724 }
725 }
726}
727
728#[cfg(test)]
729mod tests {
730 use super::*;
731
732 #[test]
733 fn spawn_error_kinds() {
734 for (kind, code) in [
735 (io::ErrorKind::NotFound, ErrorCode::OpencodeNotFound),
736 (io::ErrorKind::PermissionDenied, ErrorCode::OpencodeNotFound),
737 (io::ErrorKind::Other, ErrorCode::IoError),
738 ] {
739 assert_eq!(spawn_error(&io::Error::from(kind)).code, code, "{kind:?}");
740 }
741 }
742
743 #[tokio::test]
745 async fn probe_spawn_failure_is_opencode_not_found() {
746 let dir = crate::testing::temp_home();
747 let settings = Settings {
748 opencode_bin: Some(dir.path().join("missing")),
749 ..Settings::default()
750 };
751 let t = CliTransport::new(&settings, dir.path(), dir.path());
752 let carried = err(ErrorCode::CliError, "carried");
753 let e = t.probe(carried).await;
754 assert_eq!(e.code, ErrorCode::OpencodeNotFound);
755 assert_eq!(
756 std::fs::read_dir(t.stdout_dir()).map_or(0, |d| d.count()),
757 0
758 );
759 }
760}