use axum::{
extract::{Path, State},
http::StatusCode,
response::Json,
};
use serde::{Deserialize, Serialize};
use std::sync::Arc;
use tmai_core::api::{AgentSnapshot, ApiError, TmaiCore};
fn json_error(status: StatusCode, message: &str) -> (StatusCode, Json<serde_json::Value>) {
(status, Json(serde_json::json!({"error": message})))
}
fn shell_quote(s: &str) -> String {
let sanitized: String = s
.chars()
.filter(|&c| c as u32 >= 0x20 || c == '\n')
.collect();
if sanitized
.bytes()
.all(|b| b.is_ascii_alphanumeric() || b == b'-' || b == b'_' || b == b'.' || b == b'/')
{
return sanitized;
}
format!("'{}'", sanitized.replace('\'', "'\\''"))
}
fn is_path_within_allowed_scope(path: &std::path::Path, core: Option<&TmaiCore>) -> bool {
let canonical = match path.canonicalize() {
Ok(p) => p,
Err(_) => {
if let Some(parent) = path.parent() {
match parent.canonicalize() {
Ok(p) => p.join(path.file_name().unwrap_or_default()),
Err(_) => return false,
}
} else {
return false;
}
}
};
if let Some(home) = dirs::home_dir() {
if let Ok(home_canonical) = home.canonicalize() {
if canonical.starts_with(&home_canonical) {
return true;
}
}
}
if let Some(core) = core {
for project in core.list_projects() {
let project_path = std::path::Path::new(&project);
if let Ok(proj_canonical) = project_path.canonicalize() {
if canonical.starts_with(&proj_canonical) {
return true;
}
}
}
}
false
}
fn api_error_to_http(err: ApiError) -> (StatusCode, Json<serde_json::Value>) {
let status = match &err {
ApiError::AgentNotFound { .. } | ApiError::TeamNotFound { .. } => StatusCode::NOT_FOUND,
ApiError::NoCommandSender | ApiError::CommandError(_) => StatusCode::INTERNAL_SERVER_ERROR,
ApiError::VirtualAgent { .. } | ApiError::InvalidInput { .. } | ApiError::NoSelection => {
StatusCode::BAD_REQUEST
}
ApiError::WorktreeError(e) => match e {
tmai_core::worktree::WorktreeOpsError::NotFound(_) => StatusCode::NOT_FOUND,
tmai_core::worktree::WorktreeOpsError::AlreadyExists(_)
| tmai_core::worktree::WorktreeOpsError::InvalidName(_)
| tmai_core::worktree::WorktreeOpsError::UncommittedChanges(_)
| tmai_core::worktree::WorktreeOpsError::AgentStillRunning(_) => {
StatusCode::BAD_REQUEST
}
tmai_core::worktree::WorktreeOpsError::GitError(_) => StatusCode::INTERNAL_SERVER_ERROR,
},
};
json_error(status, &err.to_string())
}
#[derive(Debug, Deserialize)]
pub struct TextInputRequest {
pub text: String,
}
#[derive(Debug, Deserialize)]
pub struct KeyRequest {
pub key: String,
}
#[derive(Debug, Deserialize)]
pub struct PassthroughRequest {
#[serde(default)]
pub chars: Option<String>,
#[serde(default)]
pub key: Option<String>,
}
#[derive(Debug, Deserialize)]
pub struct AutoApproveOverrideRequest {
pub enabled: Option<bool>,
}
#[derive(Debug, Serialize)]
pub struct PreviewResponse {
pub content: String,
pub lines: usize,
#[serde(skip_serializing_if = "Option::is_none")]
pub cursor_x: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub cursor_y: Option<u32>,
}
#[derive(Debug, Serialize)]
pub struct TaskSummaryResponse {
pub id: String,
pub subject: String,
pub status: String,
}
#[derive(Debug, Serialize)]
pub(crate) struct TeamInfoResponse {
name: String,
description: Option<String>,
task_summary: TaskSummaryOverview,
members: Vec<TeamMemberResponse>,
#[serde(skip_serializing_if = "Vec::is_empty")]
worktree_names: Vec<String>,
}
#[derive(Debug, Serialize)]
pub(crate) struct TaskSummaryOverview {
total: usize,
completed: usize,
in_progress: usize,
pending: usize,
}
#[derive(Debug, Serialize)]
pub(crate) struct TeamMemberResponse {
name: String,
agent_type: Option<String>,
is_lead: bool,
pane_target: Option<String>,
current_task: Option<TaskSummaryResponse>,
#[serde(skip_serializing_if = "Option::is_none")]
description: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
model: Option<String>,
}
#[derive(Debug, Serialize)]
pub(crate) struct TeamTaskResponse {
id: String,
subject: String,
description: String,
active_form: Option<String>,
status: String,
owner: Option<String>,
blocks: Vec<String>,
blocked_by: Vec<String>,
}
pub(super) fn build_team_info(
snapshot: &tmai_core::state::TeamSnapshot,
app_state: &tmai_core::state::AppState,
) -> TeamInfoResponse {
let total = snapshot.task_total;
let completed = snapshot.task_done;
let in_progress = snapshot.task_in_progress;
let pending = snapshot.task_pending;
let members: Vec<TeamMemberResponse> = snapshot
.config
.members
.iter()
.map(|member| {
let pane_target = snapshot.member_panes.get(&member.name).cloned();
let current_task = pane_target
.as_ref()
.and_then(|target| app_state.agents.get(target))
.and_then(|agent| agent.team_info.as_ref())
.and_then(|ti| ti.current_task.as_ref())
.map(|t| TaskSummaryResponse {
id: t.id.clone(),
subject: t.subject.clone(),
status: t.status.to_string(),
});
let is_lead = snapshot
.config
.members
.first()
.map(|first| first.name == member.name)
.unwrap_or(false);
let agent_def = app_state
.agent_definitions
.iter()
.find(|d| d.name == member.name);
TeamMemberResponse {
name: member.name.clone(),
agent_type: member.agent_type.clone(),
is_lead,
pane_target,
current_task,
description: agent_def.and_then(|d| d.description.clone()),
model: agent_def.and_then(|d| d.model.clone()),
}
})
.collect();
TeamInfoResponse {
name: snapshot.config.team_name.clone(),
description: snapshot.config.description.clone(),
task_summary: TaskSummaryOverview {
total,
completed,
in_progress,
pending,
},
members,
worktree_names: snapshot.worktree_names.clone(),
}
}
#[derive(Debug, Deserialize)]
pub struct SelectRequest {
pub choice: usize,
}
#[derive(Debug, Deserialize)]
pub struct SubmitRequest {
#[serde(default)]
pub selected_choices: Vec<usize>,
}
pub async fn get_agents(State(core): State<Arc<TmaiCore>>) -> Json<Vec<AgentSnapshot>> {
Json(core.list_agents())
}
pub async fn approve_agent(
State(core): State<Arc<TmaiCore>>,
Path(id): Path<String>,
) -> Result<Json<serde_json::Value>, (StatusCode, Json<serde_json::Value>)> {
tracing::info!("API: approve agent_id={}", id);
core.approve(&id)
.map(|()| Json(serde_json::json!({"status": "ok"})))
.map_err(|e| {
tracing::warn!("API: approve failed agent_id={}: {}", id, e);
api_error_to_http(e)
})
}
pub async fn select_choice(
State(core): State<Arc<TmaiCore>>,
Path(id): Path<String>,
Json(req): Json<SelectRequest>,
) -> Result<Json<serde_json::Value>, (StatusCode, Json<serde_json::Value>)> {
tracing::info!("API: select choice={} agent_id={}", req.choice, id);
core.select_choice(&id, req.choice)
.map(|()| Json(serde_json::json!({"status": "ok"})))
.map_err(|e| {
tracing::warn!("API: select failed agent_id={}: {}", id, e);
api_error_to_http(e)
})
}
pub async fn submit_selection(
State(core): State<Arc<TmaiCore>>,
Path(id): Path<String>,
body: Option<Json<SubmitRequest>>,
) -> Result<Json<serde_json::Value>, (StatusCode, Json<serde_json::Value>)> {
tracing::info!("API: submit agent_id={}", id);
let selected = body.map(|b| b.0.selected_choices).unwrap_or_default();
core.submit_selection(&id, &selected)
.map(|()| Json(serde_json::json!({"status": "ok"})))
.map_err(|e| {
tracing::warn!("API: submit failed agent_id={}: {}", id, e);
api_error_to_http(e)
})
}
pub async fn send_text(
State(core): State<Arc<TmaiCore>>,
Path(id): Path<String>,
Json(req): Json<TextInputRequest>,
) -> Result<Json<serde_json::Value>, (StatusCode, Json<serde_json::Value>)> {
tracing::info!("API: input agent_id={}", id);
core.send_text(&id, &req.text)
.await
.map(|()| Json(serde_json::json!({"status": "ok"})))
.map_err(|e| {
tracing::warn!("API: input failed agent_id={}: {}", id, e);
api_error_to_http(e)
})
}
pub async fn send_key(
State(core): State<Arc<TmaiCore>>,
Path(id): Path<String>,
Json(req): Json<KeyRequest>,
) -> Result<Json<serde_json::Value>, (StatusCode, Json<serde_json::Value>)> {
tracing::info!("API: send_key key={} agent_id={}", req.key, id);
core.send_key(&id, &req.key)
.map(|()| Json(serde_json::json!({"status": "ok"})))
.map_err(|e| {
tracing::warn!("API: send_key failed agent_id={}: {}", id, e);
api_error_to_http(e)
})
}
pub async fn set_auto_approve(
State(core): State<Arc<TmaiCore>>,
Path(id): Path<String>,
Json(req): Json<AutoApproveOverrideRequest>,
) -> Result<Json<serde_json::Value>, (StatusCode, Json<serde_json::Value>)> {
core.set_auto_approve_override(&id, req.enabled)
.map(|()| Json(serde_json::json!({"status": "ok"})))
.map_err(api_error_to_http)
}
#[allow(deprecated)]
pub async fn passthrough_input(
State(core): State<Arc<TmaiCore>>,
Path(id): Path<String>,
Json(req): Json<PassthroughRequest>,
) -> Result<Json<serde_json::Value>, (StatusCode, Json<serde_json::Value>)> {
let tmux_target = {
#[allow(deprecated)]
let state = core.raw_state().read();
state
.agents
.get(&id)
.map(|a| a.target.clone())
.filter(|t| !t.starts_with("hook:") && !t.starts_with("discovered:"))
};
let send_target = tmux_target.as_deref().unwrap_or(&id);
let cmd = core
.raw_command_sender()
.ok_or_else(|| json_error(StatusCode::INTERNAL_SERVER_ERROR, "No command sender"))?;
if let Some(ref chars) = req.chars {
cmd.send_keys_literal(send_target, chars)
.map_err(|e| json_error(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string()))?;
}
if let Some(ref key) = req.key {
cmd.send_keys(send_target, key)
.map_err(|e| json_error(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string()))?;
}
Ok(Json(serde_json::json!({"status": "ok"})))
}
pub async fn kill_agent(
State(core): State<Arc<TmaiCore>>,
Path(id): Path<String>,
) -> Result<Json<serde_json::Value>, (StatusCode, Json<serde_json::Value>)> {
tracing::info!("API: kill agent_id={}", id);
core.kill_pane(&id)
.map(|()| Json(serde_json::json!({"status": "ok"})))
.map_err(|e| {
tracing::warn!("API: kill failed agent_id={}: {}", id, e);
api_error_to_http(e)
})
}
#[allow(deprecated)]
pub async fn get_preview(
State(core): State<Arc<TmaiCore>>,
Path(id): Path<String>,
) -> Result<Json<PreviewResponse>, StatusCode> {
let show_cursor = core.settings().web.show_cursor;
let (agent_target, agent_content, agent_cursor) = {
let state = core.raw_state().read();
match state.agents.get(&id) {
Some(a) => (
Some(a.target.clone()),
Some(a.last_content.clone()),
(a.cursor_x, a.cursor_y),
),
None => (None, None, (None, None)),
}
};
if let Some(ref target) = agent_target {
if let Some(cmd) = core.raw_command_sender() {
if let Ok(content) = cmd.runtime().capture_pane_full(target) {
if !content.trim().is_empty() {
let lines = content.lines().count();
let (cursor_x, cursor_y) = if show_cursor {
let cursor_result = cmd.runtime().get_cursor_position(target);
tracing::debug!("cursor query for {}: {:?}", target, cursor_result);
cursor_result
.ok()
.flatten()
.map(|(x, y)| (Some(x), Some(y)))
.unwrap_or((None, None))
} else {
(None, None)
};
return Ok(Json(PreviewResponse {
content,
lines,
cursor_x,
cursor_y,
}));
}
}
}
}
if let Some(content) = agent_content.filter(|c| !c.trim().is_empty()) {
let lines = content.lines().count();
return Ok(Json(PreviewResponse {
content,
lines,
cursor_x: if show_cursor { agent_cursor.0 } else { None },
cursor_y: if show_cursor { agent_cursor.1 } else { None },
}));
}
if core.get_agent(&id).is_err() {
return Err(StatusCode::NOT_FOUND);
}
let cmd = core
.raw_command_sender()
.ok_or(StatusCode::INTERNAL_SERVER_ERROR)?;
match cmd.runtime().capture_pane(&id) {
Ok(content) => {
let display_content = if content.trim().is_empty() {
let pane_id = {
let state = core.raw_state().read();
state.target_to_pane_id.get(&id).cloned()
};
let hook_reg = core.hook_registry().read();
let activity_content = pane_id
.as_ref()
.and_then(|pid| hook_reg.get(pid))
.filter(|hs| !hs.activity_log.is_empty())
.map(|hs| tmai_core::hooks::handler::format_activity_log(&hs.activity_log));
drop(hook_reg);
activity_content
.filter(|s| !s.is_empty())
.unwrap_or_else(|| "(waiting for agent activity...)".to_string())
} else {
content
};
let lines = display_content.lines().count();
Ok(Json(PreviewResponse {
content: display_content,
lines,
cursor_x: None,
cursor_y: None,
}))
}
Err(_) => Err(StatusCode::INTERNAL_SERVER_ERROR),
}
}
#[allow(deprecated)]
pub async fn get_teams(State(core): State<Arc<TmaiCore>>) -> Json<Vec<TeamInfoResponse>> {
let state = core.raw_state().read();
let teams: Vec<TeamInfoResponse> = state
.teams
.values()
.map(|snapshot| build_team_info(snapshot, &state))
.collect();
Json(teams)
}
fn is_valid_team_name(name: &str) -> bool {
!name.is_empty()
&& name
.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_')
}
#[allow(deprecated)]
pub async fn get_team_tasks(
State(core): State<Arc<TmaiCore>>,
Path(name): Path<String>,
) -> Result<Json<Vec<TeamTaskResponse>>, (StatusCode, Json<serde_json::Value>)> {
if !is_valid_team_name(&name) {
return Err(json_error(StatusCode::BAD_REQUEST, "Invalid team name"));
}
let state = core.raw_state().read();
let snapshot = state
.teams
.get(&name)
.ok_or_else(|| json_error(StatusCode::NOT_FOUND, "Team not found"))?;
let tasks: Vec<TeamTaskResponse> = snapshot
.tasks
.iter()
.map(|task| TeamTaskResponse {
id: task.id.clone(),
subject: task.subject.clone(),
description: task.description.clone(),
active_form: task.active_form.clone(),
status: task.status.to_string(),
owner: task.owner.clone(),
blocks: task.blocks.clone(),
blocked_by: task.blocked_by.clone(),
})
.collect();
Ok(Json(tasks))
}
fn default_agent_type() -> String {
"claude".to_string()
}
pub async fn get_worktrees(
State(core): State<Arc<TmaiCore>>,
) -> Json<Vec<tmai_core::api::WorktreeSnapshot>> {
Json(core.list_worktrees())
}
#[derive(Debug, Deserialize)]
pub struct WorktreeDeleteRequestBody {
pub repo_path: String,
pub worktree_name: String,
#[serde(default)]
pub force: bool,
}
pub async fn delete_worktree(
State(core): State<Arc<TmaiCore>>,
Json(req): Json<WorktreeDeleteRequestBody>,
) -> Result<Json<serde_json::Value>, (StatusCode, Json<serde_json::Value>)> {
if !tmai_core::git::is_valid_worktree_name(&req.worktree_name) {
return Err(json_error(StatusCode::BAD_REQUEST, "Invalid worktree name"));
}
let repo_dir = strip_git_suffix(&req.repo_path);
if !std::path::Path::new(repo_dir).is_dir() {
return Err(json_error(StatusCode::NOT_FOUND, "Repository not found"));
}
{
let repo_canonical = std::path::Path::new(repo_dir)
.canonicalize()
.map_err(|_| json_error(StatusCode::BAD_REQUEST, "Invalid repository path"))?;
let projects = core.list_projects();
#[allow(deprecated)]
let worktree_paths: Vec<String> = {
let state = core.raw_state().read();
state.agents.values().map(|a| a.cwd.clone()).collect()
};
let is_known = projects.iter().chain(worktree_paths.iter()).any(|p| {
std::path::Path::new(tmai_core::git::strip_git_suffix(p))
.canonicalize()
.map(|c| c == repo_canonical)
.unwrap_or(false)
});
if !is_known {
return Err(json_error(
StatusCode::FORBIDDEN,
"Repository path is not a known project or worktree",
));
}
}
let del_req = tmai_core::worktree::WorktreeDeleteRequest {
repo_path: strip_git_suffix(&req.repo_path).to_string(),
worktree_name: req.worktree_name,
force: req.force,
};
core.delete_worktree(&del_req)
.await
.map(|()| Json(serde_json::json!({"status": "ok"})))
.map_err(api_error_to_http)
}
#[derive(Debug, Deserialize)]
pub struct WorktreeLaunchRequestBody {
pub repo_path: String,
pub worktree_name: String,
#[serde(default = "default_agent_type")]
pub agent_type: String,
#[serde(default)]
#[allow(dead_code)]
pub session: Option<String>,
}
pub async fn launch_agent_in_worktree(
State(core): State<Arc<TmaiCore>>,
Json(req): Json<WorktreeLaunchRequestBody>,
) -> Result<Json<serde_json::Value>, (StatusCode, Json<serde_json::Value>)> {
if !tmai_core::git::is_valid_worktree_name(&req.worktree_name) {
return Err(json_error(StatusCode::BAD_REQUEST, "Invalid worktree name"));
}
let wt_path = {
let worktrees = core.list_worktrees();
worktrees
.iter()
.find(|wt| {
(wt.repo_path == req.repo_path || strip_git_suffix(&wt.repo_path) == req.repo_path)
&& wt.name == req.worktree_name
})
.map(|wt| wt.path.clone())
};
let wt_path = match wt_path {
Some(p) => p,
None => return Err(json_error(StatusCode::NOT_FOUND, "Worktree not found")),
};
let command = match req.agent_type.as_str() {
"claude" | "claude_code" => "claude",
"codex" | "codex_cli" => "codex",
"gemini" | "gemini_cli" => "gemini",
"opencode" | "open_code" => "opencode",
other => {
return Err(json_error(
StatusCode::BAD_REQUEST,
&format!("Unknown agent type: {}", other),
))
}
};
let spawn_req = SpawnRequest {
command: command.to_string(),
args: vec![],
cwd: wt_path,
rows: default_rows(),
cols: default_cols(),
force_pty: false,
};
let use_tmux = {
#[allow(deprecated)]
let state = core.raw_state().read();
state.spawn_in_tmux
};
let tmux_avail = is_tmux_available();
let result = if use_tmux && tmux_avail {
spawn_in_tmux(&core, &spawn_req).await
} else {
spawn_in_pty(&core, &spawn_req).await
};
result.map(|Json(resp)| {
Json(serde_json::json!({
"status": "ok",
"target": resp.session_id,
}))
})
}
#[derive(Debug, Deserialize)]
pub struct WorktreeDiffRequestBody {
pub worktree_path: String,
#[serde(default = "default_base_branch")]
pub base_branch: String,
}
fn default_base_branch() -> String {
"main".to_string()
}
pub async fn get_worktree_diff(
State(core): State<Arc<TmaiCore>>,
Json(req): Json<WorktreeDiffRequestBody>,
) -> Result<Json<serde_json::Value>, (StatusCode, Json<serde_json::Value>)> {
if !tmai_core::git::is_safe_git_ref(&req.base_branch) {
return Err(json_error(StatusCode::BAD_REQUEST, "Invalid base branch"));
}
let known = core
.list_worktrees()
.iter()
.any(|wt| wt.path == req.worktree_path);
if !known {
return Err(json_error(StatusCode::NOT_FOUND, "Worktree not found"));
}
match core
.get_worktree_diff(&req.worktree_path, &req.base_branch)
.await
{
Ok((diff, summary)) => {
let summary_json = summary.map(|s| {
serde_json::json!({
"files_changed": s.files_changed,
"insertions": s.insertions,
"deletions": s.deletions,
})
});
Ok(Json(serde_json::json!({
"diff": diff,
"summary": summary_json,
})))
}
Err(e) => Err(api_error_to_http(e)),
}
}
pub async fn get_projects(State(core): State<Arc<TmaiCore>>) -> Json<Vec<String>> {
Json(core.list_projects())
}
#[derive(Debug, Deserialize)]
pub struct AddProjectRequest {
pub path: String,
}
pub async fn add_project(
State(core): State<Arc<TmaiCore>>,
Json(req): Json<AddProjectRequest>,
) -> Result<Json<serde_json::Value>, (StatusCode, Json<serde_json::Value>)> {
match core.add_project(&req.path) {
Ok(()) => Ok(Json(serde_json::json!({"ok": true}))),
Err(e) => Err(api_error_to_http(e)),
}
}
#[derive(Debug, Deserialize)]
pub struct RemoveProjectRequest {
pub path: String,
}
#[derive(Debug, Serialize)]
pub struct DirEntry {
pub name: String,
pub path: String,
pub is_git: bool,
}
pub async fn list_directories(
State(core): State<Arc<TmaiCore>>,
axum::extract::Query(params): axum::extract::Query<std::collections::HashMap<String, String>>,
) -> Result<Json<Vec<DirEntry>>, (StatusCode, Json<serde_json::Value>)> {
let home = dirs::home_dir().unwrap_or_default();
let base = params
.get("path")
.filter(|p| !p.is_empty())
.map(std::path::PathBuf::from)
.unwrap_or(home);
if !is_path_within_allowed_scope(&base, Some(&core)) {
return Err(json_error(
StatusCode::FORBIDDEN,
"Path is outside allowed scope",
));
}
if !base.is_dir() {
return Err(json_error(
StatusCode::BAD_REQUEST,
&format!("Not a directory: {}", base.display()),
));
}
let mut entries = Vec::new();
let Ok(read_dir) = std::fs::read_dir(&base) else {
return Err(json_error(
StatusCode::FORBIDDEN,
&format!("Cannot read directory: {}", base.display()),
));
};
for entry in read_dir.flatten() {
let Ok(ft) = entry.file_type() else {
continue;
};
if !ft.is_dir() {
continue;
}
let name = entry.file_name().to_string_lossy().to_string();
if name.starts_with('.') {
continue;
}
let path = entry.path();
let is_git = path.join(".git").exists();
entries.push(DirEntry {
name,
path: path.to_string_lossy().to_string(),
is_git,
});
}
entries.sort_by(|a, b| a.name.to_lowercase().cmp(&b.name.to_lowercase()));
Ok(Json(entries))
}
pub async fn remove_project(
State(core): State<Arc<TmaiCore>>,
Json(req): Json<RemoveProjectRequest>,
) -> Result<Json<serde_json::Value>, (StatusCode, Json<serde_json::Value>)> {
match core.remove_project(&req.path) {
Ok(()) => Ok(Json(serde_json::json!({"ok": true}))),
Err(e) => Err(api_error_to_http(e)),
}
}
#[derive(Debug, Serialize)]
pub struct SpawnSettingsResponse {
pub use_tmux_window: bool,
pub tmux_available: bool,
pub tmux_window_name: String,
}
#[derive(Debug, Deserialize)]
pub struct UpdateSpawnSettingsRequest {
pub use_tmux_window: bool,
#[serde(default)]
pub tmux_window_name: Option<String>,
}
pub async fn get_spawn_settings(State(core): State<Arc<TmaiCore>>) -> Json<SpawnSettingsResponse> {
let (use_tmux_window, tmux_window_name) = {
#[allow(deprecated)]
let state = core.raw_state().read();
(state.spawn_in_tmux, state.spawn_tmux_window_name.clone())
};
let tmux_available = is_tmux_available();
Json(SpawnSettingsResponse {
use_tmux_window,
tmux_available,
tmux_window_name,
})
}
pub async fn update_spawn_settings(
State(core): State<Arc<TmaiCore>>,
Json(req): Json<UpdateSpawnSettingsRequest>,
) -> Json<serde_json::Value> {
{
#[allow(deprecated)]
let state = core.raw_state();
let mut s = state.write();
s.spawn_in_tmux = req.use_tmux_window;
if let Some(ref name) = req.tmux_window_name {
if !name.is_empty() {
s.spawn_tmux_window_name = name.clone();
}
}
}
tmai_core::config::Settings::save_toml_value(
"spawn",
"use_tmux_window",
toml_edit::Value::from(req.use_tmux_window),
);
if let Some(ref name) = req.tmux_window_name {
if !name.is_empty() {
tmai_core::config::Settings::save_toml_value(
"spawn",
"tmux_window_name",
toml_edit::Value::from(name.as_str()),
);
}
}
core.reload_settings();
tracing::info!(
"Spawn settings updated: use_tmux_window={} window_name={:?}",
req.use_tmux_window,
req.tmux_window_name
);
Json(serde_json::json!({"ok": true}))
}
#[derive(Debug, Serialize)]
pub struct AutoApproveSettingsResponse {
pub mode: String,
pub running: bool,
pub rules: RuleSettingsResponse,
}
#[derive(Debug, Serialize)]
pub struct RuleSettingsResponse {
pub allow_read: bool,
pub allow_tests: bool,
pub allow_fetch: bool,
pub allow_git_readonly: bool,
pub allow_format_lint: bool,
pub allow_patterns: Vec<String>,
}
#[derive(Debug, Deserialize)]
pub struct UpdateAutoApproveRequest {
#[serde(default)]
pub mode: Option<String>,
#[serde(default)]
pub rules: Option<UpdateRuleSettingsRequest>,
}
#[derive(Debug, Deserialize)]
pub struct UpdateRuleSettingsRequest {
pub allow_read: Option<bool>,
pub allow_tests: Option<bool>,
pub allow_fetch: Option<bool>,
pub allow_git_readonly: Option<bool>,
pub allow_format_lint: Option<bool>,
pub allow_patterns: Option<Vec<String>>,
}
pub async fn get_auto_approve_settings(
State(core): State<Arc<TmaiCore>>,
) -> Json<AutoApproveSettingsResponse> {
let aa = &core.settings().auto_approve;
let mode = serde_json::to_value(aa.effective_mode())
.ok()
.and_then(|v| v.as_str().map(String::from))
.unwrap_or_else(|| format!("{:?}", aa.effective_mode()).to_lowercase());
let running = aa.effective_mode() != tmai_core::auto_approve::types::AutoApproveMode::Off;
let rules = RuleSettingsResponse {
allow_read: aa.rules.allow_read,
allow_tests: aa.rules.allow_tests,
allow_fetch: aa.rules.allow_fetch,
allow_git_readonly: aa.rules.allow_git_readonly,
allow_format_lint: aa.rules.allow_format_lint,
allow_patterns: aa.rules.allow_patterns.clone(),
};
Json(AutoApproveSettingsResponse {
mode,
running,
rules,
})
}
pub async fn update_auto_approve_settings(
State(core): State<Arc<TmaiCore>>,
Json(req): Json<UpdateAutoApproveRequest>,
) -> Json<serde_json::Value> {
if let Some(ref mode) = req.mode {
let mode_lower = mode.to_lowercase();
tmai_core::config::Settings::save_toml_value(
"auto_approve",
"mode",
toml_edit::Value::from(mode_lower.as_str()),
);
tracing::info!("Auto-approve mode updated to '{mode_lower}' (restart to apply)");
}
if let Some(ref rules) = req.rules {
if let Some(v) = rules.allow_read {
tmai_core::config::Settings::save_toml_nested_value(
"auto_approve",
"rules",
"allow_read",
toml_edit::Value::from(v),
);
}
if let Some(v) = rules.allow_tests {
tmai_core::config::Settings::save_toml_nested_value(
"auto_approve",
"rules",
"allow_tests",
toml_edit::Value::from(v),
);
}
if let Some(v) = rules.allow_fetch {
tmai_core::config::Settings::save_toml_nested_value(
"auto_approve",
"rules",
"allow_fetch",
toml_edit::Value::from(v),
);
}
if let Some(v) = rules.allow_git_readonly {
tmai_core::config::Settings::save_toml_nested_value(
"auto_approve",
"rules",
"allow_git_readonly",
toml_edit::Value::from(v),
);
}
if let Some(v) = rules.allow_format_lint {
tmai_core::config::Settings::save_toml_nested_value(
"auto_approve",
"rules",
"allow_format_lint",
toml_edit::Value::from(v),
);
}
if let Some(ref patterns) = rules.allow_patterns {
let arr = patterns
.iter()
.map(|s| toml_edit::Value::from(s.as_str()))
.collect::<toml_edit::Array>();
tmai_core::config::Settings::save_toml_nested_value(
"auto_approve",
"rules",
"allow_patterns",
toml_edit::Value::Array(arr),
);
}
tracing::info!("Auto-approve rules updated (restart to apply)");
}
core.reload_settings();
Json(serde_json::json!({"ok": true}))
}
#[derive(Debug, Deserialize)]
pub struct SpawnRequest {
pub command: String,
#[serde(default)]
pub args: Vec<String>,
#[serde(default = "default_cwd")]
pub cwd: String,
#[serde(default = "default_rows")]
pub rows: u16,
#[serde(default = "default_cols")]
pub cols: u16,
#[serde(default)]
pub force_pty: bool,
}
fn default_cwd() -> String {
std::env::current_dir()
.map(|p| p.to_string_lossy().to_string())
.unwrap_or_else(|_| "/tmp".to_string())
}
fn default_rows() -> u16 {
24
}
fn default_cols() -> u16 {
80
}
#[derive(Debug, Serialize)]
pub struct SpawnResponse {
pub session_id: String,
pub pid: u32,
pub command: String,
}
pub async fn spawn_agent(
State(core): State<Arc<TmaiCore>>,
Json(req): Json<SpawnRequest>,
) -> Result<Json<SpawnResponse>, (StatusCode, Json<serde_json::Value>)> {
let allowed_commands = ["claude", "codex", "gemini", "bash", "sh", "zsh"];
let base_command = req.command.split('/').next_back().unwrap_or(&req.command);
if !allowed_commands.contains(&base_command) {
return Err(json_error(
StatusCode::BAD_REQUEST,
&format!(
"Command not allowed: {}. Allowed: {:?}",
req.command, allowed_commands
),
));
}
if !std::path::Path::new(&req.cwd).is_dir() {
return Err(json_error(
StatusCode::BAD_REQUEST,
&format!("Directory does not exist: {}", req.cwd),
));
}
let use_tmux = {
#[allow(deprecated)]
let state = core.raw_state().read();
state.spawn_in_tmux
};
let tmux_avail = is_tmux_available();
if use_tmux && tmux_avail && !req.force_pty {
spawn_in_tmux(&core, &req).await
} else {
spawn_in_pty(&core, &req).await
}
}
fn is_tmux_available() -> bool {
std::env::var("TMUX").is_ok()
&& std::process::Command::new("tmux")
.arg("list-sessions")
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status()
.map(|s| s.success())
.unwrap_or(false)
}
fn current_tmux_session() -> Option<String> {
let output = std::process::Command::new("tmux")
.args(["display-message", "-p", "#{session_name}"])
.output()
.ok()?;
let name = String::from_utf8_lossy(&output.stdout).trim().to_string();
if name.is_empty() {
None
} else {
Some(name)
}
}
fn find_tmux_window(session: &str, window_name: &str) -> Option<String> {
let output = std::process::Command::new("tmux")
.args([
"list-windows",
"-t",
session,
"-F",
"#{window_index}:#{window_name}",
])
.output()
.ok()?;
let stdout = String::from_utf8_lossy(&output.stdout);
for line in stdout.lines() {
if let Some((idx, name)) = line.split_once(':') {
if name == window_name {
return Some(format!("{}:{}", session, idx));
}
}
}
None
}
async fn spawn_in_tmux(
core: &Arc<TmaiCore>,
req: &SpawnRequest,
) -> Result<Json<SpawnResponse>, (StatusCode, Json<serde_json::Value>)> {
let tmux = tmai_core::tmux::TmuxClient::new();
let session_name = current_tmux_session()
.or_else(|| {
#[allow(deprecated)]
let state = core.raw_state().read();
state.current_session.clone().or_else(|| {
state
.agent_order
.iter()
.filter_map(|key| state.agents.get(key))
.find(|a| a.session != "hook" && a.session != "pty")
.map(|a| a.session.clone())
})
})
.unwrap_or_else(|| "main".to_string());
let window_name = {
#[allow(deprecated)]
let state = core.raw_state().read();
state.spawn_tmux_window_name.clone()
};
let pane_target = find_tmux_window(&session_name, &window_name)
.and_then(|target| {
tmux.split_window(&target, &req.cwd).ok()
})
.or_else(|| {
tmux.new_window(&session_name, &req.cwd, Some(&window_name))
.ok()
})
.ok_or_else(|| {
json_error(
StatusCode::INTERNAL_SERVER_ERROR,
"Failed to create tmux window or split pane",
)
})?;
let codex_ws_url = if req.command == "codex" {
start_codex_app_server_sync(&req.cwd).await
} else {
None
};
let mut all_args: Vec<String> = req.args.iter().map(|a| shell_quote(a)).collect();
if let Some(ref url) = codex_ws_url {
all_args.push("--remote".to_string());
all_args.push(shell_quote(url));
}
let quoted_command = shell_quote(&req.command);
let full_command = if all_args.is_empty() {
quoted_command
} else {
format!("{} {}", quoted_command, all_args.join(" "))
};
let is_shell = matches!(req.command.as_str(), "bash" | "sh" | "zsh");
if !is_shell {
tmux.run_command_wrapped(&pane_target, &full_command)
.map_err(|e| {
json_error(
StatusCode::INTERNAL_SERVER_ERROR,
&format!("Failed to run command: {}", e),
)
})?;
}
tracing::info!(
"API: spawned in tmux window '{}' target={} command={}",
window_name,
pane_target,
req.command
);
if let Some(ref ws_url) = codex_ws_url {
connect_codex_ws(core, &pane_target, ws_url);
}
Ok(Json(SpawnResponse {
session_id: pane_target,
pid: 0, command: req.command.clone(),
}))
}
async fn spawn_in_pty(
core: &Arc<TmaiCore>,
req: &SpawnRequest,
) -> Result<Json<SpawnResponse>, (StatusCode, Json<serde_json::Value>)> {
let mut extra_args: Vec<String> = Vec::new();
let rows = if req.rows > 0 { req.rows } else { 24 };
let cols = if req.cols > 0 { req.cols } else { 80 };
let codex_ws_url = if req.command == "codex" {
start_codex_app_server_sync(&req.cwd).await
} else {
None
};
if let Some(ref url) = codex_ws_url {
extra_args.push("--remote".to_string());
extra_args.push(url.clone());
}
let mut all_args: Vec<&str> = req.args.iter().map(|s| s.as_str()).collect();
for a in &extra_args {
all_args.push(a.as_str());
}
let (api_token, api_port) = {
#[allow(deprecated)]
let state = core.raw_state().read();
(state.web.token.clone().unwrap_or_default(), state.web.port)
};
let api_url = format!("http://127.0.0.1:{}", api_port);
let env: Vec<(&str, &str)> = vec![
("TMAI_API_URL", api_url.as_str()),
("TMAI_TOKEN", api_token.as_str()),
];
match core
.pty_registry()
.spawn_session(&req.command, &all_args, &req.cwd, rows, cols, &env)
{
Ok(session) => {
let session_id = session.id.clone();
let response = SpawnResponse {
session_id: session_id.clone(),
pid: session.pid,
command: session.command.clone(),
};
let git_info = tmai_core::git::GitCache::new().get_info(&req.cwd).await;
{
#[allow(deprecated)]
let state = core.raw_state();
let mut s = state.write();
let agent_type = match req.command.as_str() {
"claude" => tmai_core::agents::AgentType::ClaudeCode,
"codex" => tmai_core::agents::AgentType::CodexCli,
"gemini" => tmai_core::agents::AgentType::GeminiCli,
other => tmai_core::agents::AgentType::Custom(other.to_string()),
};
let mut agent = tmai_core::agents::MonitoredAgent::new(
session_id.clone(),
agent_type,
req.command.clone(),
req.cwd.clone(),
session.pid,
"pty".to_string(),
req.command.clone(),
0,
0,
);
agent.status = tmai_core::agents::AgentStatus::Processing {
activity: "Starting...".to_string(),
};
agent.pty_session_id = Some(session_id.clone());
if let Some(ref info) = git_info {
agent.git_branch = Some(info.branch.clone());
agent.git_dirty = Some(info.dirty);
agent.is_worktree = Some(info.is_worktree);
agent.git_common_dir = info.common_dir.clone();
agent.worktree_name = tmai_core::git::extract_claude_worktree_name(&req.cwd);
}
s.agents.insert(session_id.clone(), agent);
s.agent_order.push(session_id.clone());
}
core.notify_agents_updated();
if let Some(ref ws_url) = codex_ws_url {
connect_codex_ws(core, &session_id, ws_url);
}
tracing::info!(
"API: spawned PTY session_id={} pid={}",
response.session_id,
response.pid
);
Ok(Json(response))
}
Err(e) => {
tracing::error!("API: spawn failed: {}", e);
Err(json_error(
StatusCode::INTERNAL_SERVER_ERROR,
&format!("Failed to spawn: {}", e),
))
}
}
}
async fn start_codex_app_server_sync(cwd: &str) -> Option<String> {
use tokio::io::{AsyncBufReadExt, BufReader};
let mut child = match tokio::process::Command::new("codex")
.args(["app-server", "--listen", "ws://127.0.0.1:0"])
.current_dir(cwd)
.stdin(std::process::Stdio::null())
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::piped())
.process_group(0)
.spawn()
{
Ok(c) => c,
Err(e) => {
tracing::warn!("Failed to start Codex app-server: {}", e);
return None;
}
};
let stderr = child.stderr.take()?;
let mut reader = BufReader::new(stderr).lines();
let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(5);
for _ in 0..10 {
let line = tokio::select! {
result = reader.next_line() => match result {
Ok(Some(l)) => l,
_ => break,
},
_ = tokio::time::sleep_until(deadline) => break,
};
if let Some(url) = line.strip_prefix(" listening on: ") {
let url = url.trim().to_string();
tracing::info!(url = %url, "Codex app-server started");
save_codex_ws_url(&url);
return Some(url);
}
}
tracing::warn!("Codex app-server did not report listening URL");
let _ = child.kill().await;
None
}
fn save_codex_ws_url(url: &str) {
let state_dir = tmai_core::ipc::protocol::state_dir();
let ws_dir = state_dir.join("codex-ws");
let _ = std::fs::create_dir_all(&ws_dir);
if let Some(port) = url.rsplit(':').next() {
let _ = std::fs::write(ws_dir.join(format!("{}.url", port)), url);
}
}
pub async fn reconnect_codex_ws(core: &Arc<TmaiCore>) {
let state_dir = tmai_core::ipc::protocol::state_dir();
let ws_dir = state_dir.join("codex-ws");
let entries = match std::fs::read_dir(&ws_dir) {
Ok(e) => e,
Err(_) => return,
};
for entry in entries.flatten() {
let path = entry.path();
if path.extension().and_then(|e| e.to_str()) != Some("url") {
continue;
}
let url = match std::fs::read_to_string(&path) {
Ok(u) => u.trim().to_string(),
Err(_) => continue,
};
let reachable = if let Some(addr) = url.strip_prefix("ws://") {
tokio::time::timeout(
std::time::Duration::from_secs(1),
tokio::net::TcpStream::connect(addr),
)
.await
.map(|r| r.is_ok())
.unwrap_or(false)
} else {
false
};
if reachable {
tracing::info!(url = %url, "Reconnecting to existing Codex app-server");
let pane_id = format!(
"codex-ws-reconnect-{}",
path.file_stem().unwrap_or_default().to_string_lossy()
);
connect_codex_ws(core, &pane_id, &url);
} else {
tracing::debug!(url = %url, "Codex app-server no longer reachable, removing");
let _ = std::fs::remove_file(&path);
}
}
}
fn connect_codex_ws(core: &Arc<TmaiCore>, pane_id: &str, ws_url: &str) {
let config = tmai_core::codex_ws::client::CodexWsClientConfig {
url: ws_url.to_string(),
pane_id: Some(pane_id.to_string()),
};
let registry = core.hook_registry().clone();
let event_tx = core.event_sender();
#[allow(deprecated)]
let state = core.raw_state().clone();
tracing::info!(
pane_id,
url = ws_url,
"Connecting WS client to Codex app-server"
);
tokio::spawn(async move {
tmai_core::codex_ws::client::run(config, registry, event_tx, state).await;
});
}
#[derive(Debug, Deserialize)]
pub struct BranchQueryParams {
pub repo: String,
}
pub async fn list_branches(
axum::extract::Query(params): axum::extract::Query<BranchQueryParams>,
) -> Result<Json<tmai_core::git::BranchListResult>, (StatusCode, Json<serde_json::Value>)> {
if !std::path::Path::new(¶ms.repo).is_dir() {
return Err(json_error(
StatusCode::BAD_REQUEST,
&format!("Directory does not exist: {}", params.repo),
));
}
tmai_core::git::list_branches(¶ms.repo)
.await
.ok_or_else(|| json_error(StatusCode::INTERNAL_SERVER_ERROR, "Failed to list branches"))
.map(Json)
}
#[derive(Debug, Deserialize)]
pub struct CommitLogParams {
pub repo: String,
pub base: String,
pub branch: String,
}
#[derive(Debug, Deserialize)]
pub struct DiffStatParams {
pub repo: String,
pub branch: String,
pub base: String,
}
pub async fn git_diff_stat(
axum::extract::Query(params): axum::extract::Query<DiffStatParams>,
) -> Result<Json<serde_json::Value>, (StatusCode, Json<serde_json::Value>)> {
let repo_dir = tmai_core::git::strip_git_suffix(¶ms.repo);
let result =
tmai_core::git::fetch_branch_diff_stat(repo_dir, ¶ms.branch, ¶ms.base).await;
match result {
Some(s) => Ok(Json(serde_json::json!({
"files_changed": s.files_changed,
"insertions": s.insertions,
"deletions": s.deletions,
}))),
None => Ok(Json(serde_json::json!(null))),
}
}
pub async fn git_branch_diff(
axum::extract::Query(params): axum::extract::Query<DiffStatParams>,
) -> Result<Json<serde_json::Value>, (StatusCode, Json<serde_json::Value>)> {
let repo_dir = tmai_core::git::strip_git_suffix(¶ms.repo);
if !tmai_core::git::is_safe_git_ref(¶ms.branch)
|| !tmai_core::git::is_safe_git_ref(¶ms.base)
{
return Err(json_error(StatusCode::BAD_REQUEST, "Invalid branch name"));
}
let diff_spec = format!("{}...{}", params.base, params.branch);
let output = tokio::process::Command::new("git")
.args(["-C", repo_dir, "diff", &diff_spec])
.output()
.await
.map_err(|e| {
json_error(
StatusCode::INTERNAL_SERVER_ERROR,
&format!("git diff failed: {}", e),
)
})?;
let raw = &output.stdout;
const MAX_DIFF_BYTES: usize = 1_048_576; let truncated = raw.len() > MAX_DIFF_BYTES;
let diff_bytes = if truncated {
&raw[..MAX_DIFF_BYTES]
} else {
raw.as_slice()
};
let diff = String::from_utf8_lossy(diff_bytes).to_string();
let summary =
tmai_core::git::fetch_branch_diff_stat(repo_dir, ¶ms.branch, ¶ms.base).await;
Ok(Json(serde_json::json!({
"diff": if diff.is_empty() { None } else { Some(diff) },
"truncated": truncated,
"summary": summary.map(|s| serde_json::json!({
"files_changed": s.files_changed,
"insertions": s.insertions,
"deletions": s.deletions,
})),
})))
}
pub async fn git_log(
axum::extract::Query(params): axum::extract::Query<CommitLogParams>,
) -> Result<Json<Vec<tmai_core::git::CommitEntry>>, (StatusCode, Json<serde_json::Value>)> {
let repo_dir = tmai_core::git::strip_git_suffix(¶ms.repo);
if !std::path::Path::new(repo_dir).is_dir() {
return Err(json_error(StatusCode::NOT_FOUND, "Repository not found"));
}
let commits = tmai_core::git::log_commits(repo_dir, ¶ms.base, ¶ms.branch, 20).await;
Ok(Json(commits))
}
#[derive(Debug, Deserialize)]
pub struct GraphQueryParams {
pub repo: String,
#[serde(default = "default_graph_limit")]
pub limit: usize,
}
fn default_graph_limit() -> usize {
100
}
pub async fn git_graph(
axum::extract::Query(params): axum::extract::Query<GraphQueryParams>,
) -> Result<Json<tmai_core::git::GraphData>, (StatusCode, Json<serde_json::Value>)> {
let repo_dir = tmai_core::git::strip_git_suffix(¶ms.repo);
if !std::path::Path::new(repo_dir).is_dir() {
return Err(json_error(StatusCode::NOT_FOUND, "Repository not found"));
}
tmai_core::git::log_graph(repo_dir, params.limit)
.await
.ok_or_else(|| json_error(StatusCode::INTERNAL_SERVER_ERROR, "Failed to get graph"))
.map(Json)
}
#[derive(Debug, Deserialize)]
pub struct DeleteBranchRequest {
pub repo_path: String,
pub branch: String,
#[serde(default)]
pub force: bool,
#[serde(default)]
pub delete_remote: bool,
}
pub async fn delete_branch(
Json(req): Json<DeleteBranchRequest>,
) -> Result<Json<serde_json::Value>, (StatusCode, Json<serde_json::Value>)> {
if !tmai_core::git::is_safe_git_ref(&req.branch) {
return Err(json_error(StatusCode::BAD_REQUEST, "Invalid branch name"));
}
let repo_dir = tmai_core::git::strip_git_suffix(&req.repo_path);
if !std::path::Path::new(repo_dir).is_dir() {
return Err(json_error(StatusCode::NOT_FOUND, "Repository not found"));
}
tmai_core::git::delete_branch(repo_dir, &req.branch, req.force, req.delete_remote)
.await
.map(|()| Json(serde_json::json!({"status": "ok"})))
.map_err(|e| json_error(StatusCode::BAD_REQUEST, &e))
}
#[derive(Debug, Deserialize)]
pub struct CheckoutRequest {
pub repo_path: String,
pub branch: String,
}
pub async fn checkout_branch(
Json(req): Json<CheckoutRequest>,
) -> Result<Json<serde_json::Value>, (StatusCode, Json<serde_json::Value>)> {
if !tmai_core::git::is_safe_git_ref(&req.branch) {
return Err(json_error(StatusCode::BAD_REQUEST, "Invalid branch name"));
}
let repo_dir = tmai_core::git::strip_git_suffix(&req.repo_path);
if !std::path::Path::new(repo_dir).is_dir() {
return Err(json_error(StatusCode::NOT_FOUND, "Repository not found"));
}
tmai_core::git::checkout_branch(repo_dir, &req.branch)
.await
.map(|()| Json(serde_json::json!({"status": "ok"})))
.map_err(|e| json_error(StatusCode::BAD_REQUEST, &e))
}
#[derive(Debug, Deserialize)]
pub struct CreateBranchRequest {
pub repo_path: String,
pub name: String,
pub base: Option<String>,
}
pub async fn create_branch(
Json(req): Json<CreateBranchRequest>,
) -> Result<Json<serde_json::Value>, (StatusCode, Json<serde_json::Value>)> {
if !tmai_core::git::is_safe_git_ref(&req.name) {
return Err(json_error(StatusCode::BAD_REQUEST, "Invalid branch name"));
}
if let Some(ref base) = req.base {
if !tmai_core::git::is_safe_git_ref(base) {
return Err(json_error(
StatusCode::BAD_REQUEST,
"Invalid base branch name",
));
}
}
let repo_dir = tmai_core::git::strip_git_suffix(&req.repo_path);
if !std::path::Path::new(repo_dir).is_dir() {
return Err(json_error(StatusCode::NOT_FOUND, "Repository not found"));
}
tmai_core::git::create_branch(repo_dir, &req.name, req.base.as_deref())
.await
.map(|()| Json(serde_json::json!({"status": "ok"})))
.map_err(|e| json_error(StatusCode::BAD_REQUEST, &e))
}
#[derive(Debug, Deserialize)]
pub struct FetchRequest {
pub repo_path: String,
pub remote: Option<String>,
}
pub async fn git_fetch(
Json(req): Json<FetchRequest>,
) -> Result<Json<serde_json::Value>, (StatusCode, Json<serde_json::Value>)> {
let repo_dir = tmai_core::git::strip_git_suffix(&req.repo_path);
if !std::path::Path::new(repo_dir).is_dir() {
return Err(json_error(StatusCode::NOT_FOUND, "Repository not found"));
}
tmai_core::git::fetch_remote(repo_dir, req.remote.as_deref())
.await
.map(|output| Json(serde_json::json!({"status": "ok", "output": output})))
.map_err(|e| json_error(StatusCode::BAD_REQUEST, &e))
}
#[derive(Debug, Deserialize)]
pub struct PullRequest {
pub repo_path: String,
}
pub async fn git_pull(
Json(req): Json<PullRequest>,
) -> Result<Json<serde_json::Value>, (StatusCode, Json<serde_json::Value>)> {
let repo_dir = tmai_core::git::strip_git_suffix(&req.repo_path);
if !std::path::Path::new(repo_dir).is_dir() {
return Err(json_error(StatusCode::NOT_FOUND, "Repository not found"));
}
tmai_core::git::pull(repo_dir)
.await
.map(|output| Json(serde_json::json!({"status": "ok", "output": output})))
.map_err(|e| json_error(StatusCode::BAD_REQUEST, &e))
}
#[derive(Debug, Deserialize)]
pub struct MergeRequest {
pub repo_path: String,
pub branch: String,
}
pub async fn git_merge(
Json(req): Json<MergeRequest>,
) -> Result<Json<serde_json::Value>, (StatusCode, Json<serde_json::Value>)> {
let repo_dir = tmai_core::git::strip_git_suffix(&req.repo_path);
if !std::path::Path::new(repo_dir).is_dir() {
return Err(json_error(StatusCode::NOT_FOUND, "Repository not found"));
}
tmai_core::git::merge_branch(repo_dir, &req.branch)
.await
.map(|output| Json(serde_json::json!({"status": "ok", "output": output})))
.map_err(|e| json_error(StatusCode::BAD_REQUEST, &e))
}
#[derive(Debug, Deserialize)]
pub struct WorktreeSpawnRequest {
pub name: String,
pub cwd: String,
#[serde(default)]
pub base_branch: Option<String>,
#[serde(default = "default_rows")]
pub rows: u16,
#[serde(default = "default_cols")]
pub cols: u16,
}
pub async fn spawn_worktree(
State(core): State<Arc<TmaiCore>>,
Json(req): Json<WorktreeSpawnRequest>,
) -> Result<Json<SpawnResponse>, (StatusCode, Json<serde_json::Value>)> {
if !std::path::Path::new(&req.cwd).is_dir() {
return Err(json_error(
StatusCode::BAD_REQUEST,
&format!("Directory does not exist: {}", req.cwd),
));
}
if !tmai_core::git::is_valid_worktree_name(&req.name) {
return Err(json_error(
StatusCode::BAD_REQUEST,
&format!("Invalid worktree name: {}", req.name),
));
}
let wt_req = tmai_core::worktree::WorktreeCreateRequest {
repo_path: req.cwd.clone(),
branch_name: req.name.clone(),
dir_name: None,
base_branch: req.base_branch.clone(),
};
let wt_result = tmai_core::worktree::create_worktree(&wt_req)
.await
.map_err(|e| json_error(StatusCode::BAD_REQUEST, &e.to_string()))?;
tracing::info!(
"API: created worktree '{}' at {} (branch: {})",
req.name,
wt_result.path,
wt_result.branch
);
let spawn_req = SpawnRequest {
command: "claude".to_string(),
args: vec![],
cwd: wt_result.path,
rows: req.rows,
cols: req.cols,
force_pty: false,
};
let effective_base = req.base_branch.clone();
let use_tmux = {
#[allow(deprecated)]
let state = core.raw_state().read();
state.spawn_in_tmux
};
let result = if use_tmux && is_tmux_available() {
spawn_in_tmux(&core, &spawn_req).await
} else {
spawn_in_pty(&core, &spawn_req).await
};
if let Ok(ref resp) = result {
#[allow(deprecated)]
let state = core.raw_state();
let mut s = state.write();
if let Some(agent) = s.agents.get_mut(&resp.session_id) {
agent.worktree_base_branch = effective_base;
}
}
result
}
pub async fn get_agent_output(
State(core): State<Arc<TmaiCore>>,
Path(id): Path<String>,
) -> Result<Json<serde_json::Value>, (StatusCode, Json<serde_json::Value>)> {
let session = core
.pty_registry()
.get(&id)
.ok_or_else(|| json_error(StatusCode::NOT_FOUND, "PTY session not found"))?;
let snapshot = session.scrollback_snapshot();
let text = String::from_utf8_lossy(&snapshot).to_string();
Ok(Json(serde_json::json!({
"session_id": id,
"output": text,
"bytes": snapshot.len(),
})))
}
#[derive(Debug, Deserialize)]
pub struct SendToRequest {
pub text: String,
}
pub async fn send_to_agent(
State(core): State<Arc<TmaiCore>>,
Path((from, to)): Path<(String, String)>,
Json(req): Json<SendToRequest>,
) -> Result<Json<serde_json::Value>, (StatusCode, Json<serde_json::Value>)> {
let source_exists = core.pty_registry().get(&from).is_some() || core.get_agent(&from).is_ok();
if !source_exists {
return Err(json_error(StatusCode::NOT_FOUND, "Source agent not found"));
}
if req.text.len() > 10240 {
return Err(json_error(
StatusCode::BAD_REQUEST,
"Text too long (max 10KB)",
));
}
if let Some(target_session) = core.pty_registry().get(&to) {
target_session
.write_input(req.text.as_bytes())
.map_err(|e| {
json_error(
StatusCode::INTERNAL_SERVER_ERROR,
&format!("Failed to write to target PTY: {}", e),
)
})?;
target_session.write_input(b"\r").map_err(|e| {
json_error(
StatusCode::INTERNAL_SERVER_ERROR,
&format!("Failed to send Enter: {}", e),
)
})?;
tracing::info!(
"API: sent {} bytes from {} to {} (PTY)",
req.text.len(),
from,
to
);
return Ok(Json(serde_json::json!({
"status": "ok",
"method": "pty",
})));
}
core.send_text(&to, &req.text).await.map_err(|e| {
let status = match &e {
tmai_core::api::ApiError::AgentNotFound { .. } => StatusCode::NOT_FOUND,
_ => StatusCode::INTERNAL_SERVER_ERROR,
};
json_error(status, &e.to_string())
})?;
tracing::info!(
"API: sent {} bytes from {} to {} (command_sender)",
req.text.len(),
from,
to
);
Ok(Json(serde_json::json!({
"status": "ok",
"method": "command_sender",
})))
}
pub async fn security_scan(
State(core): State<Arc<TmaiCore>>,
) -> Json<tmai_core::security::ScanResult> {
Json(core.security_scan())
}
pub async fn last_security_scan(
State(core): State<Arc<TmaiCore>>,
) -> Json<Option<tmai_core::security::ScanResult>> {
Json(core.last_security_scan())
}
pub async fn get_usage(State(core): State<Arc<TmaiCore>>) -> Json<tmai_core::usage::UsageSnapshot> {
Json(core.get_usage())
}
pub async fn trigger_usage_fetch(State(core): State<Arc<TmaiCore>>) -> StatusCode {
core.fetch_usage();
StatusCode::ACCEPTED
}
#[derive(Debug, Serialize)]
pub struct UsageSettingsResponse {
pub enabled: bool,
pub auto_refresh_min: u32,
}
#[derive(Debug, Deserialize)]
pub struct UsageSettingsRequest {
#[serde(default)]
pub enabled: Option<bool>,
#[serde(default)]
pub auto_refresh_min: Option<u32>,
}
pub async fn get_usage_settings(State(core): State<Arc<TmaiCore>>) -> Json<UsageSettingsResponse> {
let s = core.settings();
Json(UsageSettingsResponse {
enabled: s.usage.enabled,
auto_refresh_min: s.usage.auto_refresh_min,
})
}
pub async fn update_usage_settings(
State(core): State<Arc<TmaiCore>>,
Json(req): Json<UsageSettingsRequest>,
) -> Json<serde_json::Value> {
if let Some(enabled) = req.enabled {
tmai_core::config::Settings::save_toml_value(
"usage",
"enabled",
toml_edit::Value::from(enabled),
);
}
if let Some(interval) = req.auto_refresh_min {
tmai_core::config::Settings::save_toml_value(
"usage",
"auto_refresh_min",
toml_edit::Value::from(interval as i64),
);
}
core.reload_settings();
Json(serde_json::json!({"ok": true}))
}
#[derive(Debug, Deserialize)]
pub struct PrQueryParams {
pub repo: String,
}
pub async fn list_prs(
axum::extract::Query(params): axum::extract::Query<PrQueryParams>,
) -> Result<
Json<std::collections::HashMap<String, tmai_core::github::PrInfo>>,
(StatusCode, Json<serde_json::Value>),
> {
let repo_dir = tmai_core::git::strip_git_suffix(¶ms.repo);
if !std::path::Path::new(repo_dir).is_dir() {
return Err(json_error(StatusCode::NOT_FOUND, "Repository not found"));
}
tmai_core::github::list_open_prs(repo_dir)
.await
.ok_or_else(|| {
json_error(
StatusCode::INTERNAL_SERVER_ERROR,
"Failed to list PRs (is gh CLI authenticated?)",
)
})
.map(Json)
}
#[derive(Debug, Deserialize)]
pub struct ChecksQueryParams {
pub repo: String,
pub branch: String,
}
pub async fn list_checks(
axum::extract::Query(params): axum::extract::Query<ChecksQueryParams>,
) -> Result<Json<tmai_core::github::CiSummary>, (StatusCode, Json<serde_json::Value>)> {
let repo_dir = tmai_core::git::strip_git_suffix(¶ms.repo);
if !std::path::Path::new(repo_dir).is_dir() {
return Err(json_error(StatusCode::NOT_FOUND, "Repository not found"));
}
tmai_core::github::list_checks(repo_dir, ¶ms.branch)
.await
.ok_or_else(|| json_error(StatusCode::INTERNAL_SERVER_ERROR, "Failed to list checks"))
.map(Json)
}
pub async fn list_issues(
axum::extract::Query(params): axum::extract::Query<PrQueryParams>,
) -> Result<Json<Vec<tmai_core::github::IssueInfo>>, (StatusCode, Json<serde_json::Value>)> {
let repo_dir = tmai_core::git::strip_git_suffix(¶ms.repo);
if !std::path::Path::new(repo_dir).is_dir() {
return Err(json_error(StatusCode::NOT_FOUND, "Repository not found"));
}
tmai_core::github::list_issues(repo_dir)
.await
.ok_or_else(|| json_error(StatusCode::INTERNAL_SERVER_ERROR, "Failed to list issues"))
.map(Json)
}
#[derive(Debug, Deserialize)]
pub struct PrDetailParams {
pub repo: String,
pub pr_number: u64,
}
#[derive(Debug, Deserialize)]
pub struct CiLogParams {
pub repo: String,
pub run_id: u64,
}
pub async fn get_pr_comments(
axum::extract::Query(params): axum::extract::Query<PrDetailParams>,
) -> Result<Json<Vec<tmai_core::github::PrComment>>, (StatusCode, Json<serde_json::Value>)> {
let repo_dir = tmai_core::git::strip_git_suffix(¶ms.repo);
if !std::path::Path::new(repo_dir).is_dir() {
return Err(json_error(StatusCode::NOT_FOUND, "Repository not found"));
}
tmai_core::github::get_pr_comments(repo_dir, params.pr_number)
.await
.ok_or_else(|| {
json_error(
StatusCode::INTERNAL_SERVER_ERROR,
"Failed to fetch PR comments",
)
})
.map(Json)
}
pub async fn get_pr_files(
axum::extract::Query(params): axum::extract::Query<PrDetailParams>,
) -> Result<Json<Vec<tmai_core::github::PrChangedFile>>, (StatusCode, Json<serde_json::Value>)> {
let repo_dir = tmai_core::git::strip_git_suffix(¶ms.repo);
if !std::path::Path::new(repo_dir).is_dir() {
return Err(json_error(StatusCode::NOT_FOUND, "Repository not found"));
}
tmai_core::github::get_pr_files(repo_dir, params.pr_number)
.await
.ok_or_else(|| {
json_error(
StatusCode::INTERNAL_SERVER_ERROR,
"Failed to fetch PR files",
)
})
.map(Json)
}
pub async fn get_pr_merge_status(
axum::extract::Query(params): axum::extract::Query<PrDetailParams>,
) -> Result<Json<tmai_core::github::PrMergeStatus>, (StatusCode, Json<serde_json::Value>)> {
let repo_dir = tmai_core::git::strip_git_suffix(¶ms.repo);
if !std::path::Path::new(repo_dir).is_dir() {
return Err(json_error(StatusCode::NOT_FOUND, "Repository not found"));
}
tmai_core::github::get_pr_merge_status(repo_dir, params.pr_number)
.await
.ok_or_else(|| {
json_error(
StatusCode::INTERNAL_SERVER_ERROR,
"Failed to fetch PR merge status",
)
})
.map(Json)
}
pub async fn rerun_failed_checks(
Json(body): Json<CiLogParams>,
) -> Result<Json<serde_json::Value>, (StatusCode, Json<serde_json::Value>)> {
let repo_dir = tmai_core::git::strip_git_suffix(&body.repo);
if !std::path::Path::new(repo_dir).is_dir() {
return Err(json_error(StatusCode::NOT_FOUND, "Repository not found"));
}
tmai_core::github::rerun_failed_checks(repo_dir, body.run_id)
.await
.ok_or_else(|| {
json_error(
StatusCode::INTERNAL_SERVER_ERROR,
"Failed to re-run checks (may lack actions:write permission)",
)
})
.map(|()| Json(serde_json::json!({"status": "ok"})))
}
pub async fn get_ci_failure_log(
axum::extract::Query(params): axum::extract::Query<CiLogParams>,
) -> Result<Json<tmai_core::github::CiFailureLog>, (StatusCode, Json<serde_json::Value>)> {
let repo_dir = tmai_core::git::strip_git_suffix(¶ms.repo);
if !std::path::Path::new(repo_dir).is_dir() {
return Err(json_error(StatusCode::NOT_FOUND, "Repository not found"));
}
tmai_core::github::get_ci_failure_log(repo_dir, params.run_id)
.await
.ok_or_else(|| {
json_error(
StatusCode::INTERNAL_SERVER_ERROR,
"Failed to fetch CI failure log",
)
})
.map(Json)
}
#[derive(Debug, Deserialize)]
pub struct FileReadParams {
pub path: String,
}
pub async fn read_file(
State(core): State<Arc<TmaiCore>>,
axum::extract::Query(params): axum::extract::Query<FileReadParams>,
) -> Result<Json<serde_json::Value>, (StatusCode, Json<serde_json::Value>)> {
let path = std::path::Path::new(¶ms.path);
if !is_path_within_allowed_scope(path, Some(&core)) {
return Err(json_error(
StatusCode::FORBIDDEN,
"Path is outside allowed scope",
));
}
if !path.is_file() {
return Err(json_error(StatusCode::NOT_FOUND, "File not found"));
}
let metadata = std::fs::metadata(path)
.map_err(|e| json_error(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string()))?;
if metadata.len() > 1_048_576 {
return Err(json_error(
StatusCode::BAD_REQUEST,
"File too large (max 1MB)",
));
}
let content = std::fs::read_to_string(path)
.map_err(|_| json_error(StatusCode::BAD_REQUEST, "Not a text file (binary content)"))?;
let ext = path.extension().and_then(|e| e.to_str()).unwrap_or("");
let editable = OPENABLE_EXTENSIONS.contains(&ext);
Ok(Json(
serde_json::json!({ "path": params.path, "content": content, "editable": editable }),
))
}
#[derive(Debug, Deserialize)]
pub struct FileWriteRequest {
pub path: String,
pub content: String,
}
pub async fn write_file(
State(core): State<Arc<TmaiCore>>,
Json(req): Json<FileWriteRequest>,
) -> Result<Json<serde_json::Value>, (StatusCode, Json<serde_json::Value>)> {
let path = std::path::Path::new(&req.path);
if !is_path_within_allowed_scope(path, Some(&core)) {
return Err(json_error(
StatusCode::FORBIDDEN,
"Path is outside allowed scope",
));
}
let ext = path.extension().and_then(|e| e.to_str()).unwrap_or("");
if !matches!(ext, "md" | "json" | "toml" | "txt" | "yaml" | "yml") {
return Err(json_error(StatusCode::FORBIDDEN, "File type not allowed"));
}
if !path.is_file() {
return Err(json_error(StatusCode::NOT_FOUND, "File not found"));
}
std::fs::write(path, &req.content)
.map_err(|e| json_error(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string()))?;
Ok(Json(serde_json::json!({ "status": "ok" })))
}
#[derive(Debug, Deserialize)]
pub struct MdTreeParams {
pub root: String,
}
#[derive(Debug, Serialize)]
pub struct MdTreeEntry {
pub name: String,
pub path: String,
pub is_dir: bool,
pub openable: bool,
pub children: Option<Vec<MdTreeEntry>>,
}
const OPENABLE_EXTENSIONS: &[&str] = &["md", "json", "toml", "txt", "yaml", "yml"];
pub async fn md_tree(
State(core): State<Arc<TmaiCore>>,
axum::extract::Query(params): axum::extract::Query<MdTreeParams>,
) -> Result<Json<Vec<MdTreeEntry>>, (StatusCode, Json<serde_json::Value>)> {
let root = std::path::Path::new(¶ms.root);
if !is_path_within_allowed_scope(root, Some(&core)) {
return Err(json_error(
StatusCode::FORBIDDEN,
"Path is outside allowed scope",
));
}
if !root.is_dir() {
return Err(json_error(StatusCode::NOT_FOUND, "Directory not found"));
}
let entries =
scan_md_tree(root, 0).map_err(|e| json_error(StatusCode::INTERNAL_SERVER_ERROR, &e))?;
Ok(Json(entries))
}
fn scan_md_tree(dir: &std::path::Path, depth: usize) -> Result<Vec<MdTreeEntry>, String> {
if depth > 5 {
return Ok(Vec::new());
}
let mut entries = Vec::new();
let read_dir = std::fs::read_dir(dir).map_err(|e| e.to_string())?;
let mut items: Vec<_> = read_dir.filter_map(|e| e.ok()).collect();
items.sort_by_key(|e| e.file_name());
for entry in items {
let path = entry.path();
let name = entry.file_name().to_string_lossy().to_string();
if name.starts_with('.') && name != ".claude" {
continue;
}
if matches!(name.as_str(), "node_modules" | "target" | "dist" | ".git") {
continue;
}
if path.is_dir() {
let children = scan_md_tree(&path, depth + 1)?;
if !children.is_empty() {
entries.push(MdTreeEntry {
name,
path: path.to_string_lossy().to_string(),
is_dir: true,
openable: false,
children: Some(children),
});
}
} else {
let ext = path.extension().and_then(|e| e.to_str()).unwrap_or("");
let openable = OPENABLE_EXTENSIONS.contains(&ext);
entries.push(MdTreeEntry {
name,
path: path.to_string_lossy().to_string(),
is_dir: false,
openable,
children: None,
});
}
}
Ok(entries)
}
fn strip_git_suffix(path: &str) -> &str {
tmai_core::git::strip_git_suffix(path)
}
#[derive(Debug, Serialize)]
pub struct PreviewSettingsResponse {
pub show_cursor: bool,
}
#[derive(Debug, Deserialize)]
pub struct PreviewSettingsRequest {
pub show_cursor: Option<bool>,
}
pub async fn get_preview_settings(
State(core): State<Arc<TmaiCore>>,
) -> Json<PreviewSettingsResponse> {
Json(PreviewSettingsResponse {
show_cursor: core.settings().web.show_cursor,
})
}
pub async fn update_preview_settings(
State(core): State<Arc<TmaiCore>>,
Json(req): Json<PreviewSettingsRequest>,
) -> Json<serde_json::Value> {
if let Some(v) = req.show_cursor {
tmai_core::config::Settings::save_toml_value(
"web",
"show_cursor",
toml_edit::Value::from(v),
);
}
core.reload_settings();
Json(serde_json::json!({"ok": true}))
}
#[cfg(test)]
mod tests {
use super::*;
use axum::body::Body;
use axum::routing::{get, post};
use axum::Router;
use http::Request;
use http_body_util::BodyExt;
use tmai_core::agents::AgentStatus;
use tmai_core::api::{has_checkbox_format, TmaiCoreBuilder};
use tmai_core::command_sender::CommandSender;
use tmai_core::state::SharedState;
use tower::ServiceExt;
fn test_app_state() -> SharedState {
tmai_core::state::AppState::shared()
}
fn test_router_with_state(app_state: SharedState) -> Router {
let runtime: Arc<dyn tmai_core::runtime::RuntimeAdapter> =
Arc::new(tmai_core::runtime::StandaloneAdapter::new());
let cmd = CommandSender::new(None, runtime, app_state.clone());
let core = Arc::new(
TmaiCoreBuilder::new(tmai_core::config::Settings::default())
.with_state(app_state)
.with_command_sender(Arc::new(cmd))
.build(),
);
Router::new()
.route("/agents", get(get_agents))
.route("/agents/{id}/approve", post(approve_agent))
.route("/agents/{id}/select", post(select_choice))
.route("/agents/{id}/submit", post(submit_selection))
.route("/agents/{id}/input", post(send_text))
.route("/agents/{id}/key", post(send_key))
.route("/agents/{id}/preview", get(get_preview))
.route("/teams", get(get_teams))
.route("/teams/{name}/tasks", get(get_team_tasks))
.route("/security/scan", post(security_scan))
.route("/security/last", get(last_security_scan))
.with_state(core)
}
fn test_router() -> Router {
test_router_with_state(test_app_state())
}
fn add_idle_agent(state: &SharedState, id: &str) {
let mut s = state.write();
let mut agent = tmai_core::agents::MonitoredAgent::new(
id.to_string(),
tmai_core::agents::AgentType::ClaudeCode,
"Test".to_string(),
"/tmp".to_string(),
1234,
"main".to_string(),
"window".to_string(),
0,
0,
);
agent.status = AgentStatus::Idle;
s.agents.insert(id.to_string(), agent);
s.agent_order.push(id.to_string());
}
#[tokio::test]
async fn test_get_agents_empty() {
let app = test_router();
let response = app
.oneshot(
Request::builder()
.uri("/agents")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let body = response.into_body().collect().await.unwrap().to_bytes();
let agents: Vec<serde_json::Value> = serde_json::from_slice(&body).unwrap();
assert!(agents.is_empty());
}
#[tokio::test]
async fn test_approve_not_found() {
let app = test_router();
let response = app
.oneshot(
Request::builder()
.method("POST")
.uri("/agents/nonexistent/approve")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::NOT_FOUND);
}
#[tokio::test]
async fn test_select_choice_not_found() {
let app = test_router();
let response = app
.oneshot(
Request::builder()
.method("POST")
.uri("/agents/nonexistent/select")
.header("content-type", "application/json")
.body(Body::from(r#"{"choice":1}"#))
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::NOT_FOUND);
}
#[tokio::test]
async fn test_submit_selection_not_found() {
let app = test_router();
let response = app
.oneshot(
Request::builder()
.method("POST")
.uri("/agents/nonexistent/submit")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::NOT_FOUND);
}
#[tokio::test]
async fn test_send_text_not_found() {
let app = test_router();
let response = app
.oneshot(
Request::builder()
.method("POST")
.uri("/agents/nonexistent/input")
.header("content-type", "application/json")
.body(Body::from(r#"{"text":"hello"}"#))
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::NOT_FOUND);
}
#[tokio::test]
async fn test_get_preview_not_found() {
let app = test_router();
let response = app
.oneshot(
Request::builder()
.uri("/agents/nonexistent/preview")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::NOT_FOUND);
}
#[tokio::test]
async fn test_get_teams_empty() {
let app = test_router();
let response = app
.oneshot(
Request::builder()
.uri("/teams")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let body = response.into_body().collect().await.unwrap().to_bytes();
let teams: Vec<serde_json::Value> = serde_json::from_slice(&body).unwrap();
assert!(teams.is_empty());
}
#[tokio::test]
async fn test_get_team_tasks_not_found() {
let app = test_router();
let response = app
.oneshot(
Request::builder()
.uri("/teams/nonexistent/tasks")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::NOT_FOUND);
}
#[tokio::test]
async fn test_get_team_tasks_path_traversal() {
let app = test_router();
let response = app
.oneshot(
Request::builder()
.uri("/teams/..%2Fevil/tasks")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
}
#[tokio::test]
async fn test_approve_idle_agent_returns_ok() {
let state = test_app_state();
add_idle_agent(&state, "main:0.0");
let app = test_router_with_state(state);
let response = app
.oneshot(
Request::builder()
.method("POST")
.uri("/agents/main:0.0/approve")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
}
#[tokio::test]
async fn test_get_agents_with_agent() {
let state = test_app_state();
add_idle_agent(&state, "main:0.0");
let app = test_router_with_state(state);
let response = app
.oneshot(
Request::builder()
.uri("/agents")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let body = response.into_body().collect().await.unwrap().to_bytes();
let agents: Vec<serde_json::Value> = serde_json::from_slice(&body).unwrap();
assert_eq!(agents.len(), 1);
assert_eq!(agents[0]["id"], "main:0.0");
assert_eq!(agents[0]["status"], "Idle");
}
#[tokio::test]
async fn test_send_key_not_found() {
let app = test_router();
let response = app
.oneshot(
Request::builder()
.method("POST")
.uri("/agents/nonexistent/key")
.header("content-type", "application/json")
.body(Body::from(r#"{"key":"Enter"}"#))
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::NOT_FOUND);
}
#[tokio::test]
async fn test_send_key_invalid_key() {
let state = test_app_state();
add_idle_agent(&state, "main:0.0");
let app = test_router_with_state(state);
let response = app
.oneshot(
Request::builder()
.method("POST")
.uri("/agents/main:0.0/key")
.header("content-type", "application/json")
.body(Body::from(r#"{"key":"Delete"}"#))
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
}
#[test]
fn test_is_valid_team_name() {
assert!(is_valid_team_name("my-team"));
assert!(is_valid_team_name("team_1"));
assert!(is_valid_team_name("TeamAlpha"));
assert!(!is_valid_team_name(""));
assert!(!is_valid_team_name("../evil"));
assert!(!is_valid_team_name("team/name"));
assert!(!is_valid_team_name("team name"));
}
#[tokio::test]
async fn test_security_last_initially_null() {
let app = test_router();
let response = app
.oneshot(
Request::builder()
.uri("/security/last")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let body = response.into_body().collect().await.unwrap().to_bytes();
let result: serde_json::Value = serde_json::from_slice(&body).unwrap();
assert!(result.is_null());
}
#[tokio::test]
async fn test_security_scan_returns_ok() {
let app = test_router();
let response = app
.oneshot(
Request::builder()
.method("POST")
.uri("/security/scan")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let body = response.into_body().collect().await.unwrap().to_bytes();
let result: serde_json::Value = serde_json::from_slice(&body).unwrap();
assert!(result["risks"].is_array());
assert!(result["scanned_at"].is_string());
assert!(result["files_scanned"].is_number());
}
#[test]
fn test_has_checkbox_format() {
assert!(has_checkbox_format(&[
"[ ] Option A".to_string(),
"[ ] Option B".to_string(),
]));
assert!(!has_checkbox_format(&[
"Option A".to_string(),
"Option B".to_string(),
]));
}
#[test]
fn test_shell_quote_strips_control_chars() {
assert_eq!(shell_quote("hello"), "hello");
assert_eq!(shell_quote("he\x01llo"), "hello");
assert_eq!(shell_quote("ab\x1bcd"), "abcd");
assert_eq!(shell_quote("a\nb"), "'a\nb'");
assert_eq!(shell_quote("a\tb"), "ab");
assert_eq!(shell_quote("hello world"), "'hello world'");
assert_eq!(shell_quote("it's"), "'it'\\''s'");
}
#[test]
fn test_is_path_within_allowed_scope() {
if let Some(home) = dirs::home_dir() {
let test_path = home.join("some_file.txt");
assert!(is_path_within_allowed_scope(&test_path, None));
}
let outside = std::path::Path::new("/etc/passwd");
assert!(!is_path_within_allowed_scope(outside, None));
}
}