Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 8 additions & 8 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

101 changes: 101 additions & 0 deletions crates/autocli-browser/src/auth.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
use autocli_core::CliError;
use std::io::Write;
use std::path::PathBuf;

const TOKEN_ENV: &str = "AUTOCLI_DAEMON_TOKEN";
const TOKEN_FILENAME: &str = "daemon-token";

fn home_dir() -> PathBuf {
let home = std::env::var("HOME")
.or_else(|_| std::env::var("USERPROFILE"))
.unwrap_or_else(|_| ".".to_string());
PathBuf::from(home)
}

fn normalize_token(raw: &str) -> Option<String> {
let token = raw.trim();
if token.is_empty() {
None
} else {
Some(token.to_string())
}
}

pub fn daemon_token_path() -> PathBuf {
home_dir().join(".autocli").join(TOKEN_FILENAME)
}

pub fn load_or_create_daemon_token() -> Result<String, CliError> {
if let Ok(token) = std::env::var(TOKEN_ENV) {
if let Some(token) = normalize_token(&token) {
return Ok(token);
}
}

let path = daemon_token_path();
if path.exists() {
let content = std::fs::read_to_string(&path).map_err(|e| CliError::Config {
message: format!("Failed to read daemon token from {}", path.display()),
suggestions: vec![
"Delete the token file so AutoCLI can recreate it".to_string(),
format!("Path: {}", path.display()),
],
source: Some(Box::new(e)),
})?;
if let Some(token) = normalize_token(&content) {
return Ok(token);
}
}

let token = uuid::Uuid::new_v4().simple().to_string();
persist_token(&path, &token)?;
Ok(token)
}

fn persist_token(path: &PathBuf, token: &str) -> Result<(), CliError> {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent).map_err(|e| CliError::Config {
message: format!("Failed to create config directory {}", parent.display()),
suggestions: vec![],
source: Some(Box::new(e)),
})?;
}

#[cfg(unix)]
let mut file = {
use std::os::unix::fs::OpenOptionsExt;
std::fs::OpenOptions::new()
.create(true)
.truncate(true)
.write(true)
.mode(0o600)
.open(path)
}
.map_err(|e| CliError::Config {
message: format!("Failed to write daemon token to {}", path.display()),
suggestions: vec![],
source: Some(Box::new(e)),
})?;

#[cfg(not(unix))]
let mut file = std::fs::OpenOptions::new()
.create(true)
.truncate(true)
.write(true)
.open(path)
.map_err(|e| CliError::Config {
message: format!("Failed to write daemon token to {}", path.display()),
suggestions: vec![],
source: Some(Box::new(e)),
})?;

file.write_all(token.as_bytes())
.and_then(|_| file.write_all(b"\n"))
.map_err(|e| CliError::Config {
message: format!("Failed to persist daemon token to {}", path.display()),
suggestions: vec![],
source: Some(Box::new(e)),
})?;

