Skip to content
Merged
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
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -254,7 +254,7 @@ These are compile-time constants and cannot be changed via configuration.
| `MAX_MESSAGE_SIZE` | `65536` (64 KB) | `message/envelope.rs` | Maximum encoded envelope size. Messages exceeding this are rejected. |
| `REQUEST_TIMEOUT` | `30s` | `transport/mod.rs` | Timeout for bidirectional request/response exchanges. |
| `STALE_TIMEOUT` | `60s` | `peer_table.rs` | Discovered (non-static, non-cached) peers with no activity for this duration are removed. |
| `MAX_IPC_LINE_LENGTH` | `64 KB` | `ipc/server.rs` | Maximum length of a single IPC command line. Overlong lines are rejected with `command_too_large`. |
| `MAX_IPC_LINE_LENGTH` | `64 KB` | `ipc/protocol.rs` | Maximum length of a single IPC command line. Overlong lines are rejected with `command_too_large`. |
| `MAX_CONNECTIONS` | `128` | `daemon/mod.rs` | Maximum simultaneous QUIC peer connections. |
| `KEEPALIVE` | `15s` | `daemon/mod.rs` | QUIC keepalive interval. |
| `IDLE_TIMEOUT` | `60s` | `daemon/mod.rs` | QUIC idle timeout. Connections with no traffic for this duration are closed. |
Expand Down
2 changes: 1 addition & 1 deletion axon/Cargo.lock

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

2 changes: 1 addition & 1 deletion axon/Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[package]
name = "axon"
version = "0.7.0"
version = "0.7.1"
edition = "2024"
description = "AXON — Agent eXchange Over Network, LLM-first local messaging daemon"
license = "MIT"
Expand Down
14 changes: 14 additions & 0 deletions axon/src/cli/config_cmd.rs
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,7 @@ pub async fn run(paths: &AxonPaths, args: ConfigArgs) -> Result<ExitCode> {
println!("{value}");
Ok(ExitCode::SUCCESS)
} else {
eprintln!("{}: not set", key_display_name(key));
Ok(ExitCode::from(1))
}
}
Expand Down Expand Up @@ -134,6 +135,14 @@ fn parse_action(args: &ConfigArgs) -> Result<ConfigAction> {
Ok(ConfigAction::Get(key))
}

fn key_display_name(key: ConfigKey) -> &'static str {
match key {
ConfigKey::Name => "name",
ConfigKey::Port => "port",
ConfigKey::AdvertiseAddr => "advertise-addr",
}
}

