diff --git a/Cargo.lock b/Cargo.lock index 5d368c5..896c0ed 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2286,10 +2286,12 @@ version = "0.1.0" dependencies = [ "async-trait", "chrono", + "figment", "serde", "serde_json", "thiserror", "tireless-entities", + "toml", "tracing", "uuid", ] diff --git a/crates/tireless-agent/src/lib.rs b/crates/tireless-agent/src/lib.rs index 2bb1338..70c53f0 100644 --- a/crates/tireless-agent/src/lib.rs +++ b/crates/tireless-agent/src/lib.rs @@ -19,7 +19,9 @@ pub mod checkout; pub mod claude; pub mod opencode; +pub mod preflight; pub use checkout::Checkout; pub use claude::ClaudeCodeExecutor; pub use opencode::{OpencodeExecutor, assert_not_anthropic}; +pub use preflight::Report as PreflightReport; diff --git a/crates/tireless-agent/src/opencode.rs b/crates/tireless-agent/src/opencode.rs index ef02d80..dce7926 100644 --- a/crates/tireless-agent/src/opencode.rs +++ b/crates/tireless-agent/src/opencode.rs @@ -12,7 +12,11 @@ //! uses the first-party binary. [`assert_not_anthropic`] enforces this at //! startup so the rule survives a config edit. -use tireless_entities::Error; +/// The Anthropic guard now lives in [`tireless_core::policy`], where +/// `Config::validate` can call it. Re-exported here because this is where a +/// reader looking for it will come first, and because the constraint is about +/// this lane even though the enforcement is about configuration. +pub use tireless_core::policy::assert_not_anthropic; /// Pinned for the same reason as the Claude Code package. pub const OPENCODE_PACKAGE: &str = "opencode-ai@1.4.7"; @@ -28,74 +32,17 @@ pub struct OpencodeExecutor { pub timeout_seconds: u64, } -/// Provider identifiers that indicate an Anthropic backend. -const ANTHROPIC_MARKERS: &[&str] = &["anthropic", "claude"]; - -/// Refuse to start if the OpenCode lane is configured against Anthropic. -/// -/// Checked on both the provider id and the base URL host, because either can -/// carry the intent. This is a terms-of-service constraint expressed as code: -/// the comment explaining it can rot, a failing startup assertion cannot. -pub fn assert_not_anthropic(provider: &str, base_url: &str) -> Result<(), Error> { - let provider_lc = provider.to_ascii_lowercase(); - let url_lc = base_url.to_ascii_lowercase(); - - // Match provider ids on token boundaries so a self-hosted provider that - // merely *serves* a Claude-compatible surface is not caught by accident, - // while `anthropic`, `anthropic-oauth` and `claude-max` all are. - let provider_hits = ANTHROPIC_MARKERS.iter().any(|m| { - provider_lc - .split(|c: char| !c.is_ascii_alphanumeric()) - .any(|part| part == *m) - }); - - // The URL check is host-based: `api.anthropic.com` is disqualifying wherever - // it appears, but a local gateway is not. - let url_hits = url_lc.contains("anthropic.com"); - - if provider_hits || url_hits { - return Err(Error::AnthropicViaOpencode(provider.to_string())); - } - Ok(()) -} - #[cfg(test)] mod tests { use super::*; #[test] - fn the_house_helexa_backend_is_accepted() { - assert!(assert_not_anthropic("lair-helexa", "http://hanzalova.internal:31313/v1").is_ok()); - assert!( - assert_not_anthropic("lmstudio", "http://beast.hanzalova.internal:1234/v1").is_ok() - ); - } - - #[test] - fn anthropic_providers_are_refused() { - for provider in [ - "anthropic", - "Anthropic", - "anthropic-oauth", - "claude", - "claude-max", - ] { - assert!( - assert_not_anthropic(provider, "http://localhost/v1").is_err(), - "{provider} should be refused" - ); - } - } - - #[test] - fn the_anthropic_api_host_is_refused_whatever_the_provider_is_called() { - assert!(assert_not_anthropic("totally-fine", "https://api.anthropic.com/v1").is_err()); - } - - #[test] - fn a_local_gateway_serving_an_anthropic_compatible_surface_is_fine() { - // cortex presents both OpenAI and Anthropic compatible APIs, but it is - // local inference — no Anthropic subscription is involved. + fn the_lane_guard_is_reachable_from_here() { + // The implementation and its cases are tested in `tireless_core::policy`. + // This asserts only that the documented path still resolves, so a reader + // following `doc/plan/design.md` §3.3 or CLAUDE.md invariant 4 lands + // somewhere real. assert!(assert_not_anthropic("lair-helexa", "http://hanzalova.internal:31313/v1").is_ok()); + assert!(assert_not_anthropic("anthropic", "http://localhost/v1").is_err()); } } diff --git a/crates/tireless-agent/src/preflight.rs b/crates/tireless-agent/src/preflight.rs new file mode 100644 index 0000000..c970419 --- /dev/null +++ b/crates/tireless-agent/src/preflight.rs @@ -0,0 +1,138 @@ +//! Everything checked before a service is allowed to start. +//! +//! One function, called by `tireless-worker` at startup and by `tireless +//! preflight` on demand, so that what an operator can verify by hand is exactly +//! what the service verifies for itself. A check that only one of them runs is a +//! check that will disagree with the other at the worst moment. +//! +//! This module is why the constraints in `CLAUDE.md` are true rather than +//! merely written down. Each one below names the invariant it enforces. + +use std::path::PathBuf; + +use tireless_core::{Config, PromptSet}; +use tireless_entities::{BillingMode, Error}; + +/// What preflight found. Everything here is either fatal — in which case +/// [`run`] returned `Err` and this does not exist — or something the operator +/// should be able to read off the journal's first lines. +#[derive(Debug, Clone)] +pub struct Report { + /// Which billing mode a Claude Code run will use, derived from the + /// environment tireless is about to hand over. + pub billing: BillingMode, + /// Service account home, where Claude Code keeps its credentials. + pub home: PathBuf, + /// Whether a credential store exists. Not fatal for the poller, which spends + /// nothing; fatal for the runner, which cannot work without it. + pub claude_credentials: bool, + /// OpenCode lane target, already asserted non-Anthropic by `Config::validate`. + pub oc_provider: String, + pub oc_model: String, + /// True when the prompt set came from files on disk rather than the binary. + pub prompts_overridden: bool, +} + +impl Report { + /// Write the report to the journal. Called once, at startup, so that "what + /// was this service actually configured to do" is answerable from logs + /// alone rather than by reading a config file on a host. + pub fn log(&self) { + match self.billing { + // Deliberately a warning: it means the subscription this whole + // architecture exists to use is sitting unused, which is far more + // likely to be an accident than a decision. + BillingMode::ApiKey => tracing::warn!( + "ANTHROPIC_API_KEY is set: Claude Code runs will bill \ + pay-as-you-go, not the subscription" + ), + mode => tracing::info!(?mode, home = %self.home.display(), "Claude Code billing mode"), + } + tracing::info!( + provider = %self.oc_provider, + model = %self.oc_model, + "OpenCode lane target (asserted non-Anthropic)" + ); + if self.prompts_overridden { + tracing::warn!( + "system prompts are overridden from disk; the running behaviour is \ + not the behaviour of this build" + ); + } + if !self.claude_credentials { + tracing::warn!( + home = %self.home.display(), + "no Claude Code credential store; the runner will refuse to start" + ); + } + } +} + +/// Load and check everything, or refuse. +/// +/// Enforces, in order: +/// +/// 1. **Invariant 4** — the OpenCode lane is not pointed at Anthropic. +/// [`Config::validate`] calls +/// [`tireless_core::policy::assert_not_anthropic`], so this cannot be +/// bypassed by any caller holding a `Config`. +/// 2. **Invariant 8** — the prompt set is internally consistent, including any +/// file overrides. +/// 3. **Invariants 2 and 3** — the credential store is *stat'd, never read*, and +/// `ANTHROPIC_API_KEY` is only ever observed, never set. +pub fn run(config_path: &str) -> Result { + let config = Config::load(config_path)?; + + let prompts = PromptSet::resolve(&config.prompt)?; + // Touch the loaded set so a future refactor cannot quietly stop using it. + debug_assert!(!prompts.plan_cc.is_empty()); + + // Invariant 3: tireless *observes* this variable and never sets it. Its + // presence selects pay-as-you-go; its absence selects the subscription. That + // choice belongs to whoever wrote the unit environment file. + let has_key = std::env::var_os("ANTHROPIC_API_KEY").is_some(); + + let home = std::env::var_os("HOME") + .map(PathBuf::from) + .unwrap_or_else(|| config.work.root.clone()); + + Ok(Report { + billing: crate::claude::expected_billing(has_key), + // Invariant 2: this stats the file. Its contents are Claude Code's + // business, and tireless has no reason ever to open it. + claude_credentials: crate::claude::has_credentials(&home) || has_key, + home, + oc_provider: config.lane.oc.provider.clone(), + oc_model: config.lane.oc.model.clone(), + prompts_overridden: config.prompt.any(), + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn a_missing_config_is_a_refusal_not_a_default() { + // Starting on built-in defaults when the operator's config is absent + // would mean a service running against settings nobody chose — including + // a database and a forge it was never pointed at. + assert!(run("/nonexistent/tireless/config.toml").is_err()); + } + + #[test] + fn an_api_key_counts_as_credentials_without_a_login() { + // Pay-as-you-go is a supported mode (design.md §3.2), so a runner with a + // key but no interactive login must be allowed to start. The reverse of + // this test is what the runner's own bail covers. + let report = Report { + billing: BillingMode::ApiKey, + home: PathBuf::from("/nonexistent"), + claude_credentials: true, + oc_provider: "lair-helexa".into(), + oc_model: "Qwen/Qwen3.6-27B".into(), + prompts_overridden: false, + }; + assert!(report.claude_credentials); + } +} diff --git a/crates/tireless-api/src/main.rs b/crates/tireless-api/src/main.rs index 1491d53..8efd6d5 100644 --- a/crates/tireless-api/src/main.rs +++ b/crates/tireless-api/src/main.rs @@ -6,8 +6,9 @@ use std::net::SocketAddr; use anyhow::Context; -use axum::{Router, routing::get}; +use axum::{Json, Router, extract::State, http::StatusCode, routing::get}; use clap::Parser; +use serde::Serialize; /// Registered in `architecture/port-allocations.md`. const DEFAULT_PORT: u16 = 23296; @@ -27,9 +28,17 @@ async fn main() -> anyhow::Result<()> { init_tracing(); let args = Args::parse(); + // Validated here so a bad config fails the deploy at service start, next to + // the journal that explains why, rather than on the first request. + let config = tireless_core::Config::load(&args.config) + .with_context(|| format!("invalid configuration at {}", args.config))?; + let app = Router::new() .route("/v1/health", get(health)) - .route("/v1/ready", get(ready)); + .route("/v1/ready", get(ready)) + .with_state(AppState { + database_configured: !config.database.host.is_empty(), + }); let addr: SocketAddr = args.bind.parse().context("invalid bind address")?; let listener = tokio::net::TcpListener::bind(addr) @@ -47,15 +56,68 @@ async fn main() -> anyhow::Result<()> { Ok(()) } +#[derive(Clone)] +struct AppState { + database_configured: bool, +} + +/// Liveness: the process is up and serving. Nothing more is claimed. async fn health() -> &'static str { "ok" } -/// Readiness is distinct from liveness: it reports whether dependencies (the -/// database, the forge) are reachable, so a deploy health-probe fails loudly -/// rather than greening on a process that cannot do any work. -async fn ready() -> &'static str { - "ok" +/// Readiness is distinct from liveness: it reports whether the dependencies this +/// build actually has are usable, so a deploy probe fails loudly rather than +/// greening on a process that cannot do any work. +/// +/// Each dependency reports its own state, and unimplemented ones say +/// `not_implemented` rather than `ok`. A probe that returns a blanket "ok" +/// before anything is wired teaches an operator to trust a signal that is not +/// yet measuring anything — and the day it starts measuring is the day it stops +/// being believed. +async fn ready(State(state): State) -> (StatusCode, Json) { + let checks = Readiness { + // Loading it was a precondition of starting, so reaching here proves it. + config: Check::Ok, + database: if state.database_configured { + // Stage 1 replaces this with a real round-trip against the pool. + Check::NotImplemented + } else { + Check::Failed + }, + forge: Check::NotImplemented, + }; + let code = if checks.serving() { + StatusCode::OK + } else { + StatusCode::SERVICE_UNAVAILABLE + }; + (code, Json(checks)) +} + +#[derive(Serialize)] +struct Readiness { + config: Check, + database: Check, + forge: Check, +} + +impl Readiness { + /// Whether the API can serve. A `NotImplemented` dependency does not block + /// readiness — the endpoints that need it do not exist yet either — but it + /// is reported so nobody mistakes silence for health. + fn serving(&self) -> bool { + !matches!(self.config, Check::Failed) && !matches!(self.database, Check::Failed) + } +} + +#[derive(Serialize)] +#[serde(rename_all = "snake_case")] +enum Check { + Ok, + Failed, + /// Wired in a later stage. See doc/plan/design.md §7. + NotImplemented, } /// JSON logs under systemd, pretty logs on a TTY (§12 Observability). diff --git a/crates/tireless-cli/src/main.rs b/crates/tireless-cli/src/main.rs index 8a31f9c..58885aa 100644 --- a/crates/tireless-cli/src/main.rs +++ b/crates/tireless-cli/src/main.rs @@ -86,8 +86,66 @@ enum JobAction { fn main() -> anyhow::Result<()> { tracing_subscriber::fmt().with_env_filter("info").init(); let args = Args::parse(); - // Subcommand handlers land alongside the stages that give them something to - // talk to; see doc/plan/design.md §7. - println!("{:?} (config: {})", args.command, args.config); + + match args.command { + Command::Preflight => preflight(&args.config), + // Remaining handlers land alongside the stages that give them something + // to talk to; see doc/plan/design.md §7. Until then, say so rather than + // printing something that looks like output. + other => { + anyhow::bail!( + "`{}` is not implemented yet — see doc/plan/design.md §7 for which \ + stage lands it. `tireless preflight` works today.", + subcommand_name(&other) + ) + } + } +} + +fn subcommand_name(c: &Command) -> &'static str { + match c { + Command::Repo { .. } => "repo", + Command::Job { .. } => "job", + Command::Lanes => "lanes", + Command::Preflight => "preflight", + } +} + +/// Verify configuration and credentials without starting a service. +/// +/// Runs exactly the checks `tireless-worker` runs at startup, by calling the +/// same function — so "it passes preflight but the service will not start" is +/// not a state that can exist. +fn preflight(config: &str) -> anyhow::Result<()> { + let report = tireless_agent::preflight::run(config)?; + + println!("config {config}"); + println!("claude billing {:?}", report.billing); + println!("claude home {}", report.home.display()); + println!( + "claude login {}", + if report.claude_credentials { + "present" + } else { + "MISSING — the runner will refuse to start" + } + ); + println!( + "opencode lane {} / {} (asserted non-Anthropic)", + report.oc_provider, report.oc_model + ); + println!( + "system prompts {}", + if report.prompts_overridden { + "overridden from disk" + } else { + "built in" + } + ); + + if !report.claude_credentials { + anyhow::bail!("preflight failed: no Claude Code credentials and no API key"); + } + println!("\npreflight ok"); Ok(()) } diff --git a/crates/tireless-core/Cargo.toml b/crates/tireless-core/Cargo.toml index 348eca7..87dabb1 100644 --- a/crates/tireless-core/Cargo.toml +++ b/crates/tireless-core/Cargo.toml @@ -12,7 +12,9 @@ tireless-entities = { workspace = true } async-trait = { workspace = true } chrono = { workspace = true } +figment = { workspace = true } serde = { workspace = true } +toml = { workspace = true } serde_json = { workspace = true } thiserror = { workspace = true } tracing = { workspace = true } diff --git a/crates/tireless-core/src/config.rs b/crates/tireless-core/src/config.rs new file mode 100644 index 0000000..21f4d87 --- /dev/null +++ b/crates/tireless-core/src/config.rs @@ -0,0 +1,547 @@ +//! The configuration every binary reads, and the checks it must pass. +//! +//! One type, loaded identically by `tireless-api`, `tireless-worker` and +//! `tireless-cli`, because a constraint enforced by whichever binary remembered +//! to call it is not enforced. [`Config::load`] validates before returning, so +//! there is no way to hold a `Config` that has not been checked. +//! +//! ## What is here and what is not +//! +//! Values that decide *behaviour* live here and are reviewable in +//! `/etc/tireless/config.toml`. **Secrets do not.** Tokens are named by the +//! environment variable that carries them ([`ForgeGitea::token_env`]) rather +//! than being written down, and `ANTHROPIC_API_KEY` appears nowhere in this file +//! at all: tireless never sets it, and its presence in the unit environment is +//! the operator's way of selecting pay-as-you-go over the subscription (see +//! `doc/plan/design.md` §3.2). + +use std::path::{Path, PathBuf}; + +use chrono::Duration; +use serde::{Deserialize, Serialize}; +use tireless_entities::{AgentKind, Error, LabelProtocol}; + +use crate::budget::LaneBudget; +use crate::policy::assert_not_anthropic; + +/// The whole of tireless's configuration. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct Config { + #[serde(default)] + pub api: Api, + pub database: Database, + pub forge: Forge, + #[serde(default)] + pub poll: Poll, + #[serde(default)] + pub labels: LabelProtocol, + #[serde(default)] + pub prompt: PromptOverrides, + #[serde(default)] + pub work: Work, + #[serde(default)] + pub discover: Discover, + pub lane: Lanes, + #[serde(default)] + pub quiet: Quiet, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct Api { + /// Loopback by default; nginx fronts it. See `asset/nginx/` for which host + /// that nginx runs on and what it implies for this value. + pub bind: String, +} + +impl Default for Api { + fn default() -> Self { + Self { + bind: "127.0.0.1:23296".into(), + } + } +} + +/// mTLS, passwordless (`architecture/generic.md` §5). There is deliberately no +/// password field; if one ever seems necessary, that is a signal to revisit the +/// ident mapping instead. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct Database { + pub host: String, + pub port: u16, + pub database: String, + pub user: String, + pub client_cert: PathBuf, + pub client_key: PathBuf, + pub root_cert: PathBuf, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct Forge { + pub gitea: ForgeGitea, + #[serde(default)] + pub github: ForgeGitHub, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct ForgeGitea { + pub base_url: String, + /// Name of the environment variable holding the bot account's token. The + /// token itself is never written to config — it comes from + /// `/etc/tireless/tireless.env`, 0640 root:tireless. + pub token_env: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct ForgeGitHub { + pub enabled: bool, + pub token_env: String, +} + +impl Default for ForgeGitHub { + fn default() -> Self { + Self { + enabled: false, + token_env: "GITHUB_TOKEN".into(), + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct Poll { + /// Floor on per-repo poll interval. A repo may ask for a longer interval but + /// not a shorter one — being unattended is not a licence to hammer a forge. + pub min_interval_seconds: u32, + pub default_interval_seconds: u32, + /// Jitter added per repo so N repos do not all fire together. + pub jitter_seconds: u32, +} + +impl Default for Poll { + fn default() -> Self { + Self { + min_interval_seconds: 120, + default_interval_seconds: 300, + jitter_seconds: 30, + } + } +} + +impl Poll { + /// Apply the floor to a requested interval. + pub fn clamp(&self, requested: u32) -> u32 { + requested.max(self.min_interval_seconds) + } +} + +/// Paths to prompt files that override the ones compiled into the binary. +/// +/// Useful for iterating on plan quality without a redeploy. An override must +/// declare the same `contract-version:` as the build, and the whole set is +/// loaded together — see [`crate::prompt::PromptSet`]. +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct PromptOverrides { + pub discover_cc: Option, + pub plan_cc: Option, + pub implement_oc: Option, + pub implement_cc: Option, +} + +impl PromptOverrides { + pub fn any(&self) -> bool { + self.discover_cc.is_some() + || self.plan_cc.is_some() + || self.implement_oc.is_some() + || self.implement_cc.is_some() + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct Work { + /// Per-repo bare mirrors and per-job clones live here. + pub root: PathBuf, + /// Keep a failed job's clone this long so it can be inspected. Successful + /// jobs are cleaned immediately. + pub failed_retention_hours: u32, +} + +impl Default for Work { + fn default() -> Self { + Self { + root: PathBuf::from("/var/lib/tireless"), + failed_retention_hours: 72, + } + } +} + +/// Cadence for the discovery lane. +/// +/// Discovery is anchored to a tracking issue (`doc/plan/design.md` §2.6), so +/// unlike planning and implementation it is *recurring* against the same issue. +/// The cooldown is what stops it re-enqueueing on every poll: a survey that ran +/// this morning has nothing new to say this afternoon, and asking it to look +/// again costs a run from the most expensive lane in the system. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct Discover { + pub cooldown_hours: u32, + /// Upper bound on proposals a single survey may open. A survey that wants to + /// file forty issues has misunderstood the job, and the operator should find + /// that out from a truncated list plus a note, not from their inbox. + pub max_proposals_per_run: u32, +} + +impl Default for Discover { + fn default() -> Self { + Self { + cooldown_hours: 168, // weekly + max_proposals_per_run: 8, + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct Lanes { + pub cc: LaneCc, + pub oc: LaneOc, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct LaneCc { + pub max_concurrent: u32, + pub max_runs_per_window: u32, + pub window_hours: u32, + pub timeout_seconds: u64, + pub model: String, + pub failure_threshold: u32, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct LaneOc { + /// OpenCode provider id. Never an Anthropic one — see + /// [`crate::policy::assert_not_anthropic`]. + pub provider: String, + pub base_url: String, + /// A model *name*, never a capability alias. An alias that starts resolving + /// elsewhere would change how tireless implements plans between one job and + /// the next, with no deploy and no signal. + pub model: String, + pub surface: OcSurface, + pub max_concurrent: u32, + pub max_runs_per_window: u32, + pub window_hours: u32, + pub timeout_seconds: u64, + pub failure_threshold: u32, +} + +/// Which helexa API surface the OpenCode lane talks to. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum OcSurface { + /// Preferred. `/no_think` is honoured here. + ChatCompletions, + /// `/v1/responses`. `/no_think` is ignored, and a small output budget can be + /// spent entirely on the reasoning block, yielding `""` with + /// `status: "incomplete"` (helexa#223). + Responses, +} + +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct Quiet { + /// Local time `HH:MM`. Both ends required if either is set. + pub from: Option, + pub until: Option, +} + +impl Config { + /// Read and validate a config file, with `TIRELESS_`-prefixed environment + /// overrides layered on top. + pub fn load(path: impl AsRef) -> Result { + use figment::{ + Figment, + providers::{Env, Format, Toml}, + }; + + let path = path.as_ref(); + let config: Self = Figment::new() + .merge(Toml::file(path)) + .merge(Env::prefixed("TIRELESS_").split("__")) + .extract() + .map_err(|e| Error::Config(format!("{}: {e}", path.display())))?; + + config.validate()?; + Ok(config) + } + + /// Parse from a string without touching the filesystem. For tests, and for + /// anything that wants to check a candidate config before installing it. + pub fn from_toml_str(s: &str) -> Result { + use figment::{ + Figment, + providers::{Format, Toml}, + }; + + let config: Self = Figment::new() + .merge(Toml::string(s)) + .extract() + .map_err(|e| Error::Config(e.to_string()))?; + config.validate()?; + Ok(config) + } + + /// Every check a configuration must pass before tireless will act on it. + /// + /// Called by both constructors, so a `Config` in hand is a validated one. + /// Failures here are startup failures by design: an unattended service that + /// starts "successfully" and then cannot do anything, or does the wrong + /// thing quietly, is worse than one that refuses to start. + pub fn validate(&self) -> Result<(), Error> { + // Terms of service. Not negotiable, and checked before anything else so + // a config that breaches it cannot be partially accepted. + assert_not_anthropic(&self.lane.oc.provider, &self.lane.oc.base_url)?; + + if self.poll.min_interval_seconds == 0 { + return Err(Error::Config( + "poll.min_interval_seconds must be greater than zero; a floor of \ + zero is not a floor" + .into(), + )); + } + if self.poll.default_interval_seconds < self.poll.min_interval_seconds { + return Err(Error::Config(format!( + "poll.default_interval_seconds ({}) is below poll.min_interval_seconds \ + ({}), so the default would be silently clamped on every repo", + self.poll.default_interval_seconds, self.poll.min_interval_seconds + ))); + } + + for (lane, concurrent, window_hours, threshold) in [ + ( + "cc", + self.lane.cc.max_concurrent, + self.lane.cc.window_hours, + self.lane.cc.failure_threshold, + ), + ( + "oc", + self.lane.oc.max_concurrent, + self.lane.oc.window_hours, + self.lane.oc.failure_threshold, + ), + ] { + if concurrent == 0 { + return Err(Error::Config(format!( + "lane.{lane}.max_concurrent is zero, which stops the lane \ + silently; disable it deliberately instead" + ))); + } + if window_hours == 0 { + return Err(Error::Config(format!( + "lane.{lane}.window_hours is zero, so the run budget would \ + never apply" + ))); + } + if threshold == 0 { + return Err(Error::Config(format!( + "lane.{lane}.failure_threshold is zero, which trips the \ + circuit breaker before the first run" + ))); + } + } + + // A half-specified quiet window is almost certainly a typo, and the + // failure it produces — running around the clock when the operator + // believed otherwise — is invisible until the bill arrives. + match (&self.quiet.from, &self.quiet.until) { + (Some(_), None) | (None, Some(_)) => { + return Err(Error::Config( + "quiet window needs both `from` and `until`, or neither".into(), + )); + } + _ => {} + } + + if self.discover.max_proposals_per_run == 0 { + return Err(Error::Config( + "discover.max_proposals_per_run is zero, so every survey would \ + discard its own output" + .into(), + )); + } + + Ok(()) + } + + /// The governor budget for a lane, as configured. + pub fn lane_budget(&self, lane: AgentKind) -> LaneBudget { + match lane { + AgentKind::ClaudeCode => LaneBudget { + lane, + max_concurrent: self.lane.cc.max_concurrent, + max_runs_per_window: self.lane.cc.max_runs_per_window, + window: Duration::hours(self.lane.cc.window_hours as i64), + }, + AgentKind::Opencode => LaneBudget { + lane, + max_concurrent: self.lane.oc.max_concurrent, + max_runs_per_window: self.lane.oc.max_runs_per_window, + window: Duration::hours(self.lane.oc.window_hours as i64), + }, + } + } + + pub fn failure_threshold(&self, lane: AgentKind) -> u32 { + match lane { + AgentKind::ClaudeCode => self.lane.cc.failure_threshold, + AgentKind::Opencode => self.lane.oc.failure_threshold, + } + } + + /// libpq-style connection URL. Certs rotate every 24h (`generic.md` §11), so + /// callers must be able to re-establish connections rather than assume a + /// stable pool. + pub fn database_url(&self) -> String { + let d = &self.database; + format!( + "postgres://{user}@{host}:{port}/{db}\ + ?sslmode=verify-full\ + &sslcert={cert}&sslkey={key}&sslrootcert={root}", + user = d.user, + host = d.host, + port = d.port, + db = d.database, + cert = d.client_cert.display(), + key = d.client_key.display(), + root = d.root_cert.display(), + ) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// The shipped template, with the deploy workflow's placeholder substituted. + /// Using the real template rather than a fixture means a field added to one + /// and not the other fails this test. + fn shipped_template() -> String { + include_str!("../../../asset/config/config.toml.tmpl") + .replace("{{DEPLOY_HOST_FQDN}}", "bob.hanzalova.internal") + } + + #[test] + fn the_shipped_template_is_a_valid_config() { + // This is the file the deploy workflow renders and every unit reads. If + // it does not parse, the first anyone knows is three services failing to + // start on a host, with the previous binaries already replaced. + Config::from_toml_str(&shipped_template()).expect("shipped template must load"); + } + + #[test] + fn the_template_matches_the_conservative_defaults() { + // The template is documentation as much as configuration. If it drifts + // from what the code considers sane, it teaches the wrong thing. + let c = Config::from_toml_str(&shipped_template()).expect("load"); + let cc = c.lane_budget(AgentKind::ClaudeCode); + let expected = LaneBudget::conservative(AgentKind::ClaudeCode); + assert_eq!(cc.max_concurrent, expected.max_concurrent); + assert_eq!(cc.max_runs_per_window, expected.max_runs_per_window); + assert_eq!(cc.window, expected.window); + } + + #[test] + fn a_config_pointing_opencode_at_anthropic_is_refused() { + // Invariant 4. The whole point of putting this in validate() is that no + // binary can forget to call it. + let bad = + shipped_template().replace(r#"provider = "lair-helexa""#, r#"provider = "anthropic""#); + let err = Config::from_toml_str(&bad).expect_err("must be refused"); + assert!(matches!(err, Error::AnthropicViaOpencode(_)), "got {err:?}"); + } + + #[test] + fn a_config_pointing_opencode_at_the_anthropic_api_host_is_refused() { + let bad = shipped_template().replace( + r#"base_url = "http://hanzalova.internal:31313/v1""#, + r#"base_url = "https://api.anthropic.com/v1""#, + ); + assert!(Config::from_toml_str(&bad).is_err()); + } + + #[test] + fn an_unknown_key_is_refused_rather_than_ignored() { + // `deny_unknown_fields` throughout: a typo in a key an operator believed + // was taking effect is a silent misconfiguration, which is the failure + // mode this whole module exists to prevent. + let bad = format!("{}\nnonsense_key = true\n", shipped_template()); + assert!(Config::from_toml_str(&bad).is_err()); + } + + #[test] + fn a_half_specified_quiet_window_is_refused() { + let bad = format!("{}\n[quiet]\nfrom = \"23:00\"\n", shipped_template()); + assert!(Config::from_toml_str(&bad).is_err()); + } + + #[test] + fn a_default_interval_below_the_floor_is_refused() { + let bad = shipped_template().replace( + "default_interval_seconds = 300", + "default_interval_seconds = 30", + ); + assert!(Config::from_toml_str(&bad).is_err()); + } + + #[test] + fn zeroed_lane_limits_are_refused() { + for (from, to) in [ + ("max_concurrent = 1", "max_concurrent = 0"), + ("failure_threshold = 3", "failure_threshold = 0"), + ("window_hours = 5", "window_hours = 0"), + ] { + let bad = shipped_template().replacen(from, to, 1); + assert!( + Config::from_toml_str(&bad).is_err(), + "{to} should be refused" + ); + } + } + + #[test] + fn the_poll_floor_is_applied_upward_only() { + let p = Poll::default(); + assert_eq!(p.clamp(30), p.min_interval_seconds); + assert_eq!(p.clamp(3600), 3600); + } + + #[test] + fn the_database_url_asks_for_full_certificate_verification() { + // sslmode below verify-full would accept a certificate for the wrong + // host, which defeats the point of ident-mapped mTLS. + let c = Config::from_toml_str(&shipped_template()).expect("load"); + assert!(c.database_url().contains("sslmode=verify-full")); + } + + #[test] + fn no_anthropic_api_key_field_exists_anywhere_in_config() { + // Invariant 3: the variable reaches Claude Code only if an operator put + // it in the unit environment. A config field for it would make that a + // tireless decision, which it must never be. + let rendered = toml::to_string(&Config::from_toml_str(&shipped_template()).expect("load")) + .expect("serialize"); + assert!(!rendered.to_lowercase().contains("anthropic_api_key")); + assert!(!rendered.to_lowercase().contains("api_key")); + } +} diff --git a/crates/tireless-core/src/lib.rs b/crates/tireless-core/src/lib.rs index 2c0e465..de4b16c 100644 --- a/crates/tireless-core/src/lib.rs +++ b/crates/tireless-core/src/lib.rs @@ -5,12 +5,16 @@ //! `tireless-agent` (process orchestration). pub mod budget; +pub mod config; pub mod plan; +pub mod policy; pub mod port; pub mod prompt; pub mod routing; pub use budget::{Governor, LaneBudget, LimitSignal, Verdict}; +pub use config::Config; pub use plan::{PlanDefect, implementation_order, validate}; +pub use policy::assert_not_anthropic; pub use prompt::{PromptSet, SYSTEM_PROMPT_CONTRACT_VERSION}; pub use routing::{RouteDecision, route}; diff --git a/crates/tireless-core/src/policy.rs b/crates/tireless-core/src/policy.rs new file mode 100644 index 0000000..f5d47a8 --- /dev/null +++ b/crates/tireless-core/src/policy.rs @@ -0,0 +1,89 @@ +//! Constraints that are not ours to relax. +//! +//! Everything in this module encodes a term of service or a safety property +//! rather than a design preference. They live in core, next to the config that +//! carries the values they check, so that there is exactly one place a +//! configuration is admitted — see [`crate::config::Config::validate`]. +//! +//! They were previously enforced by whichever binary remembered to call them, +//! which is a way of saying they were not enforced. + +use tireless_entities::Error; + +/// Provider identifiers that indicate an Anthropic backend. +const ANTHROPIC_MARKERS: &[&str] = &["anthropic", "claude"]; + +/// Refuse a configuration that points the OpenCode lane at Anthropic. +/// +/// OpenCode is a third-party harness with its own provider clients. Driving an +/// Anthropic *subscription* through one is the pattern Anthropic blocked in +/// January 2026, and OpenCode was named explicitly. Anthropic work goes through +/// the Claude Code lane, which spawns the first-party binary and lets it +/// authenticate itself. +/// +/// Checked on both the provider id and the base URL, because either can carry +/// the intent. This is a terms-of-service constraint expressed as code: the +/// comment explaining it can rot, a failing startup assertion cannot. +pub fn assert_not_anthropic(provider: &str, base_url: &str) -> Result<(), Error> { + let provider_lc = provider.to_ascii_lowercase(); + let url_lc = base_url.to_ascii_lowercase(); + + // Match provider ids on token boundaries so a self-hosted provider that + // merely *serves* a Claude-compatible surface is not caught by accident, + // while `anthropic`, `anthropic-oauth` and `claude-max` all are. + let provider_hits = ANTHROPIC_MARKERS.iter().any(|m| { + provider_lc + .split(|c: char| !c.is_ascii_alphanumeric()) + .any(|part| part == *m) + }); + + // The URL check is host-based: `api.anthropic.com` is disqualifying wherever + // it appears, but a local gateway is not. + let url_hits = url_lc.contains("anthropic.com"); + + if provider_hits || url_hits { + return Err(Error::AnthropicViaOpencode(provider.to_string())); + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn the_house_helexa_backend_is_accepted() { + assert!(assert_not_anthropic("lair-helexa", "http://hanzalova.internal:31313/v1").is_ok()); + assert!( + assert_not_anthropic("lmstudio", "http://beast.hanzalova.internal:1234/v1").is_ok() + ); + } + + #[test] + fn anthropic_providers_are_refused() { + for provider in [ + "anthropic", + "Anthropic", + "anthropic-oauth", + "claude", + "claude-max", + ] { + assert!( + assert_not_anthropic(provider, "http://localhost/v1").is_err(), + "{provider} should be refused" + ); + } + } + + #[test] + fn the_anthropic_api_host_is_refused_whatever_the_provider_is_called() { + assert!(assert_not_anthropic("totally-fine", "https://api.anthropic.com/v1").is_err()); + } + + #[test] + fn a_local_gateway_serving_an_anthropic_compatible_surface_is_fine() { + // cortex presents both OpenAI and Anthropic compatible APIs, but it is + // local inference — no Anthropic subscription is involved. + assert!(assert_not_anthropic("lair-helexa", "http://hanzalova.internal:31313/v1").is_ok()); + } +} diff --git a/crates/tireless-worker/src/main.rs b/crates/tireless-worker/src/main.rs index 75dd20e..9208341 100644 --- a/crates/tireless-worker/src/main.rs +++ b/crates/tireless-worker/src/main.rs @@ -43,7 +43,9 @@ async fn main() -> anyhow::Result<()> { // Fail loudly at startup, not on the first job: an unattended service that // starts "successfully" and then cannot do anything is worse than one that // refuses to start. - preflight().context("preflight checks failed")?; + let report = tireless_agent::preflight::run(&args.config) + .context("preflight checks failed; refusing to start")?; + report.log(); match args.role { Role::Poll => { @@ -53,6 +55,18 @@ async fn main() -> anyhow::Result<()> { let id = id.unwrap_or_else(|| { std::env::var("HOSTNAME").unwrap_or_else(|_| "tireless-runner".into()) }); + // The runner is the half that spends tokens, so it is the half that + // must have a working agent login. The poller does not need one and + // is not held back by its absence. + if !report.claude_credentials { + anyhow::bail!( + "no Claude Code credential store at {}/.claude.json, and no \ + ANTHROPIC_API_KEY in the environment. Complete the interactive \ + login as the service account (script/infra-setup.sh step 1) \ + before starting the runner.", + report.home.display() + ); + } tracing::info!(config = %args.config, worker = %id, "runner starting"); } } @@ -62,25 +76,6 @@ async fn main() -> anyhow::Result<()> { Ok(()) } -/// Startup assertions that encode constraints we cannot afford to rediscover at -/// runtime. See doc/plan/design.md §3. -fn preflight() -> anyhow::Result<()> { - // Report which billing mode Claude Code will use, so it is visible in the - // journal from the first line rather than inferred from an invoice later. - let has_key = std::env::var_os("ANTHROPIC_API_KEY").is_some(); - match tireless_agent::claude::expected_billing(has_key) { - tireless_entities::BillingMode::ApiKey => tracing::warn!( - "ANTHROPIC_API_KEY is set: Claude Code runs will bill pay-as-you-go, \ - not the subscription" - ), - mode => tracing::info!(?mode, "Claude Code billing mode"), - } - - // The OpenCode lane must never be pointed at Anthropic. Read from config in - // stage 5; asserted here so the guard exists from the first commit. - Ok(()) -} - fn init_tracing() { use tracing_subscriber::{EnvFilter, fmt}; let filter = EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("info"));