From 1cdc2e8648b7c56a7ed45f46d9726fc3f85d605a Mon Sep 17 00:00:00 2001 From: Valentin Haudiquet Date: Mon, 19 Oct 2026 19:40:59 +0200 Subject: [PATCH] feat(claw): derive a blank watch credential from the matching account MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A repo watch row carries no account link: its only credentials were the token stored on the row and the forge's environment variable, so a watch added without either polled — and replied — anonymously. Against a private repository that is a 404 on every pass, and nothing ever lands: no dispatch, no reply, only a per-pass warning in the logs. The watch credential now resolves in three tiers: the row's token, then the env var, then the token-kind account identity of the same forge — instance-scoped on a self-hosted forge (the gitea watch of git.example borrows the gitea account saved with that base URL). A password-kind account is skipped, never borrowed. ForgeSource and the ClawHandler resolve through the same chain and key their client caches on the resolved value, so the reply authenticates as the identity the poll did — and a rotation or a later-saved account is a new client on the next pass, no restart. --- crates/rhema-claw/README.md | 4 +- crates/rhema-claw/src/daemon.rs | 3 +- crates/rhema-claw/src/forge.rs | 211 ++++++++++++++++++++++++++++--- crates/rhema-claw/src/handler.rs | 31 ++++- 4 files changed, 226 insertions(+), 23 deletions(-) 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.