diff --git a/crates/rhema-claw/README.md b/crates/rhema-claw/README.md index 423dd47..44e0cf2 100644 --- a/crates/rhema-claw/README.md +++ b/crates/rhema-claw/README.md @@ -193,7 +193,9 @@ for a UI catching up after a reload) — and the human answers through The secret travels once, from the answer into the agent accounts; the tool wakes with the account id, never the secret, and a denial or a 15-minute silence comes back in-band so the turn can say so and move -on. Acting with a stored account is `identity_exec`: it runs one shell +on. A watch with no token of its own polls — and replies — as the +matching saved account: the same forge, and on a self-hosted forge the +same base URL. Acting with a stored account is `identity_exec`: it runs one shell command in the session workspace with the secret injected — `ACCOUNT_SECRET` plus the service's own `GITHUB_TOKEN`/`GITEA_TOKEN`/ `GITLAB_TOKEN`, and a git credential helper — so `git clone` and API diff --git a/crates/rhema-claw/src/daemon.rs b/crates/rhema-claw/src/daemon.rs index 6b53e2d..90f6b96 100644 --- a/crates/rhema-claw/src/daemon.rs +++ b/crates/rhema-claw/src/daemon.rs @@ -241,7 +241,8 @@ impl Daemon { // The forge handler resolves its watches through the table at // dispatch time, so a watch added mid-run is answerable without a // restart. - let forge_handler = ClawHandler::new(backends.clone(), workspace_root, watches.clone()); + let forge_handler = + ClawHandler::new(backends.clone(), workspace_root, watches.clone(), accounts.clone()); // Three drivers over one store admit from the same table, so // each owns its slice of the key space: a forge pass must never // pick up a resident or job event (it would fail it, and diff --git a/crates/rhema-claw/src/forge.rs b/crates/rhema-claw/src/forge.rs index fc8dcfb..a94f82e 100644 --- a/crates/rhema-claw/src/forge.rs +++ b/crates/rhema-claw/src/forge.rs @@ -12,7 +12,9 @@ //! (spec §1). Reading the tables every pass is what makes a watch added //! mid-flight (chat tool, HTTP route) take effect without a restart. //! Repo-watch clients are constructed on first use and cached per -//! `(forge, host, token)`; account clients are built fresh every pass, +//! `(forge, host, resolved token)` — resolved through +//! [`resolve_watch_token`], so a blank row borrows the matching +//! account identity; account clients are built fresh every pass, //! so a rotated identity secret is live on the next tick. Cadence //! belongs to the daemon's loop; the source is one pass, nothing more. @@ -41,11 +43,11 @@ struct ClientKey { } /// The forge client one watch RECORD polls through: the kind's -/// production base unless the record names a `host`. A blank stored -/// token falls back to the forge's environment variable — the same -/// resolution a config watch gets — and a blank everywhere constructs a -/// client that polls anonymous; the poll fails loudly at the forge, not -/// silently here. +/// production base unless the record names a `host`. Callers pass the +/// credential already resolved through [`resolve_watch_token`] (the +/// account watches pass the identity store's secret directly), so a +/// blank token here means nothing resolved anywhere: the client polls +/// anonymous, and the poll fails loudly at the forge, not silently. pub fn record_client(kind: ForgeKind, host: Option<&str>, token: &str) -> Arc { let resolved = resolve_token(|name| std::env::var(name), kind, Some(token)).unwrap_or_default(); match kind { @@ -70,6 +72,63 @@ pub fn record_client(kind: ForgeKind, host: Option<&str>, token: &str) -> Arc, + stored: &str, +) -> Option { + resolve_token(|name| std::env::var(name), kind, Some(stored)).or_else(|| { + accounts + .list() + .ok()? + .into_iter() + .filter(|account| account.service.eq_ignore_ascii_case(kind.name())) + .filter(|account| host_matches(host, account.url.as_deref())) + .find_map(|account| { + let secret = accounts.secret(&account.id).ok()??; + (secret.kind == SecretKind::Token).then_some(secret.value) + }) + }) +} + +/// Whether `account_url` names the same instance a watch with `host` +/// polls: a self-hosted watch matches only its own base URL (case and +/// trailing slash aside), and a production-base watch (no host) matches +/// an account that names no URL or the forge's own public host. +fn host_matches(host: Option<&str>, account_url: Option<&str>) -> bool { + let account_host = account_url.and_then(url_host); + // Both sides through the same strip: a watch host may carry a + // scheme (`https://git.example`), an account URL always does. + match (host.and_then(url_host), account_host) { + (None, None) => true, + (None, Some(account)) => matches!(account.as_str(), "github.com" | "gitlab.com"), + (Some(watch), account) => Some(watch) == account, + } +} + +/// The host of a service URL: scheme and path stripped, lowercased. +fn url_host(url: &str) -> Option { + let rest = url.split_once("://").map(|(_, rest)| rest).unwrap_or(url); + let host = rest.split('/').next().unwrap_or(""); + let host = host.trim(); + (!host.is_empty()).then(|| host.to_ascii_lowercase()) +} + /// One forge kind's detectable input stream: every repo watch and every /// account watch of that kind in the tables, polled into the dispatcher. pub struct ForgeSource { @@ -117,24 +176,30 @@ impl ForgeSource { /// The client for `record`: from the cache when a watch with the /// same `(forge, host, token)` polled before, constructed otherwise. fn client_for(&self, record: &WatchRecord) -> Arc { + let Some(kind) = ForgeKind::parse(&record.forge) else { + // Unreachable through the daemon's assembly, which only + // creates sources for known kinds; an unknown kind in the + // table would be skipped in `detect` before reaching here. + unreachable!("detect filters unknown forge kinds before client lookup") + }; + let resolved = resolve_watch_token( + self.accounts.as_ref(), + kind, + record.host.as_deref(), + &record.token, + ) + .unwrap_or_default(); + // The RESOLVED credential, not the row's: two blank-token + // watches whose identities differ must not share a client. let key = ClientKey { forge: record.forge.clone(), host: record.host.clone(), - token: record.token.clone(), + token: resolved.clone(), }; let mut clients = self.clients.lock().unwrap_or_else(PoisonError::into_inner); clients .entry(key) - .or_insert_with(|| { - let Some(kind) = ForgeKind::parse(&record.forge) else { - // Unreachable through the daemon's assembly, which - // only creates sources for known kinds; an unknown - // kind in the table would be skipped in `detect` - // before reaching here. - unreachable!("detect filters unknown forge kinds before client lookup") - }; - record_client(kind, record.host.as_deref(), &record.token) - }) + .or_insert_with(|| record_client(kind, record.host.as_deref(), &resolved)) .clone() } } @@ -353,6 +418,118 @@ mod tests { (dispatcher, accounts, notifications) } + /// A blank-token watch borrows the token-kind account identity of + /// the same forge and host — the derived-identity path a repo watch + /// takes when neither the row nor the environment holds a token. + #[test] + fn a_blank_token_watch_borrows_the_matching_accounts_token() { + let accounts = rhema_identity::VolatileIdentityStore::new(); + let account = rhema_identity::AccountDraft::new( + "forge bot", + "gitea", + rhema_identity::Secret::new(rhema_identity::SecretKind::Token, "acct-tok"), + ) + .with_url("https://git.example"); + accounts.put(account).expect("account lands"); + + let resolved = resolve_watch_token( + &accounts, + ForgeKind::Gitea, + Some("https://git.example"), + "", + ); + assert_eq!(resolved.as_deref(), Some("acct-tok")); + } + + /// The derived identity is instance-scoped: a self-hosted watch + /// never borrows an account saved against another instance, and a + /// production-base watch accepts only a bare or public-host URL. + #[test] + fn the_borrowed_identity_is_instance_scoped() { + let accounts = rhema_identity::VolatileIdentityStore::new(); + accounts + .put( + rhema_identity::AccountDraft::new( + "elsewhere", + "gitea", + rhema_identity::Secret::new(rhema_identity::SecretKind::Token, "nope"), + ) + .with_url("https://other.example"), + ) + .expect("account lands"); + assert_eq!( + resolve_watch_token(&accounts, ForgeKind::Gitea, Some("https://git.example"), ""), + None, + "another instance's account is not this watch's identity" + ); + + // Case and a trailing slash on the watch's host still match. + accounts + .put( + rhema_identity::AccountDraft::new( + "here", + "gitea", + rhema_identity::Secret::new(rhema_identity::SecretKind::Token, "yes"), + ) + .with_url("https://git.example"), + ) + .expect("account lands"); + // The same instance through case and a trailing slash matches. + assert_eq!( + resolve_watch_token(&accounts, ForgeKind::Gitea, Some("HTTPS://GIT.example/"), "") + .as_deref(), + Some("yes") + ); + } + + /// A password is not a poll credential: an otherwise-matching + /// password account is skipped, not borrowed. + #[test] + fn a_password_account_is_not_borrowed() { + let accounts = rhema_identity::VolatileIdentityStore::new(); + accounts + .put( + rhema_identity::AccountDraft::new( + "browser login", + "gitea", + rhema_identity::Secret::new(rhema_identity::SecretKind::Password, "hunter2"), + ) + .with_url("https://git.example"), + ) + .expect("account lands"); + assert_eq!( + resolve_watch_token(&accounts, ForgeKind::Gitea, Some("https://git.example"), ""), + None + ); + } + + /// The tiers in order: the row's stored token wins, then the env + /// var, then the derived account identity. + #[test] + fn the_stored_token_wins_over_the_derived_identity() { + let accounts = rhema_identity::VolatileIdentityStore::new(); + accounts + .put( + rhema_identity::AccountDraft::new( + "forge bot", + "gitea", + rhema_identity::Secret::new(rhema_identity::SecretKind::Token, "acct-tok"), + ) + .with_url("https://git.example"), + ) + .expect("account lands"); + assert_eq!( + resolve_watch_token( + &accounts, + ForgeKind::Gitea, + Some("https://git.example"), + " row-tok " + ) + .as_deref(), + Some("row-tok") + ); + } + /// The client cache pays off when two watches share their /// coordinates: one client, not two. #[tokio::test] diff --git a/crates/rhema-claw/src/handler.rs b/crates/rhema-claw/src/handler.rs index 2ffbc69..fafa8eb 100644 --- a/crates/rhema-claw/src/handler.rs +++ b/crates/rhema-claw/src/handler.rs @@ -24,13 +24,14 @@ use std::path::{Path, PathBuf}; use std::sync::{Arc, Mutex, PoisonError}; use crate::config::ForgeKind; -use crate::forge::record_client; +use crate::forge::{record_client, resolve_watch_token}; use crate::triage::Role; 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_identity::IdentityStore; use rhema_forge_integration::{ EventKind, Forge, ForgeError, ForgeEvent, Notification, RepoRef, Snapshot, ThreadState, decode, parse_key, @@ -58,6 +59,10 @@ pub struct ClawHandler { backends: Arc, workspace_root: PathBuf, watches: Arc, + /// The account store a blank watch credential resolves through: the + /// reply client must authenticate as the identity the poll did, or + /// the forge refuses the very post the poll was reading. + accounts: Arc, clients: Mutex>>, } @@ -68,11 +73,13 @@ impl ClawHandler { backends: Arc, workspace_root: impl Into, watches: Arc, + accounts: Arc, ) -> Self { Self { backends, workspace_root: workspace_root.into(), watches, + accounts, clients: Mutex::new(HashMap::new()), } } @@ -97,11 +104,22 @@ impl ClawHandler { .ok_or_else(|| format!("no watch configured for {key}"))?; let kind = ForgeKind::parse(&record.forge) .ok_or_else(|| format!("watch entry {watch} names an unknown forge"))?; - let cache_key = format!("{}|{:?}|{}", record.forge, record.host, record.token); + // The same chain the poll resolved: row token, env var, then + // the matching account identity — keyed on the resolved value, + // so a credential rotation (or a saved account) is a new client + // on the next dispatch. + let resolved = resolve_watch_token( + self.accounts.as_ref(), + kind, + record.host.as_deref(), + &record.token, + ) + .unwrap_or_default(); + let cache_key = format!("{}|{:?}|{}", record.forge, record.host, resolved); let mut clients = self.clients.lock().unwrap_or_else(PoisonError::into_inner); Ok(clients .entry(cache_key) - .or_insert_with(|| record_client(kind, record.host.as_deref(), &record.token)) + .or_insert_with(|| record_client(kind, record.host.as_deref(), &resolved)) .clone()) } @@ -736,7 +754,12 @@ mod tests { ) .expect("constructs"), ); - let handler = ClawHandler::new(registry, "/tmp/w", watches); + let handler = ClawHandler::new( + registry, + "/tmp/w", + watches, + Arc::new(rhema_identity::VolatileIdentityStore::new()), + ); // A watch added to the table AFTER the handler was built resolves: // the table is the source of truth, not a boot-time snapshot.