use super::AppState;
use super::mcp_discovery::read_construct_mcp;
use axum::{
extract::{
Query, State, WebSocketUpgrade,
ws::{Message, WebSocket},
},
http::{HeaderMap, StatusCode, header},
response::IntoResponse,
};
use futures_util::{SinkExt, StreamExt};
use portable_pty::{CommandBuilder, NativePtySystem, PtySize, PtySystem};
use serde::Deserialize;
use serde_json::json;
use std::io::{Read, Write};
use std::path::PathBuf;
use tracing::{debug, error, warn};
use uuid::Uuid;
const WS_PROTOCOL: &str = "construct.v1";
const BEARER_SUBPROTO_PREFIX: &str = "bearer.";
#[derive(Deserialize, Default)]
pub struct TerminalQuery {
pub token: Option<String>,
pub session_id: Option<String>,
pub tool: Option<String>,
pub cwd: Option<String>,
pub mcp_session: Option<String>,
pub mcp_token: Option<String>,
pub cols: Option<u16>,
pub rows: Option<u16>,
}
#[derive(Deserialize)]
struct ResizeMsg {
#[serde(rename = "type")]
msg_type: String,
cols: u16,
rows: u16,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum CodeTool {
Claude,
Codex,
OpenCode,
Gemini,
}
impl CodeTool {
pub fn from_query(s: &str) -> Option<Self> {
match s {
"claude" => Some(Self::Claude),
"codex" => Some(Self::Codex),
"opencode" => Some(Self::OpenCode),
"gemini" => Some(Self::Gemini),
_ => None,
}
}
pub fn binary(self) -> &'static str {
match self {
Self::Claude => "claude",
Self::Codex => "codex",
Self::OpenCode => "opencode",
Self::Gemini => "gemini",
}
}
pub fn config_env(self) -> &'static str {
match self {
Self::Claude => "CLAUDE_MCP_CONFIG",
Self::Codex => "CODEX_MCP_CONFIG",
Self::OpenCode => "OPENCODE_MCP_CONFIG",
Self::Gemini => "GEMINI_MCP_CONFIG",
}
}
}
#[derive(Debug)]
pub struct CliInjection {
pub args: Vec<String>,
pub files_written: Vec<PathBuf>,
}
pub fn write_cli_config(
tool: CodeTool,
temp_home: &std::path::Path,
mcp_url: &str,
session_id: &str,
token: &str,
) -> Result<CliInjection, String> {
match tool {
CodeTool::Claude => {
let cfg_path = temp_home.join(".mcp.json");
let cfg = build_mcp_config_json(mcp_url, session_id, token);
std::fs::write(
&cfg_path,
serde_json::to_vec_pretty(&cfg).expect("serialize claude mcp config"),
)
.map_err(|e| format!("writing claude mcp config: {e}"))?;
Ok(CliInjection {
args: vec![
"--mcp-config".into(),
cfg_path.to_string_lossy().into_owned(),
],
files_written: vec![cfg_path],
})
}
CodeTool::Codex => {
let dir = temp_home.join(".codex");
std::fs::create_dir_all(&dir).map_err(|e| format!("creating ~/.codex: {e}"))?;
let cfg_path = dir.join("config.toml");
let mut toml = String::new();
toml.push_str("[mcp_servers.construct]\n");
toml.push_str(&format!("url = {}\n", toml_string(mcp_url)));
toml.push_str("transport = \"http\"\n");
toml.push_str("[mcp_servers.construct.headers]\n");
toml.push_str(&format!(
"Authorization = {}\n",
toml_string(&format!("Bearer {token}"))
));
toml.push_str(&format!(
"X-Construct-Session = {}\n",
toml_string(session_id)
));
std::fs::write(&cfg_path, toml.as_bytes())
.map_err(|e| format!("writing codex config: {e}"))?;
Ok(CliInjection {
args: vec![],
files_written: vec![cfg_path],
})
}
CodeTool::OpenCode => {
let dir = temp_home.join(".config").join("opencode");
std::fs::create_dir_all(&dir)
.map_err(|e| format!("creating opencode config dir: {e}"))?;
let cfg_path = dir.join("config.json");
let cfg = json!({
"$schema": "https://opencode.ai/config.json",
"mcp": {
"construct": {
"type": "remote",
"url": mcp_url,
"enabled": true,
"headers": {
"Authorization": format!("Bearer {token}"),
"X-Construct-Session": session_id,
}
}
}
});
std::fs::write(
&cfg_path,
serde_json::to_vec_pretty(&cfg).expect("serialize opencode config"),
)
.map_err(|e| format!("writing opencode config: {e}"))?;
Ok(CliInjection {
args: vec![],
files_written: vec![cfg_path],
})
}
CodeTool::Gemini => {
let dir = temp_home.join(".gemini");
std::fs::create_dir_all(&dir).map_err(|e| format!("creating ~/.gemini: {e}"))?;
let cfg_path = dir.join("settings.json");
let cfg = json!({
"mcpServers": {
"construct": {
"httpUrl": mcp_url,
"headers": {
"Authorization": format!("Bearer {token}"),
"X-Construct-Session": session_id,
}
}
}
});
std::fs::write(
&cfg_path,
serde_json::to_vec_pretty(&cfg).expect("serialize gemini config"),
)
.map_err(|e| format!("writing gemini config: {e}"))?;
Ok(CliInjection {
args: vec![],
files_written: vec![cfg_path],
})
}
}
}
fn toml_string(s: &str) -> String {
let mut out = String::with_capacity(s.len() + 2);
out.push('"');
for c in s.chars() {
match c {
'\\' => out.push_str("\\\\"),
'"' => out.push_str("\\\""),
'\n' => out.push_str("\\n"),
'\r' => out.push_str("\\r"),
'\t' => out.push_str("\\t"),
c if (c as u32) < 0x20 => out.push_str(&format!("\\u{:04X}", c as u32)),
c => out.push(c),
}
}
out.push('"');
out
}
fn extract_ws_token<'a>(headers: &'a HeaderMap, query_token: Option<&'a str>) -> Option<&'a str> {
if let Some(t) = headers
.get(header::AUTHORIZATION)
.and_then(|v| v.to_str().ok())
.and_then(|auth| auth.strip_prefix("Bearer "))
{
if !t.is_empty() {
return Some(t);
}
}
if let Some(t) = headers
.get("sec-websocket-protocol")
.and_then(|v| v.to_str().ok())
.and_then(|protos| {
protos
.split(',')
.map(|p| p.trim())
.find_map(|p| p.strip_prefix(BEARER_SUBPROTO_PREFIX))
})
{
if !t.is_empty() {
return Some(t);
}
}
if let Some(t) = query_token {
if !t.is_empty() {
return Some(t);
}
}
None
}
pub fn build_mcp_config_json(mcp_url: &str, session_id: &str, token: &str) -> serde_json::Value {
json!({
"mcpServers": {
"construct": {
"type": "http",
"url": mcp_url,
"headers": {
"Authorization": format!("Bearer {token}"),
"X-Construct-Session": session_id,
}
}
}
})
}
struct TempSpawnDir(PathBuf);
impl Drop for TempSpawnDir {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.0);
}
}
fn resolve_cwd(raw: Option<&str>) -> Result<Option<PathBuf>, String> {
let Some(s) = raw.filter(|s| !s.is_empty()) else {
return Ok(None);
};
let expanded = shellexpand::tilde(s).into_owned();
let p = PathBuf::from(&expanded);
let canon = p.canonicalize().map_err(|e| format!("{s}: {e}"))?;
if !canon.is_dir() {
return Err(format!("{} is not a directory", canon.display()));
}
Ok(Some(canon))
}
pub async fn handle_ws_terminal(
State(state): State<AppState>,
Query(params): Query<TerminalQuery>,
headers: HeaderMap,
ws: WebSocketUpgrade,
) -> impl IntoResponse {
if state.pairing.require_pairing() {
let token = extract_ws_token(&headers, params.token.as_deref()).unwrap_or("");
if !state.pairing.is_authenticated(token) {
return (StatusCode::UNAUTHORIZED, "Unauthorized").into_response();
}
}
let ws = if headers
.get("sec-websocket-protocol")
.and_then(|v| v.to_str().ok())
.map_or(false, |protos| {
protos.split(',').any(|p| p.trim() == WS_PROTOCOL)
}) {
ws.protocols([WS_PROTOCOL])
} else {
ws
};
if let Some(ref logger) = state.audit_logger {
let _ = logger.log_security_event("dashboard", "WebSocket terminal session connected");
}
ws.on_upgrade(move |socket| handle_terminal_socket(socket, params))
.into_response()
}
async fn send_err(ws_sender: &mut futures_util::stream::SplitSink<WebSocket, Message>, msg: &str) {
let _ = ws_sender
.send(Message::Text(format!("\x1b[31m{msg}\x1b[0m\r\n").into()))
.await;
}
struct SpawnPlan {
cmd: CommandBuilder,
_temp: Option<TempSpawnDir>,
}
fn plan_spawn(
tool: Option<CodeTool>,
cwd: Option<PathBuf>,
mcp_session: Option<&str>,
mcp_token: Option<&str>,
) -> Result<SpawnPlan, String> {
let discovery_url = if tool.is_some() && mcp_session.is_some() && mcp_token.is_some() {
Some(
read_construct_mcp()
.map_err(|e| format!("in-process MCP server not available: {e}"))?
.url,
)
} else {
None
};
plan_spawn_with_discovery(tool, cwd, mcp_session, mcp_token, discovery_url.as_deref())
}
fn plan_spawn_with_discovery(
tool: Option<CodeTool>,
cwd: Option<PathBuf>,
mcp_session: Option<&str>,
mcp_token: Option<&str>,
mcp_url: Option<&str>,
) -> Result<SpawnPlan, String> {
let (mut cmd, temp) = match tool {
Some(t) => {
let bin = which::which(t.binary())
.map_err(|_| format!("{} not found in PATH", t.binary()))?;
let mut cmd = CommandBuilder::new(bin);
if let (Some(sess), Some(tok), Some(url)) = (mcp_session, mcp_token, mcp_url) {
let dir = std::env::temp_dir().join(format!("construct-code-{}", Uuid::new_v4()));
std::fs::create_dir_all(&dir).map_err(|e| format!("creating temp dir: {e}"))?;
let injection = write_cli_config(t, &dir, url, sess, tok)?;
for a in &injection.args {
cmd.arg(a);
}
cmd.env("HOME", &dir);
cmd.env("XDG_CONFIG_HOME", dir.join(".config"));
cmd.env("CONSTRUCT_MCP_URL", url);
cmd.env("CONSTRUCT_MCP_SESSION", sess);
cmd.env("CONSTRUCT_MCP_TOKEN", tok);
(cmd, Some(TempSpawnDir(dir)))
} else {
(cmd, None)
}
}
None => {
let shell = std::env::var("SHELL").unwrap_or_else(|_| "/bin/sh".to_string());
let mut cmd = CommandBuilder::new(&shell);
cmd.arg("-l");
(cmd, None)
}
};
if let Some(c) = cwd {
cmd.cwd(c);
}
cmd.env("TERM", "xterm-256color");
cmd.env("COLORTERM", "truecolor");
for key in [
"PATH", "LANG", "LC_ALL", "LC_CTYPE", "USER", "LOGNAME", "SHELL", "TZ",
] {
if let Ok(val) = std::env::var(key) {
cmd.env(key, val);
}
}
if temp.is_none() {
if let Ok(home) = std::env::var("HOME") {
cmd.env("HOME", home);
}
}
Ok(SpawnPlan { cmd, _temp: temp })
}
async fn handle_terminal_socket(socket: WebSocket, params: TerminalQuery) {
let (mut ws_sender, mut ws_receiver) = socket.split();
let tool = params.tool.as_deref().and_then(CodeTool::from_query);
let cwd = match resolve_cwd(params.cwd.as_deref()) {
Ok(c) => c,
Err(msg) => {
send_err(&mut ws_sender, &format!("Invalid cwd: {msg}")).await;
return;
}
};
let plan = match plan_spawn(
tool,
cwd,
params.mcp_session.as_deref(),
params.mcp_token.as_deref(),
) {
Ok(p) => p,
Err(msg) => {
send_err(&mut ws_sender, &msg).await;
let _ = ws_sender.send(Message::Close(None)).await;
return;
}
};
let initial_size = PtySize {
rows: params.rows.filter(|r| *r > 0).unwrap_or(24),
cols: params.cols.filter(|c| *c > 0).unwrap_or(80),
pixel_width: 0,
pixel_height: 0,
};
let pty_system = NativePtySystem::default();
let pair = match pty_system.openpty(initial_size) {
Ok(pair) => pair,
Err(e) => {
error!(error = %e, "Failed to open PTY");
send_err(&mut ws_sender, &format!("Failed to open PTY: {e}")).await;
return;
}
};
let SpawnPlan { cmd, _temp } = plan;
let _child = match pair.slave.spawn_command(cmd) {
Ok(child) => child,
Err(e) => {
error!(error = %e, "Failed to spawn child");
send_err(&mut ws_sender, &format!("Failed to spawn child: {e}")).await;
return;
}
};
drop(pair.slave);
let master = pair.master;
let mut pty_reader = match master.try_clone_reader() {
Ok(r) => r,
Err(e) => {
error!(error = %e, "Failed to clone PTY reader");
return;
}
};
let mut pty_writer: Box<dyn Write + Send> = match master.take_writer() {
Ok(w) => w,
Err(e) => {
error!(error = %e, "Failed to take PTY writer");
return;
}
};
let (pty_out_tx, mut pty_out_rx) = tokio::sync::mpsc::channel::<Vec<u8>>(64);
let (resize_tx, mut resize_rx) = tokio::sync::mpsc::channel::<(u16, u16)>(4);
tokio::task::spawn_blocking(move || {
let mut buf = [0u8; 4096];
loop {
match pty_reader.read(&mut buf) {
Ok(0) => break,
Ok(n) => {
if pty_out_tx.blocking_send(buf[..n].to_vec()).is_err() {
break;
}
}
Err(_) => break,
}
}
});
tokio::spawn(async move {
while let Some((cols, rows)) = resize_rx.recv().await {
let _ = master.resize(PtySize {
rows,
cols,
pixel_width: 0,
pixel_height: 0,
});
}
});
loop {
tokio::select! {
Some(data) = pty_out_rx.recv() => {
let text = String::from_utf8_lossy(&data).into_owned();
if ws_sender.send(Message::Text(text.into())).await.is_err() {
break;
}
}
msg = ws_receiver.next() => {
match msg {
Some(Ok(Message::Text(text))) => {
if let Ok(resize) = serde_json::from_str::<ResizeMsg>(&text) {
if resize.msg_type == "resize" {
let _ = resize_tx.send((resize.cols, resize.rows)).await;
continue;
}
}
if pty_writer.write_all(text.as_bytes()).is_err() {
break;
}
}
Some(Ok(Message::Binary(data))) => {
if pty_writer.write_all(&data).is_err() {
break;
}
}
Some(Ok(Message::Close(_))) | None => {
debug!("Terminal WebSocket closed");
break;
}
Some(Ok(_)) => {} Some(Err(e)) => {
warn!(error = %e, "Terminal WebSocket error");
break;
}
}
}
}
}
drop(_temp);
debug!("Terminal session ended");
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn tool_mapping_known() {
assert_eq!(CodeTool::from_query("claude"), Some(CodeTool::Claude));
assert_eq!(CodeTool::from_query("codex"), Some(CodeTool::Codex));
assert_eq!(CodeTool::from_query("opencode"), Some(CodeTool::OpenCode));
assert_eq!(CodeTool::from_query("gemini"), Some(CodeTool::Gemini));
}
#[test]
fn tool_mapping_unknown_falls_back() {
assert_eq!(CodeTool::from_query(""), None);
assert_eq!(CodeTool::from_query("bash"), None);
assert_eq!(CodeTool::from_query("Claude"), None); assert_eq!(CodeTool::from_query("nonsense"), None);
}
#[test]
fn tool_binaries_match_docs() {
assert_eq!(CodeTool::Claude.binary(), "claude");
assert_eq!(CodeTool::Codex.binary(), "codex");
assert_eq!(CodeTool::OpenCode.binary(), "opencode");
assert_eq!(CodeTool::Gemini.binary(), "gemini");
}
#[test]
fn tool_config_env_vars() {
assert_eq!(CodeTool::Claude.config_env(), "CLAUDE_MCP_CONFIG");
assert_eq!(CodeTool::Codex.config_env(), "CODEX_MCP_CONFIG");
assert_eq!(CodeTool::OpenCode.config_env(), "OPENCODE_MCP_CONFIG");
assert_eq!(CodeTool::Gemini.config_env(), "GEMINI_MCP_CONFIG");
}
#[test]
fn mcp_config_json_has_expected_shape() {
let v = build_mcp_config_json("http://127.0.0.1:54500/mcp", "sess-abc", "tok-xyz");
let srv = &v["mcpServers"]["construct"];
assert_eq!(srv["url"], "http://127.0.0.1:54500/mcp");
assert_eq!(srv["type"], "http");
assert_eq!(srv["headers"]["Authorization"], "Bearer tok-xyz");
assert_eq!(srv["headers"]["X-Construct-Session"], "sess-abc");
let s = serde_json::to_string(&v).unwrap();
let back: serde_json::Value = serde_json::from_str(&s).unwrap();
assert_eq!(back, v);
}
#[test]
fn resolve_cwd_none_when_unset() {
assert!(matches!(resolve_cwd(None), Ok(None)));
assert!(matches!(resolve_cwd(Some("")), Ok(None)));
}
#[test]
fn resolve_cwd_rejects_missing_path() {
assert!(resolve_cwd(Some("/this/should/not/exist/construct-xyz")).is_err());
}
#[test]
fn resolve_cwd_accepts_tmp() {
let tmp = std::env::temp_dir();
let got = resolve_cwd(Some(tmp.to_str().unwrap())).unwrap().unwrap();
assert!(got.is_dir());
}
#[test]
fn plan_spawn_shell_fallback_no_tool() {
let plan = plan_spawn(None, None, None, None).expect("shell fallback works");
assert!(plan._temp.is_none());
}
#[test]
fn plan_spawn_missing_binary_errors() {
match plan_spawn(Some(CodeTool::Gemini), None, None, None) {
Ok(_) => {} Err(msg) => assert!(msg.contains("not found in PATH"), "got: {msg}"),
}
}
#[test]
fn terminal_query_default_falls_back_to_shell() {
let q = TerminalQuery::default();
assert!(q.tool.is_none());
assert!(q.cwd.is_none());
assert!(q.mcp_session.is_none());
assert!(q.mcp_token.is_none());
assert!(q.tool.as_deref().and_then(CodeTool::from_query).is_none());
}
fn tempdir() -> PathBuf {
let p = std::env::temp_dir().join(format!("construct-test-{}", Uuid::new_v4()));
std::fs::create_dir_all(&p).unwrap();
p
}
const URL: &str = "http://127.0.0.1:54500/mcp";
const SESS: &str = "sess-abc";
const TOK: &str = "tok-xyz";
#[test]
fn claude_adapter_writes_mcp_json_and_passes_flag() {
let home = tempdir();
let inj = write_cli_config(CodeTool::Claude, &home, URL, SESS, TOK).unwrap();
assert_eq!(inj.args.len(), 2);
assert_eq!(inj.args[0], "--mcp-config");
let cfg_path = PathBuf::from(&inj.args[1]);
assert!(cfg_path.starts_with(&home));
assert!(cfg_path.ends_with(".mcp.json"));
assert!(cfg_path.exists());
let content: serde_json::Value =
serde_json::from_slice(&std::fs::read(&cfg_path).unwrap()).unwrap();
assert_eq!(content["mcpServers"]["construct"]["url"], URL);
assert_eq!(
content["mcpServers"]["construct"]["headers"]["Authorization"],
format!("Bearer {TOK}")
);
assert_eq!(
content["mcpServers"]["construct"]["headers"]["X-Construct-Session"],
SESS
);
let _ = std::fs::remove_dir_all(&home);
}
#[test]
fn codex_adapter_writes_toml_at_home_dot_codex() {
let home = tempdir();
let inj = write_cli_config(CodeTool::Codex, &home, URL, SESS, TOK).unwrap();
assert!(inj.args.is_empty(), "codex has no flag mechanism");
let cfg = home.join(".codex").join("config.toml");
assert!(cfg.exists(), "{} should exist", cfg.display());
let body = std::fs::read_to_string(&cfg).unwrap();
assert!(body.contains("[mcp_servers.construct]"));
assert!(body.contains(&format!("url = \"{URL}\"")), "body: {body}");
assert!(body.contains("transport = \"http\""));
assert!(body.contains("[mcp_servers.construct.headers]"));
assert!(
body.contains(&format!("Authorization = \"Bearer {TOK}\"")),
"body: {body}"
);
assert!(
body.contains(&format!("X-Construct-Session = \"{SESS}\"")),
"body: {body}"
);
let _: toml::Value = toml::from_str(&body).expect("codex config should be valid TOML");
let _ = std::fs::remove_dir_all(&home);
}
#[test]
fn opencode_adapter_writes_xdg_json() {
let home = tempdir();
let inj = write_cli_config(CodeTool::OpenCode, &home, URL, SESS, TOK).unwrap();
assert!(inj.args.is_empty());
let cfg = home.join(".config").join("opencode").join("config.json");
assert!(cfg.exists());
let v: serde_json::Value = serde_json::from_slice(&std::fs::read(&cfg).unwrap()).unwrap();
let srv = &v["mcp"]["construct"];
assert_eq!(srv["type"], "remote");
assert_eq!(srv["url"], URL);
assert_eq!(srv["enabled"], true);
assert_eq!(srv["headers"]["Authorization"], format!("Bearer {TOK}"));
assert_eq!(srv["headers"]["X-Construct-Session"], SESS);
let _ = std::fs::remove_dir_all(&home);
}
#[test]
fn gemini_adapter_writes_settings_json() {
let home = tempdir();
let inj = write_cli_config(CodeTool::Gemini, &home, URL, SESS, TOK).unwrap();
assert!(inj.args.is_empty());
let cfg = home.join(".gemini").join("settings.json");
assert!(cfg.exists());
let v: serde_json::Value = serde_json::from_slice(&std::fs::read(&cfg).unwrap()).unwrap();
let srv = &v["mcpServers"]["construct"];
assert_eq!(srv["httpUrl"], URL);
assert_eq!(srv["headers"]["Authorization"], format!("Bearer {TOK}"));
assert_eq!(srv["headers"]["X-Construct-Session"], SESS);
let _ = std::fs::remove_dir_all(&home);
}
#[test]
fn plan_spawn_with_discovery_no_creds_no_tempdir() {
let plan = plan_spawn_with_discovery(None, None, None, None, Some(URL))
.expect("shell fallback works");
assert!(plan._temp.is_none());
}
}