Skip to content
Merged
Show file tree
Hide file tree
Changes from 13 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
2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -108,7 +108,7 @@ built = { version = "0.8", features = ["chrono"] }
insta = { version = "1", features = ["json"] }
tempfile = "3"
pretty_assertions = "1"
reqwest = { version = "0.12", default-features = false }
reqwest = { version = "0.12", default-features = false, features = ["stream"] }

[lints.rust]
unsafe_code = "warn"
Expand Down
6 changes: 5 additions & 1 deletion src/cli/serve.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,10 +12,14 @@ pub struct ServeArgs {
/// Open the dashboard in your browser once the server is up.
#[arg(long)]
pub open: bool,
/// Acknowledge exposing all PM/cost/webhook data with no authentication.
/// Required whenever --host is not 127.0.0.1/localhost/::1.
#[arg(long)]
pub yes_expose: bool,
}

impl ServeArgs {
pub fn run(self) {
crate::dashboard::serve(&self.host, self.port, self.open);
crate::dashboard::serve(&self.host, self.port, self.open, self.yes_expose);
}
}
75 changes: 60 additions & 15 deletions src/dashboard/data.rs
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,10 @@ pub fn claims_json() -> String {
});
match result {
Ok(claims) => serde_json::to_string(&claims).unwrap_or_else(|_| "[]".into()),
Err(_) => "[]".into(),
Err(e) => {
eprintln!("[dashboard/data] claims_json: {e}");
"[]".into()
}
}
}

