use crate::paths;
use crate::server::{self, AppState};
use anyhow::Context;
use clap::{Parser, Subcommand};
use harness_core::{Config, Runtime, Store};
use std::path::PathBuf;
use tracing::info;
#[derive(Parser)]
#[command(
name = "harnessd",
version,
about = "Self-hosted agentic harness daemon"
)]
pub struct Cli {
#[arg(long, global = true)]
config: Option<PathBuf>,
#[command(subcommand)]
command: Option<Command>,
}
#[derive(Subcommand)]
enum Command {
Serve,
Init,
Doctor,
Pair {
#[arg(long)]
endpoint: Option<String>,
},
#[command(subcommand)]
Devices(DevicesCmd),
#[command(subcommand)]
Trust(TrustCmd),
Update,
Version,
}
#[derive(Subcommand)]
enum TrustCmd {
List,
Revoke { tool: String, scope: String },
}
#[derive(Subcommand)]
enum DevicesCmd {
List,
Revoke { name: String },
}
impl Cli {
pub async fn run(self) -> anyhow::Result<()> {
let config_path = self
.config
.clone()
.unwrap_or_else(paths::default_config_path);
match self.command.unwrap_or(Command::Serve) {
Command::Serve => serve(config_path).await,
Command::Init => init(config_path),
Command::Doctor => doctor(config_path),
Command::Pair { endpoint } => pair(config_path, endpoint),
Command::Devices(cmd) => devices(config_path, cmd),
Command::Trust(cmd) => trust(config_path, cmd),
Command::Update => update(),
Command::Version => {
println!(
"harnessd/{} protocol {}",
env!("CARGO_PKG_VERSION"),
harness_proto::PROTOCOL_VERSION
);
Ok(())
}
}
}
}
fn build_runtime(config_path: &std::path::Path) -> anyhow::Result<(Config, Runtime)> {
let config = Config::load(config_path).context("loading config")?;
let db = paths::resolve_db_path(&config.server.db_path, config_path);
if let Some(parent) = db.parent() {
std::fs::create_dir_all(parent).ok();
}
let store = Store::open(&db).with_context(|| format!("opening db at {}", db.display()))?;
let state_dir = db
.parent()
.map(std::path::Path::to_path_buf)
.unwrap_or_else(|| std::path::PathBuf::from("."));
let runtime = Runtime::with_state_dir(store, config.clone(), &state_dir);
Ok((config, runtime))
}
async fn serve(config_path: PathBuf) -> anyhow::Result<()> {
let config = Config::load(&config_path).context("loading config")?;
let db = paths::resolve_db_path(&config.server.db_path, &config_path);
if let Some(parent) = db.parent() {
std::fs::create_dir_all(parent).ok();
}
let store = Store::open(&db).with_context(|| format!("opening db at {}", db.display()))?;
let state_dir = db
.parent()
.map(std::path::Path::to_path_buf)
.unwrap_or_else(|| std::path::PathBuf::from("."));
let runtime = Runtime::with_state_dir_and_services(store, config.clone(), &state_dir).await;
let bind = config.server.bind.clone();
let state = AppState::new(runtime);
let app = server::router(state);
let listener = tokio::net::TcpListener::bind(&bind)
.await
.with_context(|| format!("binding {bind}"))?;
info!(%bind, "harnessd listening (bind only the Tailscale/loopback iface in production)");
axum::serve(listener, app).await.context("server error")?;
Ok(())
}
fn init(config_path: PathBuf) -> anyhow::Result<()> {
if config_path.exists() {
println!(
"Config already exists at {}. Edit it or delete to re-init.",
config_path.display()
);
return Ok(());
}
if let Some(parent) = config_path.parent() {
std::fs::create_dir_all(parent)?;
}
std::fs::write(&config_path, STARTER_CONFIG)?;
println!("Wrote a starter config to {}", config_path.display());
println!("It ships with a zero-key `mock` provider so you can try the stack immediately.");
println!("To use Anthropic: set ANTHROPIC_API_KEY and enable the [providers] entry.");
println!("Then: harnessd serve");
Ok(())
}
fn doctor(config_path: PathBuf) -> anyhow::Result<()> {
println!("harnessd doctor\n");
match Config::load(&config_path) {
Ok(cfg) => {
println!("[ok] config loaded ({} provider(s))", cfg.providers.len());
let (_c, rt) = build_runtime(&config_path)?;
for (id, res) in rt.validate_providers() {
match res {
Ok(()) => println!("[ok] provider '{id}' ready"),
Err(e) => println!("[!!] provider '{id}': {e}"),
}
}
println!(
"[ok] {} tool(s), {} preset(s) loaded",
rt.tool_count(),
rt.preset_count()
);
for p in &cfg.providers {
use harness_core::config::ProviderKind;
let cmd = match p.kind {
ProviderKind::ClaudeCli => p.command.clone().unwrap_or_else(|| "claude".into()),
ProviderKind::CodexCli => p.command.clone().unwrap_or_else(|| "codex".into()),
_ => continue,
};
if harness_core::provider::cli::is_on_path(&cmd) {
println!(
"[ok] CLI backend '{}' found: `{cmd}` on PATH (ensure you've logged in)",
p.id
);
} else {
println!("[!!] CLI backend '{}' missing: `{cmd}` not on PATH", p.id);
}
}
if rt.workspace_writable() {
println!("[ok] workspace writable: {}", cfg.workspace.display());
} else {
println!("[!!] workspace NOT writable: {}", cfg.workspace.display());
}
match std::net::TcpListener::bind(&cfg.server.bind) {
Ok(l) => {
drop(l);
println!("[ok] bind address free: {}", cfg.server.bind);
}
Err(e) => println!("[!!] bind {} unavailable: {e}", cfg.server.bind),
}
}
Err(e) => println!("[!!] config: {e}"),
}
println!("\n(Tailscale + macOS TCC/permission probes are tracked in issue #8.)");
Ok(())
}
fn update() -> anyhow::Result<()> {
println!("harnessd {}", env!("CARGO_PKG_VERSION"));
println!("Self-update (signature-verified binary swap) ships with signed releases.");
println!("For now, update in place with:");
println!(" curl -fsSL http://100.113.110.34:3001/Muk/harness/raw/branch/main/scripts/install.sh | sh");
println!("or, from a clone: git pull && cargo build --release");
Ok(())
}
fn pair(config_path: PathBuf, endpoint: Option<String>) -> anyhow::Result<()> {
use time::format_description::well_known::Rfc3339;
let (config, runtime) = build_runtime(&config_path)?;
let code = crate::auth::generate_code();
let expires = (time::OffsetDateTime::now_utc() + time::Duration::minutes(5))
.format(&Rfc3339)
.unwrap_or_default();
runtime
.store()
.add_pairing_code(&crate::auth::hash(&code), &expires)?;
let endpoint = endpoint.unwrap_or_else(|| resolve_endpoint(&config.server.bind));
let payload = harness_proto::PairingPayload {
endpoint: endpoint.clone(),
code: code.clone(),
};
let json = serde_json::to_string(&payload)?;
match qrcode::QrCode::new(json.as_bytes()) {
Ok(qr) => {
let art = qr
.render::<qrcode::render::unicode::Dense1x2>()
.quiet_zone(true)
.build();
println!("\nScan this with the HarnessApp pairing screen:\n\n{art}\n");
}
Err(e) => println!("(could not render QR: {e})"),
}
println!("Endpoint: {endpoint}");
println!("Pairing code: {code} (valid 5 minutes)");
println!("\nManual fallback (no camera):");
println!(
" curl -X POST {}/v1/pair/complete -H 'content-type: application/json' \\",
endpoint
);
println!(" -d '{{\"code\":\"{code}\",\"device_name\":\"my-phone\"}}'");
println!("The response contains a device_token to store on the client.");
if !config.server.require_pairing {
println!("\nNote: require_pairing is false, so clients connect without a token today.");
println!("Set `require_pairing = true` under [server] to enforce device tokens.");
}
Ok(())
}
fn resolve_endpoint(bind: &str) -> String {
let (host, port) = bind.rsplit_once(':').unwrap_or((bind, "8787"));
let unreachable = matches!(host, "0.0.0.0" | "127.0.0.1" | "localhost" | "::" | "[::]");
if unreachable {
if let Some(ip) = tailscale_ip() {
return format!("http://{ip}:{port}");
}
eprintln!(
"warning: bind is {bind} (not reachable from another device) and no Tailscale IP \
was found. Pass --endpoint http://<reachable-ip>:{port}, or set `bind` to the \
Tailscale interface."
);
}
format!("http://{bind}")
}
fn tailscale_ip() -> Option<String> {
let out = std::process::Command::new("tailscale")
.args(["ip", "-4"])
.output()
.ok()?;
if !out.status.success() {
return None;
}
String::from_utf8(out.stdout)
.ok()?
.lines()
.next()
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
}
fn devices(config_path: PathBuf, cmd: DevicesCmd) -> anyhow::Result<()> {
let (_config, runtime) = build_runtime(&config_path)?;
match cmd {
DevicesCmd::List => {
let devs = runtime.store().list_devices()?;
if devs.is_empty() {
println!("No paired devices. Run `harnessd pair` to add one.");
} else {
println!("{:<24} paired_at", "NAME");
for (name, at) in devs {
println!("{name:<24} {at}");
}
}
Ok(())
}
DevicesCmd::Revoke { name } => {
let n = runtime.store().revoke_device(&name)?;
println!("revoked {n} device(s) named '{name}'");
Ok(())
}
}
}
fn trust(config_path: PathBuf, cmd: TrustCmd) -> anyhow::Result<()> {
let (_config, runtime) = build_runtime(&config_path)?;
match cmd {
TrustCmd::List => {
let grants = runtime.list_trust()?;
if grants.is_empty() {
println!("No trust grants. Approve a tool with \"always allow\" to add one.");
} else {
println!("{:<28} {:<24} {:<8} granted_by", "TOOL", "SCOPE", "CLASS");
for g in grants {
println!(
"{:<28} {:<24} {:<8} {}",
g.tool, g.scope, g.class, g.granted_by
);
}
}
Ok(())
}
TrustCmd::Revoke { tool, scope } => {
if runtime.revoke_trust(&tool, &scope)? {
println!("revoked trust: {tool} on {scope}");
} else {
println!("no grant found for {tool} on {scope}");
}
Ok(())
}
}
}
const STARTER_CONFIG: &str = r#"# harnessd configuration
# Secrets (API keys) are read from environment variables named below, never stored
# here. Production moves these into the OS keyring / an age file (design doc §8).
user_name = "you"
[server]
# Bind loopback by default. In production bind only the Tailscale interface.
bind = "127.0.0.1:8787"
db_path = "harness.db"
[roles]
main = "mock"
fast = "mock"
# Zero-key deterministic provider — great for trying the stack end to end.
[[providers]]
id = "mock"
kind = "mock"
model = "mock-1"
enabled = true
# Anthropic (native API-key backend). Set ANTHROPIC_API_KEY and flip enabled = true,
# then point roles.main = "anthropic".
[[providers]]
id = "anthropic"
kind = "anthropic"
model = "claude-opus-4-8"
api_key_env = "ANTHROPIC_API_KEY"
enabled = false
"#;