Ok(())
}
82 changes: 63 additions & 19 deletions crates/autocli-browser/src/daemon.rs
Original file line number Diff line number Diff line change
@@ -1,15 +1,16 @@
use autocli_core::CliError;
use axum::{
extract::{
ws::{Message, WebSocket},
State, WebSocketUpgrade,
},
http::header::ORIGIN,
http::{HeaderMap, StatusCode},
response::IntoResponse,
routing::{get, post},
Json, Router,
};
use futures::{SinkExt, StreamExt};
use autocli_core::CliError;
use serde_json::json;
use std::{
collections::HashMap,
Expand All @@ -19,6 +20,7 @@ use std::{
use tokio::sync::{oneshot, Mutex, RwLock};
use tracing::{debug, error, info, warn};

use crate::auth::load_or_create_daemon_token;
use crate::types::{DaemonCommand, DaemonResult};

/// Command response timeout.
Expand All @@ -36,15 +38,17 @@ pub struct DaemonState {
pub pending_commands: RwLock<PendingMap>,
pub extension_connected: RwLock<bool>,
pub last_activity: RwLock<Instant>,
pub auth_token: String,
}

impl DaemonState {
fn new() -> Self {
fn new(auth_token: String) -> Self {
Self {
extension_tx: Mutex::new(None),
pending_commands: RwLock::new(HashMap::new()),
extension_connected: RwLock::new(false),
last_activity: RwLock::new(Instant::now()),
auth_token,
}
}

Expand All @@ -62,21 +66,16 @@ pub struct Daemon {
impl Daemon {
/// Start the daemon server on the given port. Returns immediately after the listener binds.
pub async fn start(port: u16) -> Result<Self, CliError> {
let state = Arc::new(DaemonState::new());
let auth_token = load_or_create_daemon_token()?;
let state = Arc::new(DaemonState::new(auth_token));
let (shutdown_tx, shutdown_rx) = oneshot::channel::<()>();

let cors = tower_http::cors::CorsLayer::new()
.allow_origin(tower_http::cors::Any)
.allow_methods(tower_http::cors::Any)
.allow_headers(tower_http::cors::Any);

let app = Router::new()
.route("/health", get(health_handler))
.route("/ping", get(health_handler))
.route("/status", get(status_handler))
.route("/command", post(command_handler))
.route("/ext", get(ws_handler))
.layer(cors)
.with_state(state.clone());

let listener = tokio::net::TcpListener::bind(format!("127.0.0.1:{port}"))
Expand Down Expand Up @@ -136,9 +135,41 @@ async fn health_handler() -> impl IntoResponse {
(StatusCode::OK, "ok")
}

fn unauthorized_response() -> (StatusCode, Json<serde_json::Value>) {
(
StatusCode::UNAUTHORIZED,
Json(json!({ "error": "Missing or invalid daemon token" })),
)
}

fn is_authorized(headers: &HeaderMap, expected_token: &str) -> bool {
headers
.get("x-autocli-token")
.or_else(|| headers.get("x-opencli-token"))
.and_then(|value| value.to_str().ok())
.map(|token| token == expected_token)
.unwrap_or(false)
}

fn websocket_origin_allowed(headers: &HeaderMap) -> bool {
headers
.get(ORIGIN)
.and_then(|value| value.to_str().ok())
.map(|origin| {
origin.starts_with("chrome-extension://") || origin.starts_with("moz-extension://")
})
.unwrap_or(false)
}

/// GET /status — return daemon and extension status.
/// Compatible with both autocli and original opencli formats.
async fn status_handler(State(state): State<Arc<DaemonState>>) -> impl IntoResponse {
async fn status_handler(
State(state): State<Arc<DaemonState>>,
headers: HeaderMap,
) -> impl IntoResponse {
if !is_authorized(&headers, &state.auth_token) {
return unauthorized_response().into_response();
}
let ext = *state.extension_connected.read().await;
let pending = state.pending_commands.read().await.len();
Json(json!({
Expand All @@ -149,6 +180,7 @@ async fn status_handler(State(state): State<Arc<DaemonState>>) -> impl IntoRespo
"extensionConnected": ext,
"pending": pending,
}))
.into_response()
}

/// POST /command — accept a command from the CLI and forward to the extension.
Expand All @@ -157,12 +189,8 @@ async fn command_handler(
headers: HeaderMap,
Json(cmd): Json<DaemonCommand>,
) -> impl IntoResponse {
// Security: require X-AutoCLI or X-OpenCLI header (backward compatible)
if !headers.contains_key("x-autocli") && !headers.contains_key("x-opencli") {
return (
StatusCode::FORBIDDEN,
Json(json!({ "error": "Missing X-AutoCLI header" })),
);
if !is_authorized(&headers, &state.auth_token) {
return unauthorized_response();
}

state.touch().await;
Expand All @@ -179,7 +207,11 @@ async fn command_handler(

// Create a oneshot channel for the result
let (tx, rx) = oneshot::channel::<DaemonResult>();
state.pending_commands.write().await.insert(cmd_id.clone(), tx);
state
.pending_commands
.write()
.await
.insert(cmd_id.clone(), tx);

// Forward command to extension via WebSocket
{
Expand Down Expand Up @@ -210,7 +242,10 @@ async fn command_handler(
} else {
StatusCode::UNPROCESSABLE_ENTITY
};
(status, Json(serde_json::to_value(result).unwrap_or(json!({}))))
(
status,
Json(serde_json::to_value(result).unwrap_or(json!({}))),
)
}
Ok(Err(_)) => (
StatusCode::INTERNAL_SERVER_ERROR,
Expand All @@ -229,9 +264,18 @@ async fn command_handler(
/// GET /ext — WebSocket upgrade for Chrome extension.
async fn ws_handler(
State(state): State<Arc<DaemonState>>,
headers: HeaderMap,
ws: WebSocketUpgrade,
) -> impl IntoResponse {
if !websocket_origin_allowed(&headers) {
return (
StatusCode::FORBIDDEN,
Json(json!({ "error": "WebSocket origin not allowed" })),
)
.into_response();
}
ws.on_upgrade(move |socket| handle_extension_ws(state, socket))
.into_response()
}

async fn handle_extension_ws(state: Arc<DaemonState>, socket: WebSocket) {
Expand Down Expand Up @@ -326,7 +370,7 @@ mod tests {

#[tokio::test]
async fn test_daemon_state_touch() {
let state = DaemonState::new();
let state = DaemonState::new("test-token".to_string());
let before = *state.last_activity.read().await;
tokio::time::sleep(Duration::from_millis(10)).await;
state.touch().await;
Expand Down
Loading