1use super::AppState;
28use super::mcp_discovery::read_construct_mcp;
29use axum::{
30 extract::{
31 Query, State, WebSocketUpgrade,
32 ws::{Message, WebSocket},
33 },
34 http::{HeaderMap, StatusCode, header},
35 response::IntoResponse,
36};
37use futures_util::{SinkExt, StreamExt};
38use portable_pty::{CommandBuilder, NativePtySystem, PtySize, PtySystem};
39use serde::Deserialize;
40use serde_json::json;
41use std::io::{Read, Write};
42use std::path::PathBuf;
43use tracing::{debug, error, warn};
44use uuid::Uuid;
45
46const WS_PROTOCOL: &str = "construct.v1";
48
49const BEARER_SUBPROTO_PREFIX: &str = "bearer.";
51
52#[derive(Deserialize, Default)]
53pub struct TerminalQuery {
54 pub token: Option<String>,
55 pub session_id: Option<String>,
56 pub tool: Option<String>,
60 pub cwd: Option<String>,
64 pub mcp_session: Option<String>,
67 pub mcp_token: Option<String>,
69 pub cols: Option<u16>,
74 pub rows: Option<u16>,
76}
77
78#[derive(Deserialize)]
80struct ResizeMsg {
81 #[serde(rename = "type")]
82 msg_type: String,
83 cols: u16,
84 rows: u16,
85}
86
87#[derive(Debug, Clone, Copy, PartialEq, Eq)]
89pub enum CodeTool {
90 Claude,
91 Codex,
92 OpenCode,
93 Gemini,
94}
95
96impl CodeTool {
97 pub fn from_query(s: &str) -> Option<Self> {
100 match s {
101 "claude" => Some(Self::Claude),
102 "codex" => Some(Self::Codex),
103 "opencode" => Some(Self::OpenCode),
104 "gemini" => Some(Self::Gemini),
105 _ => None,
106 }
107 }
108
109 pub fn binary(self) -> &'static str {
111 match self {
112 Self::Claude => "claude",
113 Self::Codex => "codex",
114 Self::OpenCode => "opencode",
115 Self::Gemini => "gemini",
116 }
117 }
118
119 pub fn config_env(self) -> &'static str {
126 match self {
127 Self::Claude => "CLAUDE_MCP_CONFIG",
128 Self::Codex => "CODEX_MCP_CONFIG",
129 Self::OpenCode => "OPENCODE_MCP_CONFIG",
130 Self::Gemini => "GEMINI_MCP_CONFIG",
131 }
132 }
133}
134
135#[derive(Debug)]
138pub struct CliInjection {
139 pub args: Vec<String>,
141 pub files_written: Vec<PathBuf>,
143}
144
145pub fn write_cli_config(
152 tool: CodeTool,
153 temp_home: &std::path::Path,
154 mcp_url: &str,
155 session_id: &str,
156 token: &str,
157) -> Result<CliInjection, String> {
158 match tool {
159 CodeTool::Claude => {
166 let cfg_path = temp_home.join(".mcp.json");
167 let cfg = build_mcp_config_json(mcp_url, session_id, token);
168 std::fs::write(
169 &cfg_path,
170 serde_json::to_vec_pretty(&cfg).expect("serialize claude mcp config"),
171 )
172 .map_err(|e| format!("writing claude mcp config: {e}"))?;
173 Ok(CliInjection {
174 args: vec![
175 "--mcp-config".into(),
176 cfg_path.to_string_lossy().into_owned(),
177 ],
178 files_written: vec![cfg_path],
179 })
180 }
181
182 CodeTool::Codex => {
189 let dir = temp_home.join(".codex");
190 std::fs::create_dir_all(&dir).map_err(|e| format!("creating ~/.codex: {e}"))?;
191 let cfg_path = dir.join("config.toml");
192 let mut toml = String::new();
193 toml.push_str("[mcp_servers.construct]\n");
194 toml.push_str(&format!("url = {}\n", toml_string(mcp_url)));
195 toml.push_str("transport = \"http\"\n");
196 toml.push_str("[mcp_servers.construct.headers]\n");
197 toml.push_str(&format!(
198 "Authorization = {}\n",
199 toml_string(&format!("Bearer {token}"))
200 ));
201 toml.push_str(&format!(
202 "X-Construct-Session = {}\n",
203 toml_string(session_id)
204 ));
205 std::fs::write(&cfg_path, toml.as_bytes())
206 .map_err(|e| format!("writing codex config: {e}"))?;
207 Ok(CliInjection {
208 args: vec![],
209 files_written: vec![cfg_path],
210 })
211 }
212
213 CodeTool::OpenCode => {
219 let dir = temp_home.join(".config").join("opencode");
220 std::fs::create_dir_all(&dir)
221 .map_err(|e| format!("creating opencode config dir: {e}"))?;
222 let cfg_path = dir.join("config.json");
223 let cfg = json!({
224 "$schema": "https://opencode.ai/config.json",
225 "mcp": {
226 "construct": {
227 "type": "remote",
228 "url": mcp_url,
229 "enabled": true,
230 "headers": {
231 "Authorization": format!("Bearer {token}"),
232 "X-Construct-Session": session_id,
233 }
234 }
235 }
236 });
237 std::fs::write(
238 &cfg_path,
239 serde_json::to_vec_pretty(&cfg).expect("serialize opencode config"),
240 )
241 .map_err(|e| format!("writing opencode config: {e}"))?;
242 Ok(CliInjection {
243 args: vec![],
244 files_written: vec![cfg_path],
245 })
246 }
247
248 CodeTool::Gemini => {
254 let dir = temp_home.join(".gemini");
255 std::fs::create_dir_all(&dir).map_err(|e| format!("creating ~/.gemini: {e}"))?;
256 let cfg_path = dir.join("settings.json");
257 let cfg = json!({
258 "mcpServers": {
259 "construct": {
260 "httpUrl": mcp_url,
261 "headers": {
262 "Authorization": format!("Bearer {token}"),
263 "X-Construct-Session": session_id,
264 }
265 }
266 }
267 });
268 std::fs::write(
269 &cfg_path,
270 serde_json::to_vec_pretty(&cfg).expect("serialize gemini config"),
271 )
272 .map_err(|e| format!("writing gemini config: {e}"))?;
273 Ok(CliInjection {
274 args: vec![],
275 files_written: vec![cfg_path],
276 })
277 }
278 }
279}
280
281fn toml_string(s: &str) -> String {
285 let mut out = String::with_capacity(s.len() + 2);
286 out.push('"');
287 for c in s.chars() {
288 match c {
289 '\\' => out.push_str("\\\\"),
290 '"' => out.push_str("\\\""),
291 '\n' => out.push_str("\\n"),
292 '\r' => out.push_str("\\r"),
293 '\t' => out.push_str("\\t"),
294 c if (c as u32) < 0x20 => out.push_str(&format!("\\u{:04X}", c as u32)),
295 c => out.push(c),
296 }
297 }
298 out.push('"');
299 out
300}
301
302fn extract_ws_token<'a>(headers: &'a HeaderMap, query_token: Option<&'a str>) -> Option<&'a str> {
304 if let Some(t) = headers
306 .get(header::AUTHORIZATION)
307 .and_then(|v| v.to_str().ok())
308 .and_then(|auth| auth.strip_prefix("Bearer "))
309 {
310 if !t.is_empty() {
311 return Some(t);
312 }
313 }
314
315 if let Some(t) = headers
317 .get("sec-websocket-protocol")
318 .and_then(|v| v.to_str().ok())
319 .and_then(|protos| {
320 protos
321 .split(',')
322 .map(|p| p.trim())
323 .find_map(|p| p.strip_prefix(BEARER_SUBPROTO_PREFIX))
324 })
325 {
326 if !t.is_empty() {
327 return Some(t);
328 }
329 }
330
331 if let Some(t) = query_token {
333 if !t.is_empty() {
334 return Some(t);
335 }
336 }
337
338 None
339}
340
341pub fn build_mcp_config_json(mcp_url: &str, session_id: &str, token: &str) -> serde_json::Value {
350 json!({
351 "mcpServers": {
352 "construct": {
353 "type": "http",
354 "url": mcp_url,
355 "headers": {
356 "Authorization": format!("Bearer {token}"),
357 "X-Construct-Session": session_id,
358 }
359 }
360 }
361 })
362}
363
364struct TempSpawnDir(PathBuf);
367
368impl Drop for TempSpawnDir {
369 fn drop(&mut self) {
370 let _ = std::fs::remove_dir_all(&self.0);
371 }
372}
373
374fn resolve_cwd(raw: Option<&str>) -> Result<Option<PathBuf>, String> {
377 let Some(s) = raw.filter(|s| !s.is_empty()) else {
378 return Ok(None);
379 };
380 let expanded = shellexpand::tilde(s).into_owned();
381 let p = PathBuf::from(&expanded);
382 let canon = p.canonicalize().map_err(|e| format!("{s}: {e}"))?;
383 if !canon.is_dir() {
384 return Err(format!("{} is not a directory", canon.display()));
385 }
386 Ok(Some(canon))
387}
388
389pub async fn handle_ws_terminal(
391 State(state): State<AppState>,
392 Query(params): Query<TerminalQuery>,
393 headers: HeaderMap,
394 ws: WebSocketUpgrade,
395) -> impl IntoResponse {
396 if state.pairing.require_pairing() {
398 let token = extract_ws_token(&headers, params.token.as_deref()).unwrap_or("");
399 if !state.pairing.is_authenticated(token) {
400 return (StatusCode::UNAUTHORIZED, "Unauthorized").into_response();
401 }
402 }
403
404 let ws = if headers
406 .get("sec-websocket-protocol")
407 .and_then(|v| v.to_str().ok())
408 .map_or(false, |protos| {
409 protos.split(',').any(|p| p.trim() == WS_PROTOCOL)
410 }) {
411 ws.protocols([WS_PROTOCOL])
412 } else {
413 ws
414 };
415
416 if let Some(ref logger) = state.audit_logger {
417 let _ = logger.log_security_event("dashboard", "WebSocket terminal session connected");
418 }
419
420 ws.on_upgrade(move |socket| handle_terminal_socket(socket, params))
421 .into_response()
422}
423
424async fn send_err(ws_sender: &mut futures_util::stream::SplitSink<WebSocket, Message>, msg: &str) {
426 let _ = ws_sender
427 .send(Message::Text(format!("\x1b[31m{msg}\x1b[0m\r\n").into()))
428 .await;
429}
430
431struct SpawnPlan {
433 cmd: CommandBuilder,
434 _temp: Option<TempSpawnDir>,
436}
437
438fn plan_spawn(
441 tool: Option<CodeTool>,
442 cwd: Option<PathBuf>,
443 mcp_session: Option<&str>,
444 mcp_token: Option<&str>,
445) -> Result<SpawnPlan, String> {
446 let discovery_url = if tool.is_some() && mcp_session.is_some() && mcp_token.is_some() {
451 Some(
452 read_construct_mcp()
453 .map_err(|e| format!("in-process MCP server not available: {e}"))?
454 .url,
455 )
456 } else {
457 None
458 };
459 plan_spawn_with_discovery(tool, cwd, mcp_session, mcp_token, discovery_url.as_deref())
460}
461
462fn plan_spawn_with_discovery(
463 tool: Option<CodeTool>,
464 cwd: Option<PathBuf>,
465 mcp_session: Option<&str>,
466 mcp_token: Option<&str>,
467 mcp_url: Option<&str>,
468) -> Result<SpawnPlan, String> {
469 let (mut cmd, temp) = match tool {
470 Some(t) => {
471 let bin = which::which(t.binary())
472 .map_err(|_| format!("{} not found in PATH", t.binary()))?;
473 let mut cmd = CommandBuilder::new(bin);
474
475 if let (Some(sess), Some(tok), Some(url)) = (mcp_session, mcp_token, mcp_url) {
476 let dir = std::env::temp_dir().join(format!("construct-code-{}", Uuid::new_v4()));
477 std::fs::create_dir_all(&dir).map_err(|e| format!("creating temp dir: {e}"))?;
478
479 let injection = write_cli_config(t, &dir, url, sess, tok)?;
483 for a in &injection.args {
484 cmd.arg(a);
485 }
486
487 cmd.env("HOME", &dir);
492 cmd.env("XDG_CONFIG_HOME", dir.join(".config"));
493
494 cmd.env("CONSTRUCT_MCP_URL", url);
496 cmd.env("CONSTRUCT_MCP_SESSION", sess);
497 cmd.env("CONSTRUCT_MCP_TOKEN", tok);
498
499 (cmd, Some(TempSpawnDir(dir)))
500 } else {
501 (cmd, None)
502 }
503 }
504 None => {
505 let shell = std::env::var("SHELL").unwrap_or_else(|_| "/bin/sh".to_string());
506 let mut cmd = CommandBuilder::new(&shell);
507 cmd.arg("-l");
508 (cmd, None)
509 }
510 };
511
512 if let Some(c) = cwd {
513 cmd.cwd(c);
514 }
515
516 cmd.env("TERM", "xterm-256color");
522 cmd.env("COLORTERM", "truecolor");
523 for key in [
524 "PATH", "LANG", "LC_ALL", "LC_CTYPE", "USER", "LOGNAME", "SHELL", "TZ",
525 ] {
526 if let Ok(val) = std::env::var(key) {
527 cmd.env(key, val);
528 }
529 }
530 if temp.is_none() {
535 if let Ok(home) = std::env::var("HOME") {
536 cmd.env("HOME", home);
537 }
538 }
539
540 Ok(SpawnPlan { cmd, _temp: temp })
541}
542
543async fn handle_terminal_socket(socket: WebSocket, params: TerminalQuery) {
544 let (mut ws_sender, mut ws_receiver) = socket.split();
545
546 let tool = params.tool.as_deref().and_then(CodeTool::from_query);
549
550 let cwd = match resolve_cwd(params.cwd.as_deref()) {
552 Ok(c) => c,
553 Err(msg) => {
554 send_err(&mut ws_sender, &format!("Invalid cwd: {msg}")).await;
555 return;
556 }
557 };
558
559 let plan = match plan_spawn(
561 tool,
562 cwd,
563 params.mcp_session.as_deref(),
564 params.mcp_token.as_deref(),
565 ) {
566 Ok(p) => p,
567 Err(msg) => {
568 send_err(&mut ws_sender, &msg).await;
569 let _ = ws_sender.send(Message::Close(None)).await;
570 return;
571 }
572 };
573
574 let initial_size = PtySize {
575 rows: params.rows.filter(|r| *r > 0).unwrap_or(24),
576 cols: params.cols.filter(|c| *c > 0).unwrap_or(80),
577 pixel_width: 0,
578 pixel_height: 0,
579 };
580
581 let pty_system = NativePtySystem::default();
583 let pair = match pty_system.openpty(initial_size) {
584 Ok(pair) => pair,
585 Err(e) => {
586 error!(error = %e, "Failed to open PTY");
587 send_err(&mut ws_sender, &format!("Failed to open PTY: {e}")).await;
588 return;
589 }
590 };
591
592 let SpawnPlan { cmd, _temp } = plan;
593 let _child = match pair.slave.spawn_command(cmd) {
594 Ok(child) => child,
595 Err(e) => {
596 error!(error = %e, "Failed to spawn child");
597 send_err(&mut ws_sender, &format!("Failed to spawn child: {e}")).await;
598 return;
599 }
600 };
601 drop(pair.slave);
603
604 let master = pair.master;
605
606 let mut pty_reader = match master.try_clone_reader() {
607 Ok(r) => r,
608 Err(e) => {
609 error!(error = %e, "Failed to clone PTY reader");
610 return;
611 }
612 };
613
614 let mut pty_writer: Box<dyn Write + Send> = match master.take_writer() {
615 Ok(w) => w,
616 Err(e) => {
617 error!(error = %e, "Failed to take PTY writer");
618 return;
619 }
620 };
621
622 let (pty_out_tx, mut pty_out_rx) = tokio::sync::mpsc::channel::<Vec<u8>>(64);
624 let (resize_tx, mut resize_rx) = tokio::sync::mpsc::channel::<(u16, u16)>(4);
625
626 tokio::task::spawn_blocking(move || {
628 let mut buf = [0u8; 4096];
629 loop {
630 match pty_reader.read(&mut buf) {
631 Ok(0) => break,
632 Ok(n) => {
633 if pty_out_tx.blocking_send(buf[..n].to_vec()).is_err() {
634 break;
635 }
636 }
637 Err(_) => break,
638 }
639 }
640 });
641
642 tokio::spawn(async move {
644 while let Some((cols, rows)) = resize_rx.recv().await {
645 let _ = master.resize(PtySize {
646 rows,
647 cols,
648 pixel_width: 0,
649 pixel_height: 0,
650 });
651 }
652 });
653
654 loop {
656 tokio::select! {
657 Some(data) = pty_out_rx.recv() => {
659 let text = String::from_utf8_lossy(&data).into_owned();
660 if ws_sender.send(Message::Text(text.into())).await.is_err() {
661 break;
662 }
663 }
664 msg = ws_receiver.next() => {
666 match msg {
667 Some(Ok(Message::Text(text))) => {
668 if let Ok(resize) = serde_json::from_str::<ResizeMsg>(&text) {
670 if resize.msg_type == "resize" {
671 let _ = resize_tx.send((resize.cols, resize.rows)).await;
672 continue;
673 }
674 }
675 if pty_writer.write_all(text.as_bytes()).is_err() {
677 break;
678 }
679 }
680 Some(Ok(Message::Binary(data))) => {
681 if pty_writer.write_all(&data).is_err() {
682 break;
683 }
684 }
685 Some(Ok(Message::Close(_))) | None => {
686 debug!("Terminal WebSocket closed");
687 break;
688 }
689 Some(Ok(_)) => {} Some(Err(e)) => {
691 warn!(error = %e, "Terminal WebSocket error");
692 break;
693 }
694 }
695 }
696 }
697 }
698
699 drop(_temp);
701
702 debug!("Terminal session ended");
703}
704
705#[cfg(test)]
706mod tests {
707 use super::*;
708
709 #[test]
710 fn tool_mapping_known() {
711 assert_eq!(CodeTool::from_query("claude"), Some(CodeTool::Claude));
712 assert_eq!(CodeTool::from_query("codex"), Some(CodeTool::Codex));
713 assert_eq!(CodeTool::from_query("opencode"), Some(CodeTool::OpenCode));
714 assert_eq!(CodeTool::from_query("gemini"), Some(CodeTool::Gemini));
715 }
716
717 #[test]
718 fn tool_mapping_unknown_falls_back() {
719 assert_eq!(CodeTool::from_query(""), None);
720 assert_eq!(CodeTool::from_query("bash"), None);
721 assert_eq!(CodeTool::from_query("Claude"), None); assert_eq!(CodeTool::from_query("nonsense"), None);
723 }
724
725 #[test]
726 fn tool_binaries_match_docs() {
727 assert_eq!(CodeTool::Claude.binary(), "claude");
728 assert_eq!(CodeTool::Codex.binary(), "codex");
729 assert_eq!(CodeTool::OpenCode.binary(), "opencode");
730 assert_eq!(CodeTool::Gemini.binary(), "gemini");
731 }
732
733 #[test]
734 fn tool_config_env_vars() {
735 assert_eq!(CodeTool::Claude.config_env(), "CLAUDE_MCP_CONFIG");
736 assert_eq!(CodeTool::Codex.config_env(), "CODEX_MCP_CONFIG");
737 assert_eq!(CodeTool::OpenCode.config_env(), "OPENCODE_MCP_CONFIG");
738 assert_eq!(CodeTool::Gemini.config_env(), "GEMINI_MCP_CONFIG");
739 }
740
741 #[test]
742 fn mcp_config_json_has_expected_shape() {
743 let v = build_mcp_config_json("http://127.0.0.1:54500/mcp", "sess-abc", "tok-xyz");
744 let srv = &v["mcpServers"]["construct"];
745 assert_eq!(srv["url"], "http://127.0.0.1:54500/mcp");
746 assert_eq!(srv["type"], "http");
747 assert_eq!(srv["headers"]["Authorization"], "Bearer tok-xyz");
748 assert_eq!(srv["headers"]["X-Construct-Session"], "sess-abc");
749 let s = serde_json::to_string(&v).unwrap();
751 let back: serde_json::Value = serde_json::from_str(&s).unwrap();
752 assert_eq!(back, v);
753 }
754
755 #[test]
756 fn resolve_cwd_none_when_unset() {
757 assert!(matches!(resolve_cwd(None), Ok(None)));
758 assert!(matches!(resolve_cwd(Some("")), Ok(None)));
759 }
760
761 #[test]
762 fn resolve_cwd_rejects_missing_path() {
763 assert!(resolve_cwd(Some("/this/should/not/exist/construct-xyz")).is_err());
764 }
765
766 #[test]
767 fn resolve_cwd_accepts_tmp() {
768 let tmp = std::env::temp_dir();
769 let got = resolve_cwd(Some(tmp.to_str().unwrap())).unwrap().unwrap();
770 assert!(got.is_dir());
771 }
772
773 #[test]
774 fn plan_spawn_shell_fallback_no_tool() {
775 let plan = plan_spawn(None, None, None, None).expect("shell fallback works");
777 assert!(plan._temp.is_none());
780 }
781
782 #[test]
783 fn plan_spawn_missing_binary_errors() {
784 match plan_spawn(Some(CodeTool::Gemini), None, None, None) {
788 Ok(_) => {} Err(msg) => assert!(msg.contains("not found in PATH"), "got: {msg}"),
790 }
791 }
792
793 #[test]
797 fn terminal_query_default_falls_back_to_shell() {
798 let q = TerminalQuery::default();
799 assert!(q.tool.is_none());
800 assert!(q.cwd.is_none());
801 assert!(q.mcp_session.is_none());
802 assert!(q.mcp_token.is_none());
803 assert!(q.tool.as_deref().and_then(CodeTool::from_query).is_none());
804 }
805
806 fn tempdir() -> PathBuf {
809 let p = std::env::temp_dir().join(format!("construct-test-{}", Uuid::new_v4()));
810 std::fs::create_dir_all(&p).unwrap();
811 p
812 }
813
814 const URL: &str = "http://127.0.0.1:54500/mcp";
815 const SESS: &str = "sess-abc";
816 const TOK: &str = "tok-xyz";
817
818 #[test]
819 fn claude_adapter_writes_mcp_json_and_passes_flag() {
820 let home = tempdir();
821 let inj = write_cli_config(CodeTool::Claude, &home, URL, SESS, TOK).unwrap();
822
823 assert_eq!(inj.args.len(), 2);
825 assert_eq!(inj.args[0], "--mcp-config");
826 let cfg_path = PathBuf::from(&inj.args[1]);
827 assert!(cfg_path.starts_with(&home));
828 assert!(cfg_path.ends_with(".mcp.json"));
829 assert!(cfg_path.exists());
830
831 let content: serde_json::Value =
832 serde_json::from_slice(&std::fs::read(&cfg_path).unwrap()).unwrap();
833 assert_eq!(content["mcpServers"]["construct"]["url"], URL);
834 assert_eq!(
835 content["mcpServers"]["construct"]["headers"]["Authorization"],
836 format!("Bearer {TOK}")
837 );
838 assert_eq!(
839 content["mcpServers"]["construct"]["headers"]["X-Construct-Session"],
840 SESS
841 );
842 let _ = std::fs::remove_dir_all(&home);
843 }
844
845 #[test]
846 fn codex_adapter_writes_toml_at_home_dot_codex() {
847 let home = tempdir();
848 let inj = write_cli_config(CodeTool::Codex, &home, URL, SESS, TOK).unwrap();
849 assert!(inj.args.is_empty(), "codex has no flag mechanism");
850 let cfg = home.join(".codex").join("config.toml");
851 assert!(cfg.exists(), "{} should exist", cfg.display());
852 let body = std::fs::read_to_string(&cfg).unwrap();
853 assert!(body.contains("[mcp_servers.construct]"));
854 assert!(body.contains(&format!("url = \"{URL}\"")), "body: {body}");
855 assert!(body.contains("transport = \"http\""));
856 assert!(body.contains("[mcp_servers.construct.headers]"));
857 assert!(
858 body.contains(&format!("Authorization = \"Bearer {TOK}\"")),
859 "body: {body}"
860 );
861 assert!(
862 body.contains(&format!("X-Construct-Session = \"{SESS}\"")),
863 "body: {body}"
864 );
865 let _: toml::Value = toml::from_str(&body).expect("codex config should be valid TOML");
867 let _ = std::fs::remove_dir_all(&home);
868 }
869
870 #[test]
871 fn opencode_adapter_writes_xdg_json() {
872 let home = tempdir();
873 let inj = write_cli_config(CodeTool::OpenCode, &home, URL, SESS, TOK).unwrap();
874 assert!(inj.args.is_empty());
875 let cfg = home.join(".config").join("opencode").join("config.json");
876 assert!(cfg.exists());
877 let v: serde_json::Value = serde_json::from_slice(&std::fs::read(&cfg).unwrap()).unwrap();
878 let srv = &v["mcp"]["construct"];
879 assert_eq!(srv["type"], "remote");
880 assert_eq!(srv["url"], URL);
881 assert_eq!(srv["enabled"], true);
882 assert_eq!(srv["headers"]["Authorization"], format!("Bearer {TOK}"));
883 assert_eq!(srv["headers"]["X-Construct-Session"], SESS);
884 let _ = std::fs::remove_dir_all(&home);
885 }
886
887 #[test]
888 fn gemini_adapter_writes_settings_json() {
889 let home = tempdir();
890 let inj = write_cli_config(CodeTool::Gemini, &home, URL, SESS, TOK).unwrap();
891 assert!(inj.args.is_empty());
892 let cfg = home.join(".gemini").join("settings.json");
893 assert!(cfg.exists());
894 let v: serde_json::Value = serde_json::from_slice(&std::fs::read(&cfg).unwrap()).unwrap();
895 let srv = &v["mcpServers"]["construct"];
896 assert_eq!(srv["httpUrl"], URL);
897 assert_eq!(srv["headers"]["Authorization"], format!("Bearer {TOK}"));
898 assert_eq!(srv["headers"]["X-Construct-Session"], SESS);
899 let _ = std::fs::remove_dir_all(&home);
900 }
901
902 #[test]
903 fn plan_spawn_with_discovery_no_creds_no_tempdir() {
904 let plan = plan_spawn_with_discovery(None, None, None, None, Some(URL))
907 .expect("shell fallback works");
908 assert!(plan._temp.is_none());
909 }
910}