feat(config): load and validate configuration in every binary

The invariants in CLAUDE.md were tested functions nobody called. There was no
config type at all: --config was accepted and ignored by all three binaries,
figment was an unused dependency, and the 124-line config.toml.tmpl was
aspirational. assert_not_anthropic could not run at startup because nothing read
the provider it checks.

Add tireless_core::config, validated on construction so a Config in hand is a
checked one, and tireless_agent::preflight, called by the worker at startup and
by `tireless preflight` on demand — the same function, so the two cannot
disagree. Move the Anthropic guard to tireless_core::policy where validate() can
reach it; tireless_agent::opencode re-exports it so the documented path resolves.

Also make /v1/ready honest. It returned "ok" unconditionally while its own doc
comment promised dependency checks, so the deploy probe greened on a process
that could do nothing. It now reports per-dependency state, with unwired ones
saying not_implemented rather than ok.

Tests parse the shipped template rather than a fixture, so template and code
cannot drift apart silently.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013TxK1CWPkFXqdcXMJ4hVe6
This commit is contained in:
rob thijssen
2026-08-07 15:36:25 +03:00
parent 21c35ff2c3
commit c49b531bcd
11 changed files with 940 additions and 94 deletions

2
Cargo.lock generated
View File

@@ -2286,10 +2286,12 @@ version = "0.1.0"
dependencies = [
"async-trait",
"chrono",
"figment",
"serde",
"serde_json",
"thiserror",
"tireless-entities",
"toml",
"tracing",
"uuid",
]

View File

@@ -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;

View File

@@ -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());
}
}

View File

@@ -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<Report, Error> {
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);
}
}

View File

@@ -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<AppState>) -> (StatusCode, Json<Readiness>) {
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).

View File

@@ -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(())
}

View File

@@ -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 }

View File

@@ -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<PathBuf>,
pub plan_cc: Option<PathBuf>,
pub implement_oc: Option<PathBuf>,
pub implement_cc: Option<PathBuf>,
}
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<String>,
pub until: Option<String>,
}
impl Config {
/// Read and validate a config file, with `TIRELESS_`-prefixed environment
/// overrides layered on top.
pub fn load(path: impl AsRef<Path>) -> Result<Self, Error> {
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<Self, Error> {
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"));
}
}

View File

@@ -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};

View File

@@ -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());
}
}

View File

@@ -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"));