use std::convert::Infallible;
use std::net::SocketAddr;
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
use axum::{
Json,
extract::{ConnectInfo, Path, Query, State},
http::StatusCode,
response::sse::{Event, KeepAlive, Sse},
};
use futures::Stream;
use serde::{Deserialize, Serialize};
use tokio_stream::StreamExt;
use tokio_stream::wrappers::BroadcastStream;
use crate::control::event::BackendKind;
use crate::control::{ActorCommand, ControlSessionId, RunParams};
use crate::daemon::rpc::managed::control::CallerTrust;
use crate::daemon::rpc::managed::outcome::RouteOutcome;
use crate::daemon::state::DaemonState;
struct WriteLockGuard {
flag: Arc<AtomicBool>,
held: bool,
}
impl WriteLockGuard {
fn new(flag: Arc<AtomicBool>, held: bool) -> Self {
Self { flag, held }
}
}
impl Drop for WriteLockGuard {
fn drop(&mut self) {
if self.held {
self.flag.store(false, Ordering::SeqCst);
}
}
}
#[derive(Debug, Deserialize)]
pub struct CtlRunRequest {
pub project_id: String,
pub workdir: String,
pub backend: Option<String>,
pub prompt_file: Option<String>,
pub claude_cmd: Option<String>,
}
#[derive(Debug, Serialize)]
pub struct CtlRunResponse {
pub session_id: String,
}
#[derive(Debug, Serialize)]
pub struct CtlConnectResponse {
pub session_id: String,
pub writer: bool,
}
#[derive(Debug, Deserialize)]
pub struct CtlStopQuery {
pub force: Option<bool>,
}
#[derive(Debug, Serialize)]
pub struct CtlStopResponse {
pub session_id: String,
pub force: bool,
}
#[derive(Debug, Serialize)]
pub struct CtlAuthResponse {
pub session_id: String,
pub state: String,
pub awaiting_auth: bool,
}
#[derive(Debug, Deserialize)]
pub struct CtlListQuery {
pub project: Option<String>,
}
#[derive(Debug, Serialize)]
pub struct CtlSessionSummary {
pub session_id: String,
pub project_id: String,
pub backend: String,
pub state: String,
pub restart_count: u32,
pub context_lost: bool,
pub uptime_secs: u64,
pub last_activity_ts: String,
pub last_summary: Option<String>,
pub pending_decision: Option<String>,
pub proposed_default: Option<String>,
}
#[derive(Debug, Serialize)]
pub struct CtlListResponse {
pub sessions: Vec<CtlSessionSummary>,
}
fn parse_id(s: &str) -> ControlSessionId {
ControlSessionId(s.to_owned())
}
fn parse_backend(s: Option<&str>) -> BackendKind {
match s {
Some("tmux") => BackendKind::Tmux,
_ => BackendKind::StreamJson,
}
}
fn loopback_guard(peer: &SocketAddr) -> CallerTrust {
let is_loopback = super::rpc::is_loopback(peer);
if !is_loopback {
tracing::warn!(%peer, "rejected non-loopback control-plane session request (#6197)");
}
CallerTrust::from_loopback_check(is_loopback)
}
fn is_safe_cmd_charset(s: &str) -> bool {
!s.is_empty()
&& s.chars()
.all(|c| c.is_ascii_alphanumeric() || matches!(c, '/' | '.' | '_' | '-'))
}
pub(crate) fn validate_claude_cmd(cmd: Option<&str>) -> Result<(), RouteOutcome> {
match cmd {
None => Ok(()),
Some("claude") => Ok(()),
Some(other) => {
tracing::warn!(cmd = %other, "rejected claude_cmd outside the allowed set (#6197)");
Err(RouteOutcome::text(
400,
"claude_cmd override is not permitted over HTTP; omit it to use the default `claude`",
))
}
}
}
pub(crate) fn validate_prompt_file(prompt_file: Option<&str>) -> Result<(), RouteOutcome> {
let Some(pf) = prompt_file else { return Ok(()) };
let path = std::path::Path::new(pf);
let traversal = path
.components()
.any(|c| matches!(c, std::path::Component::ParentDir));
if is_safe_cmd_charset(pf) && path.is_absolute() && !traversal && path.is_file() {
Ok(())
} else {
tracing::warn!(prompt_file = %pf, "rejected invalid prompt_file (#6197)");
Err(RouteOutcome::text(
400,
"prompt_file must be an absolute path (no `..`, no shell metacharacters) to an existing file",
))
}
}
pub(crate) fn validate_workdir(workdir: &std::path::Path) -> Result<(), RouteOutcome> {
let traversal = workdir
.components()
.any(|c| matches!(c, std::path::Component::ParentDir));
if workdir.is_absolute() && !traversal && workdir.is_dir() {
Ok(())
} else {
tracing::warn!(workdir = %workdir.display(), "rejected invalid control-plane workdir (#6197)");
Err(RouteOutcome::text(
400,
"workdir must be an absolute path (no `..`) to an existing directory",
))
}
}
pub async fn ctl_run_session(
State(state): State<Arc<DaemonState>>,
ConnectInfo(peer): ConnectInfo<SocketAddr>,
Json(req): Json<CtlRunRequest>,
) -> impl axum::response::IntoResponse {
ctl_run_core(&state, loopback_guard(&peer), req).await }
pub(crate) async fn ctl_run_core(
state: &Arc<DaemonState>,
trust: CallerTrust,
req: CtlRunRequest,
) -> RouteOutcome {
if let Err(refusal) = trust.ensure_local() {
return refusal;
}
if let Err(refusal) = validate_claude_cmd(req.claude_cmd.as_deref()) {
return refusal;
}
if let Err(refusal) = validate_prompt_file(req.prompt_file.as_deref()) {
return refusal;
}
if let Err(refusal) = validate_workdir(std::path::Path::new(&req.workdir)) {
return refusal;
}
let backend = parse_backend(req.backend.as_deref());
let params = RunParams {
project_id: req.project_id,
workdir: req.workdir.into(),
backend,
prompt_file: req.prompt_file.map(Into::into),
claude_cmd: req.claude_cmd,
};
match state.session_registry.run_session(params).await {
Ok(id) => RouteOutcome::ok(&CtlRunResponse {
session_id: id.to_string(),
}),
Err(e) => RouteOutcome::text(500, e.to_string()),
}
}
pub async fn ctl_connect_session(
State(state): State<Arc<DaemonState>>,
ConnectInfo(peer): ConnectInfo<SocketAddr>,
Path(id_str): Path<String>,
) -> Result<Sse<impl Stream<Item = Result<Event, Infallible>>>, (StatusCode, String)> {
if !loopback_guard(&peer).is_local() {
return Err((StatusCode::FORBIDDEN, CallerTrust::REFUSAL.to_owned()));
}
let id = parse_id(&id_str);
let handle = state
.session_registry
.get(&id)
.await
.ok_or_else(|| (StatusCode::NOT_FOUND, format!("session {id_str} not found")))?;
let writer = handle.try_acquire_write_lock();
let guard = WriteLockGuard::new(Arc::clone(&handle.write_lock_held), writer);
let rx = handle.event_tx.subscribe();
let stream = BroadcastStream::new(rx).filter_map(move |item| {
let _guard = &guard;
match item {
Ok(event) => {
let json = serde_json::to_string(&event).unwrap_or_default();
Some(Ok(Event::default().data(json)))
}
Err(_) => None,
}
});
Ok(Sse::new(stream).keep_alive(KeepAlive::default()))
}
pub async fn ctl_stop_session(
State(state): State<Arc<DaemonState>>,
ConnectInfo(peer): ConnectInfo<SocketAddr>,
Path(id_str): Path<String>,
Query(query): Query<CtlStopQuery>,
) -> impl axum::response::IntoResponse {
ctl_stop_core(
&state,
loopback_guard(&peer),
&id_str,
query.force.unwrap_or(false),
)
.await }
pub(crate) async fn ctl_stop_core(
state: &Arc<DaemonState>,
trust: CallerTrust,
id_str: &str,
force: bool,
) -> RouteOutcome {
if let Err(refusal) = trust.ensure_local() {
return refusal; }
let id = parse_id(id_str);
let Some(handle) = state.session_registry.get(&id).await else {
return RouteOutcome::text(404, format!("session {id_str} not found"));
};
let cmd = if force {
ActorCommand::ForceStop
} else {
ActorCommand::Stop
};
if let Err(e) = handle.command_tx.send(cmd).await {
return RouteOutcome::text(500, e.to_string());
}
RouteOutcome::ok(&CtlStopResponse {
session_id: id_str.to_owned(),
force,
})
}
pub async fn ctl_auth_session(
State(state): State<Arc<DaemonState>>,
ConnectInfo(peer): ConnectInfo<SocketAddr>,
Path(id_str): Path<String>,
) -> impl axum::response::IntoResponse {
ctl_auth_core(&state, loopback_guard(&peer), &id_str).await }
pub(crate) async fn ctl_auth_core(
state: &Arc<DaemonState>,
trust: CallerTrust,
id_str: &str,
) -> RouteOutcome {
if let Err(refusal) = trust.ensure_local() {
return refusal; }
let id = parse_id(id_str);
let Some(handle) = state.session_registry.get(&id).await else {
return RouteOutcome::text(404, format!("session {id_str} not found"));
};
let meta = handle.metadata.read().await;
let state_label = meta.state.label().to_owned();
let awaiting_auth = state_label == "awaiting-auth";
RouteOutcome::ok(&CtlAuthResponse {
session_id: id_str.to_owned(),
state: state_label,
awaiting_auth,
})
}
pub async fn ctl_list_sessions(
State(state): State<Arc<DaemonState>>,
ConnectInfo(peer): ConnectInfo<SocketAddr>,
Query(query): Query<CtlListQuery>,
) -> impl axum::response::IntoResponse {
ctl_list_core(&state, loopback_guard(&peer), query).await }
pub(crate) async fn ctl_list_core(
state: &Arc<DaemonState>,
trust: CallerTrust,
query: CtlListQuery,
) -> RouteOutcome {
if let Err(refusal) = trust.ensure_local() {
return refusal; }
let ids = state.session_registry.list_ids().await;
let mut sessions = Vec::with_capacity(ids.len());
for id in ids {
if let Some(handle) = state.session_registry.get(&id).await {
let meta = handle.metadata.read().await;
let backend_label = match meta.backend {
BackendKind::StreamJson => "stream-json",
BackendKind::Tmux => "tmux",
};
if query
.project
.as_deref()
.is_some_and(|f| f != meta.project_id)
{
continue;
}
sessions.push(CtlSessionSummary {
session_id: id.to_string(),
project_id: meta.project_id.clone(),
backend: backend_label.to_owned(),
state: meta.state.label().to_owned(),
restart_count: meta.restart_count as u32,
context_lost: meta.context_lost,
uptime_secs: meta.uptime_secs(),
last_activity_ts: meta.last_activity_at.to_rfc3339(),
last_summary: meta.last_summary.clone(),
pending_decision: meta.pending_decision.clone(),
proposed_default: meta.proposed_default.clone(),
});
}
}
RouteOutcome::ok(&CtlListResponse { sessions })
}
#[cfg(test)]
mod tests {
use super::*;
use crate::control::actor::SessionActorHandle;
use crate::control::id::ControlSessionId;
use crate::control::state::SessionMetadata;
use crate::daemon::state::DaemonState;
use axum::Router;
use axum::body::Body;
use axum::extract::connect_info::MockConnectInfo;
use axum::http::{Method, Request, StatusCode};
use axum::routing::{get, post};
use std::net::{IpAddr, Ipv4Addr, SocketAddr};
use std::sync::Arc;
use std::sync::atomic::AtomicBool;
use tokio::sync::{RwLock, broadcast, mpsc};
use tower::ServiceExt;
fn loopback_peer() -> SocketAddr {
SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 0)
}
fn make_handle(id: &ControlSessionId) -> SessionActorHandle {
let (command_tx, _) = mpsc::channel(4);
let (event_tx, _) = broadcast::channel(16);
SessionActorHandle {
command_tx,
event_tx,
write_lock_held: Arc::new(AtomicBool::new(false)),
metadata: Arc::new(RwLock::new(SessionMetadata::new(
id.clone(),
"test-proj".into(),
BackendKind::StreamJson,
))),
}
}
fn test_state() -> (Arc<DaemonState>, tempfile::TempDir) {
let dir = tempfile::tempdir().expect("temp dir");
let paths = crate::core::paths::FrameworkPaths::under(dir.path());
let state = Arc::new(DaemonState::with_paths(&paths));
(state, dir)
}
fn test_state_no_spawn() -> (Arc<DaemonState>, tempfile::TempDir) {
let dir = tempfile::tempdir().expect("temp dir");
let paths = crate::core::paths::FrameworkPaths::under(dir.path());
std::fs::create_dir_all(&paths.root).expect("create framework root");
std::fs::write(
paths.root.join("config.toml"),
"[control_plane]\nmax_concurrent_sessions = 0\n",
)
.expect("write config");
let state = Arc::new(DaemonState::with_paths(&paths));
(state, dir)
}
fn test_routes(state: Arc<DaemonState>) -> Router {
Router::new()
.route("/api/v1/control/sessions", get(ctl_list_sessions))
.route("/api/v1/control/sessions/run", post(ctl_run_session))
.route(
"/api/v1/control/sessions/{id}/connect",
post(ctl_connect_session),
)
.route("/api/v1/control/sessions/{id}/stop", post(ctl_stop_session))
.route("/api/v1/control/sessions/{id}/auth", get(ctl_auth_session))
.with_state(state)
}
fn test_router(state: Arc<DaemonState>) -> Router {
test_routes(state).layer(MockConnectInfo(loopback_peer()))
}
#[tokio::test]
async fn ctl_list_sessions_returns_empty() {
let (state, _dir) = test_state();
let app = test_router(state);
let req = Request::builder()
.method(Method::GET)
.uri("/api/v1/control/sessions")
.body(Body::empty())
.unwrap();
let resp = app.oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
.await
.unwrap();
let parsed: serde_json::Value = serde_json::from_slice(&body).unwrap();
assert_eq!(parsed["sessions"].as_array().unwrap().len(), 0);
}
#[tokio::test]
async fn ctl_list_sessions_returns_registered_sessions() {
let (state, _dir) = test_state();
let id = ControlSessionId::new("list-proj", 0);
let handle = make_handle(&id);
state.session_registry.register(id.clone(), handle).await;
let app = test_router(Arc::clone(&state));
let req = Request::builder()
.method(Method::GET)
.uri("/api/v1/control/sessions")
.body(Body::empty())
.unwrap();
let resp = app.oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
.await
.unwrap();
let parsed: serde_json::Value = serde_json::from_slice(&body).unwrap();
assert_eq!(parsed["sessions"].as_array().unwrap().len(), 1);
assert_eq!(
parsed["sessions"][0]["session_id"].as_str().unwrap(),
id.as_str()
);
}
#[tokio::test]
async fn ctl_list_sessions_full_schema() {
let (state, _dir) = test_state();
let id = ControlSessionId::new("schema-proj", 0);
let handle = make_handle(&id);
state.session_registry.register(id.clone(), handle).await;
let app = test_router(Arc::clone(&state));
let req = Request::builder()
.method(Method::GET)
.uri("/api/v1/control/sessions")
.body(Body::empty())
.unwrap();
let resp = app.oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
.await
.unwrap();
let parsed: serde_json::Value = serde_json::from_slice(&body).unwrap();
let row = &parsed["sessions"][0];
assert!(row["session_id"].is_string(), "session_id must be a string");
assert!(row["project_id"].is_string(), "project_id must be a string");
assert!(row["backend"].is_string(), "backend must be a string");
assert!(row["state"].is_string(), "state must be a string");
assert!(
row["restart_count"].is_number(),
"restart_count must be a number"
);
assert!(
row["context_lost"].is_boolean(),
"context_lost must be a boolean"
);
assert!(
row["uptime_secs"].is_number(),
"uptime_secs must be a number"
);
assert!(
row["last_activity_ts"].is_string(),
"last_activity_ts must be a string (ISO-8601)"
);
assert!(
row["last_summary"].is_null() || row["last_summary"].is_string(),
"last_summary must be null or string"
);
assert!(
row["pending_decision"].is_null() || row["pending_decision"].is_string(),
"pending_decision must be null or string"
);
assert!(
row["proposed_default"].is_null() || row["proposed_default"].is_string(),
"proposed_default must be null or string"
);
assert_eq!(row["session_id"].as_str().unwrap(), id.as_str());
assert_eq!(row["project_id"].as_str().unwrap(), "test-proj");
assert_eq!(row["backend"].as_str().unwrap(), "stream-json");
assert_eq!(row["restart_count"].as_u64().unwrap(), 0);
assert!(!row["context_lost"].as_bool().unwrap());
}
#[tokio::test]
async fn ctl_stop_session_not_found() {
let (state, _dir) = test_state();
let app = test_router(state);
let req = Request::builder()
.method(Method::POST)
.uri("/api/v1/control/sessions/nonexistent-0/stop")
.body(Body::empty())
.unwrap();
let resp = app.oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::NOT_FOUND);
}
#[tokio::test]
async fn ctl_auth_session_returns_state() {
let (state, _dir) = test_state();
let id = ControlSessionId::new("auth-proj", 0);
let handle = make_handle(&id);
state.session_registry.register(id.clone(), handle).await;
let app = test_router(Arc::clone(&state));
let uri = format!("/api/v1/control/sessions/{}/auth", id.as_str());
let req = Request::builder()
.method(Method::GET)
.uri(&uri)
.body(Body::empty())
.unwrap();
let resp = app.oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
.await
.unwrap();
let parsed: serde_json::Value = serde_json::from_slice(&body).unwrap();
assert_eq!(parsed["session_id"].as_str().unwrap(), id.as_str());
assert!(!parsed["awaiting_auth"].as_bool().unwrap());
}
#[tokio::test]
async fn ctl_auth_session_not_found() {
let (state, _dir) = test_state();
let app = test_router(state);
let req = Request::builder()
.method(Method::GET)
.uri("/api/v1/control/sessions/no-such-0/auth")
.body(Body::empty())
.unwrap();
let resp = app.oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::NOT_FOUND);
}
#[tokio::test]
async fn ctl_connect_write_lock_cas_first_caller_is_writer() {
let (state, _dir) = test_state();
let id = ControlSessionId::new("cas-proj", 0);
let handle = make_handle(&id);
state.session_registry.register(id.clone(), handle).await;
let h = state
.session_registry
.get(&id)
.await
.expect("handle must exist");
let first = h.try_acquire_write_lock();
let second = h.try_acquire_write_lock();
assert!(first, "first caller must acquire write lock");
assert!(!second, "second caller must be observer while lock held");
h.release_write_lock();
let third = h.try_acquire_write_lock();
assert!(third, "after release, lock must be acquirable again");
h.release_write_lock();
}
#[test]
fn write_lock_guard_releases_on_drop() {
let flag = Arc::new(AtomicBool::new(false));
flag.store(true, Ordering::SeqCst);
{
let _guard = WriteLockGuard::new(Arc::clone(&flag), true);
assert!(
flag.load(Ordering::SeqCst),
"flag must be true while guard is live"
);
}
assert!(
!flag.load(Ordering::SeqCst),
"guard must release lock on drop"
);
}
#[test]
fn write_lock_guard_observer_noop_on_drop() {
let flag = Arc::new(AtomicBool::new(true)); {
let _observer_guard = WriteLockGuard::new(Arc::clone(&flag), false);
}
assert!(
flag.load(Ordering::SeqCst),
"observer guard must not clear a writer's lock"
);
}
#[test]
fn ctl_connect_write_lock_guard_lives_with_stream() {
use tokio_stream::wrappers::BroadcastStream;
let (tx, rx) = tokio::sync::broadcast::channel::<crate::control::event::SessionEvent>(4);
drop(tx);
let flag = Arc::new(AtomicBool::new(true)); let guard = WriteLockGuard::new(Arc::clone(&flag), true);
let stream = BroadcastStream::new(rx).filter_map(move |item| {
let _guard = &guard; match item {
Ok(event) => {
let json = serde_json::to_string(&event).unwrap_or_default();
Some(Ok::<_, std::convert::Infallible>(
axum::response::sse::Event::default().data(json),
))
}
Err(_) => None,
}
});
let pinned = Box::pin(stream);
assert!(
flag.load(Ordering::SeqCst),
"write_lock_held must be true while the stream (and its closure) is alive"
);
let second = flag
.compare_exchange(false, true, Ordering::SeqCst, Ordering::SeqCst)
.is_ok();
assert!(
!second,
"CAS must fail while the guard-owned stream is alive"
);
drop(pinned);
assert!(
!flag.load(Ordering::SeqCst),
"write_lock_held must be false after the stream is dropped"
);
let third = flag
.compare_exchange(false, true, Ordering::SeqCst, Ordering::SeqCst)
.is_ok();
assert!(
third,
"third caller must be able to re-acquire lock after stream drop"
);
flag.store(false, Ordering::SeqCst);
}
#[tokio::test]
async fn ctl_stop_session_sends_stop_command() {
let (state, _dir) = test_state();
let id = ControlSessionId::new("stop-proj", 0);
let (command_tx, mut command_rx) = mpsc::channel(4);
let (event_tx, _) = broadcast::channel(16);
let handle = SessionActorHandle {
command_tx,
event_tx,
write_lock_held: Arc::new(AtomicBool::new(false)),
metadata: Arc::new(RwLock::new(SessionMetadata::new(
id.clone(),
"stop-proj".into(),
BackendKind::StreamJson,
))),
};
state.session_registry.register(id.clone(), handle).await;
let app = test_router(Arc::clone(&state));
let uri = format!("/api/v1/control/sessions/{}/stop", id.as_str());
let req = Request::builder()
.method(Method::POST)
.uri(&uri)
.body(Body::empty())
.unwrap();
let resp = app.oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let received =
tokio::time::timeout(std::time::Duration::from_millis(200), command_rx.recv())
.await
.expect("command must arrive within timeout")
.expect("channel must not be closed");
assert!(
matches!(received, ActorCommand::Stop),
"expected Stop, got {:?}",
received
);
}
#[tokio::test]
async fn ctl_run_session_rejects_non_loopback_peer() {
let (state, _dir) = test_state_no_spawn();
let app = test_routes(state);
let peer = SocketAddr::new(IpAddr::V4(Ipv4Addr::new(192, 168, 1, 50)), 40000);
let mut req = Request::builder()
.method(Method::POST)
.uri("/api/v1/control/sessions/run")
.header("content-type", "application/json")
.body(Body::from(
r#"{"project_id":"p","workdir":"/tmp","backend":"stream-json"}"#,
))
.unwrap();
req.extensions_mut().insert(ConnectInfo(peer));
let resp = app.oneshot(req).await.unwrap();
assert_eq!(
resp.status(),
StatusCode::FORBIDDEN,
"a non-loopback caller must be refused before any spawn"
);
}
#[tokio::test]
async fn ctl_run_session_rejects_claude_cmd_outside_allowlist() {
let (state, _dir) = test_state_no_spawn();
let app = test_router(Arc::clone(&state)); let req = Request::builder()
.method(Method::POST)
.uri("/api/v1/control/sessions/run")
.header("content-type", "application/json")
.body(Body::from(
r#"{"project_id":"p","workdir":"/tmp","backend":"tmux","claude_cmd":"sh"}"#,
))
.unwrap();
let resp = app.oneshot(req).await.unwrap();
assert_eq!(
resp.status(),
StatusCode::BAD_REQUEST,
"a claude_cmd outside the allowed set must be rejected before any spawn"
);
}
#[tokio::test]
async fn ctl_run_session_rejects_prompt_file_injection() {
let (state, _dir) = test_state_no_spawn();
let app = test_router(Arc::clone(&state)); let req = Request::builder()
.method(Method::POST)
.uri("/api/v1/control/sessions/run")
.header("content-type", "application/json")
.body(Body::from(
r#"{"project_id":"p","workdir":"/tmp","backend":"tmux","prompt_file":"/tmp/x; touch /tmp/PWNED; #"}"#,
))
.unwrap();
let resp = app.oneshot(req).await.unwrap();
assert_eq!(
resp.status(),
StatusCode::BAD_REQUEST,
"a prompt_file with shell metacharacters must be rejected before any spawn"
);
}
#[tokio::test]
async fn ctl_run_session_rejects_relative_workdir() {
let (state, _dir) = test_state_no_spawn();
let app = test_router(Arc::clone(&state));
let req = Request::builder()
.method(Method::POST)
.uri("/api/v1/control/sessions/run")
.header("content-type", "application/json")
.body(Body::from(
r#"{"project_id":"p","workdir":"relative/dir","backend":"stream-json"}"#,
))
.unwrap();
let resp = app.oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
}
#[tokio::test]
async fn ctl_run_session_rejects_workdir_traversal() {
let (state, _dir) = test_state_no_spawn();
let app = test_router(Arc::clone(&state));
let req = Request::builder()
.method(Method::POST)
.uri("/api/v1/control/sessions/run")
.header("content-type", "application/json")
.body(Body::from(
r#"{"project_id":"p","workdir":"/tmp/../etc","backend":"stream-json"}"#,
))
.unwrap();
let resp = app.oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
}
#[tokio::test]
async fn ctl_run_session_accepts_default_claude_cmd() {
let (state, _dir) = test_state_no_spawn();
let app = test_router(Arc::clone(&state));
let dir = tempfile::tempdir().expect("temp dir");
let body = serde_json::json!({
"project_id": "p",
"workdir": dir.path().to_string_lossy(),
"backend": "stream-json",
});
let req = Request::builder()
.method(Method::POST)
.uri("/api/v1/control/sessions/run")
.header("content-type", "application/json")
.body(Body::from(body.to_string()))
.unwrap();
let resp = app.oneshot(req).await.unwrap();
assert_eq!(
resp.status(),
StatusCode::INTERNAL_SERVER_ERROR,
"a valid default request must pass all guards and reach the cap-rejected spawn path"
);
}
#[test]
fn loopback_guard_accepts_loopback_refuses_remote() {
assert!(loopback_guard(&loopback_peer()).is_local());
let remote = SocketAddr::new(IpAddr::V4(Ipv4Addr::new(10, 0, 0, 5)), 1234);
assert!(!loopback_guard(&remote).is_local());
let refusal = loopback_guard(&remote)
.ensure_local()
.expect_err("remote must be refused");
assert_eq!(refusal.status, 403);
}
#[test]
fn validate_claude_cmd_allows_default_refuses_paths() {
assert!(validate_claude_cmd(None).is_ok());
assert!(validate_claude_cmd(Some("claude")).is_ok());
for bad in [
"/opt/anthropic/claude",
"/tmp/evil/claude",
"./claude",
"sh",
"curl",
"/bin/bash",
"claude; rm -rf /",
"claude foo",
"",
] {
let err = validate_claude_cmd(Some(bad)).expect_err("must refuse");
assert_eq!(err.status, 400, "bad claude_cmd: {bad:?}");
}
}
#[test]
fn validate_prompt_file_requires_absolute_existing_regular_file() {
assert!(validate_prompt_file(None).is_ok());
let dir = tempfile::tempdir().expect("temp dir");
let file = dir.path().join("prompt.txt");
std::fs::write(&file, "hi").expect("write prompt");
assert!(validate_prompt_file(Some(file.to_str().unwrap())).is_ok());
assert!(validate_prompt_file(Some("/tmp/x; touch /tmp/PWNED; #")).is_err());
assert!(validate_prompt_file(Some("relative/prompt.txt")).is_err());
assert!(validate_prompt_file(Some("/tmp/../etc/hosts")).is_err());
assert!(
validate_prompt_file(Some(dir.path().to_str().unwrap())).is_err(),
"a directory is not a regular file"
);
assert!(validate_prompt_file(Some("/no/such/file-6197.txt")).is_err());
}
#[test]
fn validate_workdir_requires_absolute_existing_dir() {
let dir = tempfile::tempdir().expect("temp dir");
assert!(validate_workdir(dir.path()).is_ok());
assert!(validate_workdir(std::path::Path::new("relative/dir")).is_err());
assert!(validate_workdir(std::path::Path::new("/tmp/../etc")).is_err());
assert!(
validate_workdir(std::path::Path::new("/no/such/dir/xyz-6197")).is_err(),
"a non-existent absolute path must be refused"
);
}
}