fn get_value(config: &PersistedConfig, key: ConfigKey) -> Option<String> {
match key {
ConfigKey::Name => config.name.clone(),
Expand All @@ -155,6 +164,11 @@ fn apply_set(config: &mut PersistedConfig, key: ConfigKey, value: &str) -> Resul
let port = value
.parse::<u16>()
.with_context(|| format!("invalid port '{value}'"))?;
if port == 0 {
anyhow::bail!(
"port 0 is not valid; QUIC requires a non-zero port for peer connections"
);
}
config.port = Some(port);
}
ConfigKey::AdvertiseAddr => {
Expand Down
25 changes: 24 additions & 1 deletion axon/src/cli/config_cmd_tests.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
use super::{ConfigArgs, ConfigKey, apply_set, parse_action, render_list_text};
use super::{ConfigArgs, ConfigKey, apply_set, key_display_name, parse_action, render_list_text};
use axon::config::PersistedConfig;

#[test]
Expand Down Expand Up @@ -37,6 +37,29 @@ fn apply_set_rejects_bad_values() {
assert!(apply_set(&mut config, ConfigKey::AdvertiseAddr, "missing-port").is_err());
}

#[test]
fn apply_set_rejects_port_zero() {
let mut config = PersistedConfig::default();
let err = apply_set(&mut config, ConfigKey::Port, "0").expect_err("port 0 should fail");
assert!(err.to_string().contains("port 0"));
}

#[test]
fn apply_set_accepts_valid_port_boundaries() {
let mut config = PersistedConfig::default();
apply_set(&mut config, ConfigKey::Port, "1").expect("port 1");
assert_eq!(config.port, Some(1));
apply_set(&mut config, ConfigKey::Port, "65535").expect("port 65535");
assert_eq!(config.port, Some(65535));
}

#[test]
fn key_display_name_maps_all_keys() {
assert_eq!(key_display_name(ConfigKey::Name), "name");
assert_eq!(key_display_name(ConfigKey::Port), "port");
assert_eq!(key_display_name(ConfigKey::AdvertiseAddr), "advertise-addr");
}

#[test]
fn render_list_text_only_includes_set_keys() {
let config = PersistedConfig {
Expand Down
2 changes: 1 addition & 1 deletion axon/src/cli/format.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ use serde_json::Value;
pub fn render_peers_human(response: &Value) -> Option<String> {
let peers = response.get("peers")?.as_array()?;
if peers.is_empty() {
return Some("No peers found.".to_string());
return Some("No peers.".to_string());
}

let mut rows: Vec<[String; 5]> = Vec::with_capacity(peers.len());
Expand Down
15 changes: 14 additions & 1 deletion axon/src/cli/ipc_client.rs
Original file line number Diff line number Diff line change
Expand Up @@ -42,14 +42,25 @@ pub fn render_json(value: &Value) -> Result<String> {
}

pub async fn send_ipc(paths: &AxonPaths, command: Value) -> Result<Value> {
let line = serde_json::to_string(&command).context("failed to serialize IPC command")?;
if line.len() > axon::ipc::MAX_IPC_LINE_LENGTH {
anyhow::bail!(
"IPC command size ({} bytes) exceeds the 64KB limit",
line.len()
);
}

tracing::debug!(socket = %paths.socket.display(), "connecting to daemon IPC socket");

let mut stream = UnixStream::connect(&paths.socket).await.with_context(|| {
format!(
"failed to connect to daemon socket: {}. Is the daemon running?",
paths.socket.display()
)
})?;

let line = serde_json::to_string(&command).context("failed to serialize IPC command")?;
tracing::debug!(cmd_bytes = line.len(), "sending IPC command");

stream
.write_all(line.as_bytes())
.await
Expand Down Expand Up @@ -85,8 +96,10 @@ pub async fn send_ipc(paths: &AxonPaths, command: Value) -> Result<Value> {

let decoded: Value = serde_json::from_str(line).context("failed to decode IPC response")?;
if is_unsolicited_event(&decoded) {
tracing::debug!("skipping unsolicited IPC event");
continue;
}
tracing::debug!("received IPC command response");
return Ok(decoded);
}
}
Expand Down
22 changes: 21 additions & 1 deletion axon/src/cli/ipc_client_tests.rs
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
use std::path::PathBuf;
use std::process::ExitCode;

use serde_json::json;

use super::{ResponseMode, daemon_reply_exit_code, is_unsolicited_event};
use super::{ResponseMode, daemon_reply_exit_code, is_unsolicited_event, send_ipc};

#[test]
fn daemon_error_maps_to_exit_two() {
Expand Down Expand Up @@ -35,6 +36,25 @@ fn request_with_embedded_error_envelope_maps_to_exit_two() {
assert_eq!(code, ExitCode::from(2));
}

#[tokio::test]
async fn send_ipc_rejects_oversized_command() {
let paths = axon::config::AxonPaths {
root: PathBuf::from("/tmp/axon-test-nonexistent"),
socket: PathBuf::from("/tmp/axon-test-nonexistent/axon.sock"),
config: PathBuf::from("/tmp/axon-test-nonexistent/config.yaml"),
known_peers: PathBuf::from("/tmp/axon-test-nonexistent/known_peers.json"),
identity_key: PathBuf::from("/tmp/axon-test-nonexistent/identity.key"),
identity_pub: PathBuf::from("/tmp/axon-test-nonexistent/identity.pub"),
};

let big_payload = "x".repeat(70_000);
let command = json!({"cmd": "send", "to": "ed25519.00000000000000000000000000000000", "kind": "message", "payload": big_payload});
let err = send_ipc(&paths, command)
.await
.expect_err("should reject oversized command");
assert!(err.to_string().contains("exceeds"), "error: {err}");
}

#[test]
fn unsolicited_event_detection_uses_event_key() {
assert!(is_unsolicited_event(&json!({"event": "inbound"})));
Expand Down
2 changes: 1 addition & 1 deletion axon/src/doctor.rs
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,7 @@ pub async fn run(paths: &AxonPaths, args: &DoctorArgs) -> Result<DoctorReport> {
identity_check::check_identity(paths, args, &mut report)?;
checks::check_daemon_artifacts(paths, args, &mut report)?;
checks::check_known_peers(paths, args, &mut report).await?;
checks::check_config(paths, &mut report).await?;
checks::check_config(paths, args, &mut report).await?;

Ok(report)
}
42 changes: 34 additions & 8 deletions axon/src/doctor/checks.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,9 @@ use std::time::{SystemTime, UNIX_EPOCH};

use anyhow::{Context, Result, anyhow};

use axon::config::{AxonPaths, Config, load_known_peers, save_known_peers};
use axon::config::{
AxonPaths, Config, PersistedConfig, load_known_peers, save_known_peers, save_persisted_config,
};

use super::{DoctorArgs, DoctorReport};

Expand Down Expand Up @@ -303,7 +305,11 @@ pub(super) async fn check_known_peers(
Ok(())
}

pub(super) async fn check_config(paths: &AxonPaths, report: &mut DoctorReport) -> Result<()> {
pub(super) async fn check_config(
paths: &AxonPaths,
args: &DoctorArgs,
report: &mut DoctorReport,
) -> Result<()> {
if !paths.config.exists() {
report.add_check("config", true, false, "config.yaml not present".to_string());
return Ok(());
Expand All @@ -319,12 +325,32 @@ pub(super) async fn check_config(paths: &AxonPaths, report: &mut DoctorReport) -
);
}
Err(err) => {
report.add_check(
"config",
false,
false,
format!("config.yaml parse/load error: {err}"),
);
if args.fix {
let backup = backup_file_with_timestamp(&paths.config)?;
save_persisted_config(&paths.config, &PersistedConfig::default()).await?;
report.add_fix(
"config_reset",
format!(
"backed up corrupt config.yaml to {} and reset to defaults (peer enrollments lost — re-run `axon connect` to restore)",
backup.display()
),
);
report.add_check(
"config",
true,
true,
"corrupt config.yaml reset to defaults".to_string(),
);
} else {
report.add_check(
"config",
false,
true,
format!(
"config.yaml parse/load error: {err}; run `axon doctor --fix` to back up and reset"
),
);
}
}
}

Expand Down
3 changes: 2 additions & 1 deletion axon/src/ipc/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ mod protocol;
mod server;

pub use protocol::{
CommandEvent, DaemonReply, IpcCommand, IpcErrorCode, IpcSendKind, PeerSummary, WhoamiInfo,
CommandEvent, DaemonReply, IpcCommand, IpcErrorCode, IpcSendKind, MAX_IPC_LINE_LENGTH,
PeerSummary, WhoamiInfo,
};
pub use server::{IpcServer, IpcServerConfig};
3 changes: 3 additions & 0 deletions axon/src/ipc/protocol.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,9 @@ use uuid::Uuid;

use crate::message::{Envelope, MessageKind};

/// Maximum length of a single IPC command line (64 KB).
pub const MAX_IPC_LINE_LENGTH: usize = 64 * 1024;

// ---------------------------------------------------------------------------
// IPC protocol types
// ---------------------------------------------------------------------------
Expand Down
2 changes: 1 addition & 1 deletion axon/src/ipc/server.rs
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ use super::auth;
use super::protocol::{CommandEvent, DaemonReply, IpcCommand, IpcErrorCode, WhoamiInfo};
use crate::message::Envelope;

const MAX_IPC_LINE_LENGTH: usize = 64 * 1024; // 64 KB, aligned with MAX_MESSAGE_SIZE
use super::protocol::MAX_IPC_LINE_LENGTH;

static INVALID_COMMAND_LINE: LazyLock<Arc<str>> = LazyLock::new(|| {
let error = super::protocol::IpcErrorCode::InvalidCommand;
Expand Down
4 changes: 3 additions & 1 deletion axon/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -68,15 +68,17 @@ enum Commands {
value_parser = clap::value_parser!(u64).range(1..)
)]
timeout: u64,
/// Text payload (sent as {"message":"<TEXT>"} on the wire).
message: String,
},
/// Send a fire-and-forget message to another agent.
Notify {
#[arg(value_parser = parse_agent_id_arg)]
agent_id: String,
/// Parse payload as JSON (default sends literal text).
/// Parse payload as JSON (default sends literal text). Payload is sent as {"data": <value>}.
#[arg(long)]
json: bool,
/// Payload data (sent as {"data":"<TEXT>"}, or {"data":<JSON>} with --json).
data: String,
},
/// List discovered and connected peers.
Expand Down
21 changes: 21 additions & 0 deletions axon/tests/cli_contract_config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,11 @@ fn config_get_set_list_and_unset_roundtrip() {
.trim()
.is_empty()
);
let get_initial_stderr = String::from_utf8_lossy(&get_initial.stderr);
assert!(
get_initial_stderr.contains("name: not set"),
"stderr should indicate unset key, got: {get_initial_stderr}"
);

let set_name =
run_command(Command::new(&bin).args(["--state-root", root_str, "config", "name", "Alice"]));
Expand Down Expand Up @@ -141,6 +146,22 @@ fn config_set_invalid_port_fails() {
assert!(stderr.contains("invalid port"));
}

#[test]
fn config_set_port_zero_fails() {
let bin = axon_bin();
let root = tempdir().expect("tempdir");
let root_str = root.path().to_str().expect("utf8 path");

let output =
run_command(Command::new(&bin).args(["--state-root", root_str, "config", "port", "0"]));
assert_eq!(output.status.code(), Some(1));
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(
stderr.contains("port 0"),
"stderr should mention port 0, got: {stderr}"
);
}

#[test]
fn connect_writes_config_and_sends_add_peer_ipc() {
let bin = axon_bin();
Expand Down
Loading