Files
tireless/crates/tireless-agent/src/preflight.rs
rob thijssen c49b531bcd 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
2026-08-07 15:36:25 +03:00

139 lines
5.7 KiB
Rust

//! 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);
}
}