diff --git a/crates/rhema-claw/src/accounts_tools.rs b/crates/rhema-claw/src/accounts_tools.rs index 67f4146..f8c5e4e 100644 --- a/crates/rhema-claw/src/accounts_tools.rs +++ b/crates/rhema-claw/src/accounts_tools.rs @@ -35,8 +35,8 @@ use rhema_identity::{Account, AccountDraft, IdentityStore, Secret, SecretKind}; use serde::Deserialize; use serde_json::{Value, json}; -use crate::config::ForgeKind; use crate::watch_store::ClawStore; +use rhema_forge_integration::ForgeKind; /// `identity_save`: store an account (label, service, url, username, and /// the secret — required) and optionally register its forge watch. diff --git a/crates/rhema-claw/src/config.rs b/crates/rhema-claw/src/config.rs index b172ebf..78a16fa 100644 --- a/crates/rhema-claw/src/config.rs +++ b/crates/rhema-claw/src/config.rs @@ -1,14 +1,17 @@ -//! The claw config file: what to watch, where state lives, and which -//! model answers the dispatches. +//! The claw config file: where state lives, which model answers the +//! dispatches, and the credentials the surface answers to. //! -//! YAML at `~/.config/rhema/claw.yaml` (`--config` overrides). Paths and -//! credentials fail loudly at startup: a watch with no token, a gitea -//! watch with no host, or an unresolvable state directory never starts a -//! daemon that would poll half-configured. Forge tokens fall back to the -//! per-forge environment variable; the model backend's keys resolve -//! through `rhema-config` exactly as the TUI's do (env first, stored -//! second) and are validated in the binary's startup, not here — the -//! daemon library takes the built provider, so tests can inject a mock. +//! YAML at `~/.config/rhema/claw.yaml` (`--config` overrides). The +//! file carries only what boot needs; everything the daemon +//! reconfigures at runtime — watches, calendars — lives in its tables +//! behind the HTTP doors, validated where it enters. Unresolvable +//! state directories and colliding credentials fail loudly at startup; +//! a daemon that starts with nothing to guard still serves chat. +//! Forge tokens fall back to the per-forge environment variable; the +//! model backend's keys resolve through `rhema-config` exactly as the +//! TUI's do (env first, stored second) and are validated in the +//! binary's startup, not here — the daemon library takes the built +//! provider, so tests can inject a mock. use std::path::PathBuf; @@ -31,8 +34,8 @@ pub const DEFAULT_LISTEN: &str = "127.0.0.1:7799"; /// Why a config operation failed. #[derive(Debug, thiserror::Error)] pub enum ConfigError { - /// The config file does not exist. Claw has nothing to guess from: a - /// daemon without its watch list would silently watch nothing. + /// The config file does not exist. Claw has nothing to guess from: + /// paths and credentials are the file's job. #[error("no claw config at {0}; pass --config or create one")] Missing(PathBuf), /// Reading or writing the file failed. @@ -76,85 +79,6 @@ pub enum ConfigError { }, } -/// Which forge a watch polls. The wire spelling is the source name the -/// dispatcher registers and the resource key carries (`github:acme/w#7`). -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] -#[serde(rename_all = "lowercase")] -pub enum ForgeKind { - /// GitHub (api.github.com, or a GitHub Enterprise host). - GitHub, - /// Gitea — self-hosted; a watch must name its instance. - Gitea, - /// GitLab (gitlab.com, or a self-hosted instance). - GitLab, -} - -impl ForgeKind { - /// Every kind the claw ships a source for, in registration order. - /// A kind missing here has no watch poller, whatever the table - /// holds. - pub const ALL: [ForgeKind; 3] = [ForgeKind::GitHub, ForgeKind::Gitea, ForgeKind::GitLab]; - - /// The source name this kind registers and dispatches under. - pub fn name(self) -> &'static str { - match self { - ForgeKind::GitHub => "github", - ForgeKind::Gitea => "gitea", - ForgeKind::GitLab => "gitlab", - } - } - - /// The environment variable the token falls back to. - pub fn token_env(self) -> &'static str { - match self { - ForgeKind::GitHub => "GITHUB_TOKEN", - ForgeKind::Gitea => "GITEA_TOKEN", - ForgeKind::GitLab => "GITLAB_TOKEN", - } - } - - /// Parses a forge name as the config, the HTTP surface, and the - /// watch tools spell it: the source name (`github`, `gitea`, - /// `gitlab`). `None` for anything else. - pub fn parse(name: &str) -> Option { - match name.trim() { - "github" => Some(ForgeKind::GitHub), - "gitea" => Some(ForgeKind::Gitea), - "gitlab" => Some(ForgeKind::GitLab), - _ => None, - } - } - - /// The production base URL; `None` for the self-hosted-only forges. - pub fn default_base(self) -> Option<&'static str> { - match self { - ForgeKind::GitHub => Some(rhema_forge_integration::github::DEFAULT_BASE_URL), - ForgeKind::Gitea => None, - ForgeKind::GitLab => Some(rhema_forge_integration::gitlab::DEFAULT_BASE_URL), - } - } -} - -/// Pure token resolution: the config value first, then the forge's env -/// variable; the first non-blank wins. Parameterized over the env lookup -/// so tests can hold the environment still. -pub fn resolve_token( - get_env: impl Fn(&str) -> Result, - forge: ForgeKind, - stored: Option<&str>, -) -> Option { - stored - .map(str::trim) - .filter(|token| !token.is_empty()) - .map(str::to_owned) - .or_else(|| { - get_env(forge.token_env()) - .ok() - .map(|token| token.trim().to_owned()) - .filter(|token| !token.is_empty()) - }) -} - /// Whether a `host:port` listen address binds beyond loopback. /// `localhost` (any case) and any loopback IP literal — `127.x.x.x`, /// `::1` — are loopback; an empty host (`:7799`, the bind-everything @@ -388,11 +312,10 @@ impl ClawConfig { Ok(serde_yaml::from_str(&raw)?) } - /// The startup checks: every seeded watch resolvable (shape, host, - /// token). Loud, before anything polls. An empty list is fine now — - /// the watch table is seeded from it, and the agent manages its own - /// watches from there; a daemon that starts with nothing to guard - /// still serves chat. + /// The startup checks: the credential classes do not collide, and + /// the surface is open only where it is safe. Loud, before anything + /// listens. Watch and calendar validity is not checked here — those + /// are table rows, validated at their doors. pub fn validate(&self) -> Result<(), ConfigError> { // The agent token's safety is that it is scoped; making it the // human token silently unscopes everything the model's shell @@ -560,50 +483,6 @@ ingest_token: door-key assert_eq!(config.listen_addr(), DEFAULT_LISTEN); assert_eq!(config.token, None); } - #[test] - fn forge_names_parse_as_the_sources_spell_them() { - assert_eq!(ForgeKind::parse("github"), Some(ForgeKind::GitHub)); - assert_eq!(ForgeKind::parse(" gitea "), Some(ForgeKind::Gitea)); - assert_eq!(ForgeKind::parse("gitlab"), Some(ForgeKind::GitLab)); - assert_eq!(ForgeKind::parse("sourcehut"), None); - assert_eq!(ForgeKind::parse(""), None); - for kind in [ForgeKind::GitHub, ForgeKind::Gitea, ForgeKind::GitLab] { - assert_eq!(ForgeKind::parse(kind.name()), Some(kind), "{}", kind.name()); - } - } - - #[test] - fn the_config_token_wins_over_env_and_blank_counts_as_unset() { - let env = |name: &str| match name { - "GITHUB_TOKEN" => Ok("from-env".to_owned()), - "GITEA_TOKEN" => Ok(" ".to_owned()), - _ => Err(std::env::VarError::NotPresent), - }; - assert_eq!( - resolve_token(env, ForgeKind::GitHub, Some(" from-config ")), - Some("from-config".to_owned()), - "the config token wins" - ); - assert_eq!( - resolve_token(env, ForgeKind::GitHub, None), - Some("from-env".to_owned()), - "the env token serves when no config token" - ); - assert_eq!( - resolve_token(env, ForgeKind::Gitea, Some(" ")), - None, - "a blank env token counts as unset" - ); - assert_eq!( - resolve_token( - |_| Err(std::env::VarError::NotPresent), - ForgeKind::GitLab, - None - ), - None - ); - } - #[test] fn a_non_loopback_surface_demands_a_token() { let config = |listen: Option<&str>, token: Option<&str>| ClawConfig { diff --git a/crates/rhema-claw/src/extension.rs b/crates/rhema-claw/src/extension.rs index d9f4bc0..262931f 100644 --- a/crates/rhema-claw/src/extension.rs +++ b/crates/rhema-claw/src/extension.rs @@ -36,13 +36,13 @@ use rhema_autonomous_agent::{Allowlist, Source, ToolRegistry}; use rhema_core::ToolResolver; use rhema_rsd::{Dispatcher, ResourceKey, SystemClock}; -use crate::config::ForgeKind; use crate::consents::WaitBridge; use crate::daemon::ClawError; use crate::forge::ForgeSource; use crate::hub::EventHub; use rhema_calendar_integration::CalendarSource; use rhema_calendar_integration::CalendarStore; +use rhema_forge_integration::ForgeKind; /// The store handles the standard set is assembled over. One struct, /// so a new extension is a field plus a constructor line, not another diff --git a/crates/rhema-claw/src/forge.rs b/crates/rhema-claw/src/forge.rs index d1c7cc1..6117553 100644 --- a/crates/rhema-claw/src/forge.rs +++ b/crates/rhema-claw/src/forge.rs @@ -30,8 +30,8 @@ use rhema_forge_integration::{ use rhema_identity::{IdentityStore, SecretKind}; use rhema_rsd::{Clock, Dispatcher, Event, KeyFn, SystemClock}; -use crate::config::{ForgeKind, resolve_token}; use crate::watch_store::{AccountWatchRecord, ClawStore, WatchRecord}; +use rhema_forge_integration::{ForgeKind, resolve_token}; /// The cache key of a forge client: everything that changes the client /// is the forge kind, its host, and the token it authenticates with. diff --git a/crates/rhema-claw/src/handler.rs b/crates/rhema-claw/src/handler.rs index 999d0b1..2a3bdbd 100644 --- a/crates/rhema-claw/src/handler.rs +++ b/crates/rhema-claw/src/handler.rs @@ -23,13 +23,13 @@ use std::collections::HashMap; use std::path::{Path, PathBuf}; use std::sync::{Arc, Mutex, PoisonError}; -use crate::config::ForgeKind; use crate::forge::{record_client, resolve_watch_token}; use crate::watch_store::ClawStore; use rhema_agent_tools::AllowAll; use rhema_autonomous_agent::{Handler, SessionContext, async_trait}; use rhema_coding_agent::{CodingAgent, CodingAgentConfig}; use rhema_core::{Content, Event as ModelEvent, EventSink, Message, Session}; +use rhema_forge_integration::ForgeKind; use rhema_forge_integration::triage::Role; use rhema_forge_integration::{ EventKind, Forge, ForgeError, ForgeEvent, Notification, RepoRef, Snapshot, ThreadState, decode, diff --git a/crates/rhema-claw/src/http.rs b/crates/rhema-claw/src/http.rs index 867619d..2e47ac1 100644 --- a/crates/rhema-claw/src/http.rs +++ b/crates/rhema-claw/src/http.rs @@ -738,7 +738,7 @@ pub struct WatchBody { } async fn watches_add(State(state): State, Json(body): Json) -> Response { - let Some(kind) = crate::config::ForgeKind::parse(&body.forge) else { + let Some(kind) = rhema_forge_integration::ForgeKind::parse(&body.forge) else { return ( StatusCode::BAD_REQUEST, Json(json!({"error": "forge must be github, gitea, or gitlab"})), @@ -783,7 +783,7 @@ async fn watches_remove( State(state): State, Path((forge, repo)): Path<(String, String)>, ) -> Response { - let removed = match crate::config::ForgeKind::parse(&forge) { + let removed = match rhema_forge_integration::ForgeKind::parse(&forge) { Some(kind) => state .watches .remove(kind.name(), repo.trim()) @@ -1242,7 +1242,7 @@ fn is_reserved_source(source: &str) -> bool { matches!( source, crate::interactive::SOURCE | crate::task_source::TASKS_SOURCE - ) || crate::config::ForgeKind::parse(source).is_some() + ) || rhema_forge_integration::ForgeKind::parse(source).is_some() } /// The external event's dedup id: a hash of the request's coordinates diff --git a/crates/rhema-claw/src/lib.rs b/crates/rhema-claw/src/lib.rs index 9db2a8f..39b69f2 100644 --- a/crates/rhema-claw/src/lib.rs +++ b/crates/rhema-claw/src/lib.rs @@ -100,7 +100,7 @@ pub use bootstrap::{ BOOTSTRAP_SAVE, Bootstrap, BootstrapError, BootstrapKind, BootstrapStatus, DEFAULT_SYSTEM_MD, render_system, }; -pub use config::{ClawConfig, ConfigError, ForgeKind}; +pub use config::{ClawConfig, ConfigError}; pub use daemon::{ClawError, Daemon, TickReport, run_once}; pub use environment::write_report; pub use extension::{ClawExtension, ExtensionDeps, Extensions, Lanes}; @@ -112,6 +112,7 @@ pub use interactive::InteractiveHandler; #[cfg(feature = "mock")] pub use mock::MockLoop; pub use notifications::{NotificationRecord, NotificationStore, NotificationStoreError}; +pub use rhema_forge_integration::{ForgeKind, resolve_token}; pub use settings::SettingsStore; pub use task_source::{TASKS_SOURCE, TaskScheduler}; pub use task_store::{TaskKind, TaskRecord, TaskStore, TaskStoreError}; diff --git a/crates/rhema-claw/src/watch_store.rs b/crates/rhema-claw/src/watch_store.rs index 49dff72..8355823 100644 --- a/crates/rhema-claw/src/watch_store.rs +++ b/crates/rhema-claw/src/watch_store.rs @@ -22,7 +22,7 @@ use rhema_forge_integration::RepoRef; use rusqlite::params; use serde::Serialize; -use crate::config::{ForgeKind, resolve_token}; +use rhema_forge_integration::{ForgeKind, resolve_token}; /// One watched repository — the row's vocabulary, and the type the /// routes parse into. It used to live in the config as the YAML seed's diff --git a/crates/rhema-claw/src/watch_tools.rs b/crates/rhema-claw/src/watch_tools.rs index 02e39e2..19fc1b1 100644 --- a/crates/rhema-claw/src/watch_tools.rs +++ b/crates/rhema-claw/src/watch_tools.rs @@ -18,8 +18,8 @@ use rhema_autonomous_agent::{ToolOp, ToolRegistry, async_trait}; use serde::Deserialize; use serde_json::{Value, json}; -use crate::config::ForgeKind; use crate::watch_store::{ClawStore, validate_watch}; +use rhema_forge_integration::ForgeKind; /// The compiled operations behind the manifests, by `run.builtin` id. fn builtins(store: Arc) -> BTreeMap> { diff --git a/integrations/rhema-forge-integration/src/kind.rs b/integrations/rhema-forge-integration/src/kind.rs new file mode 100644 index 0000000..a6f30df --- /dev/null +++ b/integrations/rhema-forge-integration/src/kind.rs @@ -0,0 +1,128 @@ +//! The forge kinds: the vocabulary every forge surface shares — the +//! watch table's rows, the account store's `forge` column, the HTTP +//! routes' spelling, the source names the dispatcher registers, the +//! resource keys (`github:acme/w#7`). One type, owned by the crate +//! that speaks the forges; the claw's config no longer carries it. + +/// Which forge a watch polls. The wire spelling is the source name the +/// dispatcher registers and the resource key carries (`github:acme/w#7`). +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum ForgeKind { + /// GitHub (api.github.com, or a GitHub Enterprise host). + GitHub, + /// Gitea — self-hosted; a watch must name its instance. + Gitea, + /// GitLab (gitlab.com, or a self-hosted instance). + GitLab, +} + +impl ForgeKind { + /// Every kind the claw ships a source for, in registration order. + /// A kind missing here has no watch poller, whatever the table + /// holds. + pub const ALL: [ForgeKind; 3] = [ForgeKind::GitHub, ForgeKind::Gitea, ForgeKind::GitLab]; + + /// The source name this kind registers and dispatches under. + pub fn name(self) -> &'static str { + match self { + ForgeKind::GitHub => "github", + ForgeKind::Gitea => "gitea", + ForgeKind::GitLab => "gitlab", + } + } + + /// The environment variable the token falls back to. + pub fn token_env(self) -> &'static str { + match self { + ForgeKind::GitHub => "GITHUB_TOKEN", + ForgeKind::Gitea => "GITEA_TOKEN", + ForgeKind::GitLab => "GITLAB_TOKEN", + } + } + + /// Parses a forge name as the config, the HTTP surface, and the + /// watch tools spell it: the source name (`github`, `gitea`, + /// `gitlab`). `None` for anything else. + pub fn parse(name: &str) -> Option { + match name.trim() { + "github" => Some(ForgeKind::GitHub), + "gitea" => Some(ForgeKind::Gitea), + "gitlab" => Some(ForgeKind::GitLab), + _ => None, + } + } + + /// The production base URL; `None` for the self-hosted-only forges. + pub fn default_base(self) -> Option<&'static str> { + match self { + ForgeKind::GitHub => Some(crate::github::DEFAULT_BASE_URL), + ForgeKind::Gitea => None, + ForgeKind::GitLab => Some(crate::gitlab::DEFAULT_BASE_URL), + } + } +} + +/// Pure token resolution: the stored value first, then the forge's env +/// variable; the first non-blank wins. Parameterized over the env +/// lookup so tests can hold the environment still. +pub fn resolve_token( + get_env: impl Fn(&str) -> Result, + forge: ForgeKind, + stored: Option<&str>, +) -> Option { + stored + .map(str::trim) + .filter(|token| !token.is_empty()) + .map(str::to_owned) + .or_else(|| { + get_env(forge.token_env()) + .ok() + .map(|token| token.trim().to_owned()) + .filter(|token| !token.is_empty()) + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn forge_names_parse_as_the_sources_spell_them() { + assert_eq!(ForgeKind::parse("github"), Some(ForgeKind::GitHub)); + assert_eq!(ForgeKind::parse(" gitea "), Some(ForgeKind::Gitea)); + assert_eq!(ForgeKind::parse("gitlab"), Some(ForgeKind::GitLab)); + assert_eq!(ForgeKind::parse("sourcehut"), None); + assert_eq!(ForgeKind::parse(""), None); + for kind in ForgeKind::ALL { + assert_eq!(ForgeKind::parse(kind.name()), Some(kind), "{}", kind.name()); + } + } + + #[test] + fn the_token_falls_back_to_the_env_and_blank_counts_as_unset() { + let env = |name: &str| match name { + "GITHUB_TOKEN" => Ok("from-env".to_owned()), + _ => Err(std::env::VarError::NotPresent), + }; + assert_eq!( + resolve_token(env, ForgeKind::GitHub, Some(" from-config ")), + Some("from-config".to_owned()), + "the stored token wins" + ); + assert_eq!( + resolve_token(env, ForgeKind::GitHub, None), + Some("from-env".to_owned()), + "the env token serves when no stored token" + ); + assert_eq!( + resolve_token( + |_| Err(std::env::VarError::NotPresent), + ForgeKind::GitLab, + Some(" ") + ), + None, + "a blank token counts as unset" + ); + } +} diff --git a/integrations/rhema-forge-integration/src/lib.rs b/integrations/rhema-forge-integration/src/lib.rs index 31a1fa2..ac4612f 100644 --- a/integrations/rhema-forge-integration/src/lib.rs +++ b/integrations/rhema-forge-integration/src/lib.rs @@ -52,6 +52,8 @@ pub mod error; pub mod event; pub mod forge; pub mod gitea; +pub mod kind; +pub use kind::{ForgeKind, resolve_token}; pub mod github; pub mod gitlab; pub mod triage;