Expand All @@ -35,14 +38,20 @@ pub fn claims_json() -> String {
pub fn workspaces_json() -> String {
match pm_db_readonly() {
Ok(conn) => workspaces_json_from(&conn),
Err(_) => "[]".into(),
Err(e) => {
eprintln!("[dashboard/data] workspaces_json: {e}");
"[]".into()
}
}
}

fn workspaces_json_from(conn: &Connection) -> String {
match agentflare_backend::workspace::list(conn) {
Ok(rows) => serde_json::to_string(&rows).unwrap_or_else(|_| "[]".into()),
Err(_) => "[]".into(),
Err(e) => {
eprintln!("[dashboard/data] workspaces_json_from: {e}");
"[]".into()
}
}
}

Expand All @@ -51,14 +60,20 @@ fn workspaces_json_from(conn: &Connection) -> String {
pub fn projects_json(workspace_id: &str) -> String {
match pm_db_readonly() {
Ok(conn) => projects_json_from(&conn, workspace_id),
Err(_) => "[]".into(),
Err(e) => {
eprintln!("[dashboard/data] projects_json: {e}");
"[]".into()
}
}
}

fn projects_json_from(conn: &Connection, workspace_id: &str) -> String {
match agentflare_backend::project::list_by_workspace(conn, workspace_id) {
Ok(rows) => serde_json::to_string(&rows).unwrap_or_else(|_| "[]".into()),
Err(_) => "[]".into(),
Err(e) => {
eprintln!("[dashboard/data] projects_json_from: {e}");
"[]".into()
}
}
}

Expand All @@ -67,14 +82,20 @@ fn projects_json_from(conn: &Connection, workspace_id: &str) -> String {
pub fn items_json(project_id: &str) -> String {
match pm_db_readonly() {
Ok(conn) => items_json_from(&conn, project_id),
Err(_) => "[]".into(),
Err(e) => {
eprintln!("[dashboard/data] items_json: {e}");
"[]".into()
}
}
}

fn items_json_from(conn: &Connection, project_id: &str) -> String {
match agentflare_backend::item::list_by_project(conn, project_id) {
Ok(rows) => serde_json::to_string(&rows).unwrap_or_else(|_| "[]".into()),
Err(_) => "[]".into(),
Err(e) => {
eprintln!("[dashboard/data] items_json_from: {e}");
"[]".into()
}
}
}

Expand All @@ -83,14 +104,20 @@ fn items_json_from(conn: &Connection, project_id: &str) -> String {
pub fn states_json(project_id: &str) -> String {
match pm_db_readonly() {
Ok(conn) => states_json_from(&conn, project_id),
Err(_) => "[]".into(),
Err(e) => {
eprintln!("[dashboard/data] states_json: {e}");
"[]".into()
}
}
}

fn states_json_from(conn: &Connection, project_id: &str) -> String {
match agentflare_backend::state::list_by_project(conn, project_id) {
Ok(rows) => serde_json::to_string(&rows).unwrap_or_else(|_| "[]".into()),
Err(_) => "[]".into(),
Err(e) => {
eprintln!("[dashboard/data] states_json_from: {e}");
"[]".into()
}
}
}

Expand All @@ -99,14 +126,20 @@ fn states_json_from(conn: &Connection, project_id: &str) -> String {
pub fn comments_json(item_id: &str) -> String {
match pm_db_readonly() {
Ok(conn) => comments_json_from(&conn, item_id),
Err(_) => "[]".into(),
Err(e) => {
eprintln!("[dashboard/data] comments_json: {e}");
"[]".into()
}
}
}

fn comments_json_from(conn: &Connection, item_id: &str) -> String {
match agentflare_backend::comment::list_by_item(conn, item_id) {
Ok(rows) => serde_json::to_string(&rows).unwrap_or_else(|_| "[]".into()),
Err(_) => "[]".into(),
Err(e) => {
eprintln!("[dashboard/data] comments_json_from: {e}");
"[]".into()
}
}
}

Expand All @@ -117,7 +150,10 @@ fn comments_json_from(conn: &Connection, item_id: &str) -> String {
pub fn labels_json(workspace_id: Option<&str>, project_id: Option<&str>) -> String {
match pm_db_readonly() {
Ok(conn) => labels_json_from(&conn, workspace_id, project_id),
Err(_) => "[]".into(),
Err(e) => {
eprintln!("[dashboard/data] labels_json: {e}");
"[]".into()
}
}
}

Expand All @@ -133,7 +169,10 @@ fn labels_json_from(
};
match result {
Ok(rows) => serde_json::to_string(&rows).unwrap_or_else(|_| "[]".into()),
Err(_) => "[]".into(),
Err(e) => {
eprintln!("[dashboard/data] labels_json_from: {e}");
"[]".into()
}
}
}

Expand All @@ -143,14 +182,20 @@ fn labels_json_from(
pub fn webhooks_json(workspace_id: &str) -> String {
match pm_db_readonly() {
Ok(conn) => webhooks_json_from(&conn, workspace_id),
Err(_) => "[]".into(),
Err(e) => {
eprintln!("[dashboard/data] webhooks_json: {e}");
"[]".into()
}
}
}

fn webhooks_json_from(conn: &Connection, workspace_id: &str) -> String {
match agentflare_backend::webhook::list_logs_by_workspace(conn, workspace_id) {
Ok(rows) => serde_json::to_string(&rows).unwrap_or_else(|_| "[]".into()),
Err(_) => "[]".into(),
Err(e) => {
eprintln!("[dashboard/data] webhooks_json_from: {e}");
"[]".into()
}
}
}

Expand Down
4 changes: 2 additions & 2 deletions src/dashboard/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,9 @@ mod artifacts;
mod data;
mod server;

pub fn serve(host: &str, port: u16, open: bool) {
pub fn serve(host: &str, port: u16, open: bool, yes_expose: bool) {
let runtime = tokio::runtime::Runtime::new().expect("failed to build tokio runtime");
runtime.block_on(server::run(host, port, open));
runtime.block_on(server::run(host, port, open, yes_expose));
}

/// Best-effort: open a URL in the default browser. Never panics.
Expand Down
135 changes: 132 additions & 3 deletions src/dashboard/server.rs
Original file line number Diff line number Diff line change
Expand Up @@ -106,7 +106,17 @@ struct CostQuery {

async fn cost_handler(Query(q): Query<CostQuery>) -> Response {
let days = q.days.unwrap_or(1);
let by = q.by.as_deref().unwrap_or("model");
let by = match q.by.as_deref() {
None | Some("model") => "model",
Some("project") => "project",
Some(other) => {
return (
StatusCode::BAD_REQUEST,
format!("invalid `by` value {other:?}; expected \"model\" or \"project\""),
)
.into_response();
}
};
(
[(header::CONTENT_TYPE, "application/json")],
crate::dashboard::data::cost_json(days, by),
Expand Down Expand Up @@ -232,14 +242,25 @@ pub fn router() -> Router {
.fallback(static_handler)
}

pub async fn run(host: &str, port: u16, open: bool) {
fn is_local_bind(host: &str) -> bool {
matches!(host, "127.0.0.1" | "localhost" | "::1")
}

pub async fn run(host: &str, port: u16, open: bool, yes_expose: bool) {
if !is_local_bind(host) && !yes_expose {
eprintln!(
"refusing to bind to {host}: this would expose all PM/cost/webhook data with no authentication."
);
eprintln!("pass --yes-expose to bind anyway (trusted networks only).");
std::process::exit(1);
}
let listener = tokio::net::TcpListener::bind((host, port))
.await
.expect("failed to bind dashboard server");
let addr = listener.local_addr().expect("no local addr");
let url = format!("http://{addr}");
eprintln!("agentflare dashboard listening on {url}");
if host != "127.0.0.1" && host != "localhost" {
if !is_local_bind(host) {
Comment on lines +245 to +263

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

Enforce exposure safety against the bound socket address.

localhost is allowlisted before resolution, so a local resolver/hosts configuration mapping it to a LAN address bypasses --yes-expose; conversely, valid loopback addresses such as 127.0.0.2 are rejected. Bind first, then use listener.local_addr()?.ip().is_loopback() for both the refusal and warning checks, and update the related test accordingly.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/dashboard/server.rs` around lines 245 - 263, Update run to bind the
TcpListener before performing exposure checks, then use
listener.local_addr().ip().is_loopback() for both the refusal condition and the
subsequent warning check instead of is_local_bind(host). Remove or replace
is_local_bind as appropriate, and update the related test to cover resolved
socket behavior, including non-127.0.0.1 loopback addresses and localhost
resolving to a non-loopback address.

eprintln!(" warning: bound to {host} — anyone on your network can view this");
}
if open {
Expand Down Expand Up @@ -271,4 +292,112 @@ mod tests {
.unwrap();
assert!(body.starts_with('['), "expected JSON array, got: {body}");
}

#[test]
fn is_local_bind_recognizes_loopback_only() {
assert!(is_local_bind("127.0.0.1"));
assert!(is_local_bind("localhost"));
assert!(is_local_bind("::1"));
assert!(!is_local_bind("0.0.0.0"));
assert!(!is_local_bind("192.168.1.5"));
}

#[test]
fn mime_for_maps_known_extensions() {
assert_eq!(mime_for("index.html"), "text/html; charset=utf-8");
assert_eq!(mime_for("app.js"), "text/javascript; charset=utf-8");
assert_eq!(mime_for("app.css"), "text/css; charset=utf-8");
assert_eq!(mime_for("logo.png"), "application/octet-stream");
}

#[tokio::test]
async fn static_handler_serves_index_at_root() {
let resp = static_handler(Uri::from_static("/")).await;
assert_eq!(resp.status(), StatusCode::OK);
assert_eq!(
resp.headers().get(header::CONTENT_TYPE).unwrap(),
"text/html; charset=utf-8"
);
}

#[tokio::test]
async fn static_handler_404s_unknown_path() {
let resp = static_handler(Uri::from_static("/does-not-exist.xyz")).await;
assert_eq!(resp.status(), StatusCode::NOT_FOUND);
}

#[tokio::test]
async fn static_handler_404s_dot_dot_path_instead_of_escaping_assets() {
// Embedded assets have no ".." keys, so a literal ".." in the path can
// never escape `dashboard/web/` — it just misses the lookup.
let resp = static_handler(Uri::from_static("/../Cargo.toml")).await;
assert_eq!(resp.status(), StatusCode::NOT_FOUND);
}

#[tokio::test]
async fn cost_handler_defaults_to_one_day_grouped_by_model() {
let resp = cost_handler(Query(CostQuery {
days: None,
by: None,
}))
.await;
assert_eq!(resp.status(), StatusCode::OK);
let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
.await
.unwrap();
// Live analytics data (this machine's own ~/.claude/projects usage)
// can change between calls, so assert on shape rather than an exact
// second snapshot — this still proves the days=1/by=model defaults
// reached `cost_json` without a 400/500.
let v: serde_json::Value = serde_json::from_slice(&body).unwrap();
assert!(v.get("groups").is_some(), "expected groups in {v}");
assert!(
v.get("total_cost_usd").is_some(),
"expected total_cost_usd in {v}"
);
assert!(
v.get("any_unpriced").is_some(),
"expected any_unpriced in {v}"
);
}

#[tokio::test]
async fn cost_handler_rejects_unknown_by_value() {
let resp = cost_handler(Query(CostQuery {
days: None,
by: Some("projct".into()),
}))
.await;
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
}

#[tokio::test]
async fn events_endpoint_streams_claims_and_cost_snapshot() {
use tokio_stream::StreamExt as _;
let listener = tokio::net::TcpListener::bind(("127.0.0.1", 0))
.await
.unwrap();
let addr = listener.local_addr().unwrap();
tokio::spawn(async move {
axum::serve(listener, router()).await.unwrap();
});
let resp = reqwest::get(format!("http://{addr}/events")).await.unwrap();
let mut stream = resp.bytes_stream();
let first = tokio::time::timeout(std::time::Duration::from_secs(10), stream.next())
.await
.expect("first SSE frame within 10s")
.expect("stream item")
.unwrap();
let text = String::from_utf8(first.to_vec()).unwrap();
let data_line = text
.lines()
.find_map(|l| l.strip_prefix("data: "))
.expect("SSE data line");
let v: serde_json::Value = serde_json::from_str(data_line).unwrap();
assert!(v.get("claims").is_some(), "expected claims field in {v}");
assert!(
v.get("cost_today").is_some(),
"expected cost_today field in {v}"
);
}
Comment on lines +375 to +402

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Locate the target file and inspect the relevant range.
grep -n "events_endpoint_streams_claims_and_cost_snapshot" -n src/dashboard/server.rs || true
nl -ba src/dashboard/server.rs | sed -n '330,430p'

# Find SSE-related code that produces /events.
grep -n "text/event-stream\|SSE\|events" -n src/dashboard/server.rs | sed -n '1,120p'

Repository: getappz/agentflare

Length of output: 266


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Read the surrounding server implementation and event stream producer.
nl -ba src/dashboard/server.rs | sed -n '1,260p'
nl -ba src/dashboard/server.rs | sed -n '260,520p'

Repository: getappz/agentflare

Length of output: 196


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Narrow search for the exact test and any SSE framing logic.
grep -RIn --line-number --fixed-strings "bytes_stream()" src || true
grep -RIn --line-number --fixed-strings "data: " src/dashboard || true
grep -RIn --line-number --fixed-strings "text/event-stream" src || true

Repository: getappz/agentflare

Length of output: 426


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Show only the test and the /events handler around the relevant lines.
nl -ba src/dashboard/server.rs | sed -n '360,430p'

Repository: getappz/agentflare

Length of output: 196


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Print the relevant portion of src/dashboard/server.rs with line numbers.
awk 'NR>=320 && NR<=430 { printf "%4d: %s\n", NR, $0 }' src/dashboard/server.rs

Repository: getappz/agentflare

Length of output: 3830


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Inspect the exact response framing used by the /events endpoint.
awk 'NR>=1 && NR<=320 { printf "%4d: %s\n", NR, $0 }' src/dashboard/server.rs | sed -n '1,320p'

Repository: getappz/agentflare

Length of output: 13140


Buffer the SSE event before parsing.

resp.bytes_stream() can split the first SSE record across transport chunks, so stream.next() may return partial data: JSON and flake. Accumulate bytes until the blank-line delimiter before decoding.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/dashboard/server.rs` around lines 375 - 402, Update
events_endpoint_streams_claims_and_cost_snapshot to accumulate bytes from
resp.bytes_stream() until the SSE blank-line record delimiter is received,
rather than parsing the first stream.next() chunk directly. Decode and parse the
complete event afterward, preserving the existing claims and cost_today
assertions.

}
Loading