feat(claw): derive a blank watch credential from the matching account

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.
This commit is contained in:
vhaudiquet committed 2026-10-19 19:44:39 +02:00
1 parent 7c707e7a09
commit 1cdc2e8648
4 files changed
+226 -23

No files matched your search

+3 -1
View File
@@ -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 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 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 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 — command in the session workspace with the secret injected —
`ACCOUNT_SECRET` plus the service's own `GITHUB_TOKEN`/`GITEA_TOKEN`/ `ACCOUNT_SECRET` plus the service's own `GITHUB_TOKEN`/`GITEA_TOKEN`/
`GITLAB_TOKEN`, and a git credential helper — so `git clone` and API `GITLAB_TOKEN`, and a git credential helper — so `git clone` and API
+2 -1
View File
@@ -241,7 +241,8 @@ impl Daemon {
// The forge handler resolves its watches through the table at // The forge handler resolves its watches through the table at
// dispatch time, so a watch added mid-run is answerable without a // dispatch time, so a watch added mid-run is answerable without a
// restart. // 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 // Three drivers over one store admit from the same table, so
// each owns its slice of the key space: a forge pass must never // 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 // pick up a resident or job event (it would fail it, and
+194 -17
View File
@@ -12,7 +12,9 @@
//! (spec §1). Reading the tables every pass is what makes a watch added //! (spec §1). Reading the tables every pass is what makes a watch added
//! mid-flight (chat tool, HTTP route) take effect without a restart. //! mid-flight (chat tool, HTTP route) take effect without a restart.
//! Repo-watch clients are constructed on first use and cached per //! 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 //! 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. //! 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 /// The forge client one watch RECORD polls through: the kind's
/// production base unless the record names a `host`. A blank stored /// production base unless the record names a `host`. Callers pass the
/// token falls back to the forge's environment variable — the same /// credential already resolved through [`resolve_watch_token`] (the
/// resolution a config watch gets — and a blank everywhere constructs a /// account watches pass the identity store's secret directly), so a
/// client that polls anonymous; the poll fails loudly at the forge, not /// blank token here means nothing resolved anywhere: the client polls
/// silently here. /// anonymous, and the poll fails loudly at the forge, not silently.
pub fn record_client(kind: ForgeKind, host: Option<&str>, token: &str) -> Arc<dyn Forge> { pub fn record_client(kind: ForgeKind, host: Option<&str>, token: &str) -> Arc<dyn Forge> {
let resolved = resolve_token(|name| std::env::var(name), kind, Some(token)).unwrap_or_default(); let resolved = resolve_token(|name| std::env::var(name), kind, Some(token)).unwrap_or_default();
match kind { match kind {
@@ -70,6 +72,63 @@ pub fn record_client(kind: ForgeKind, host: Option<&str>, token: &str) -> Arc<dy
} }
} }
/// The credential a watch row polls (and replies) with: the row's
/// stored token first, then the forge's environment variable — the
/// same resolution a config watch gets — then, when both are absent,
/// the token-kind account identity of the same forge and host. A repo
/// watch is not linked to an account in the table, so the identity is
/// *derived*: the gitea watch of `git.example` borrows the gitea
/// account saved with that base URL. Nothing else changes a watch's
/// meaning: a watch with a stored token keeps it, a deployment that
/// sets the env var keeps that tier, and a watch that resolves to
/// nothing still constructs its client — anonymous — and fails the
/// poll loudly at the forge, where the fix (paste a token, save the
/// account) is visible. A matching account that holds a password is
/// skipped without a word: a password is not a poll credential, and
/// the per-pass alternative is a log line per watch per tick.
pub fn resolve_watch_token(
accounts: &dyn IdentityStore,
kind: ForgeKind,
host: Option<&str>,
stored: &str,
) -> Option<String> {
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<String> {
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 /// One forge kind's detectable input stream: every repo watch and every
/// account watch of that kind in the tables, polled into the dispatcher. /// account watch of that kind in the tables, polled into the dispatcher.
pub struct ForgeSource { pub struct ForgeSource {
@@ -117,24 +176,30 @@ impl ForgeSource {
/// The client for `record`: from the cache when a watch with the /// The client for `record`: from the cache when a watch with the
/// same `(forge, host, token)` polled before, constructed otherwise. /// same `(forge, host, token)` polled before, constructed otherwise.
fn client_for(&self, record: &WatchRecord) -> Arc<dyn Forge> { fn client_for(&self, record: &WatchRecord) -> Arc<dyn Forge> {
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 { let key = ClientKey {
forge: record.forge.clone(), forge: record.forge.clone(),
host: record.host.clone(), host: record.host.clone(),
token: record.token.clone(), token: resolved.clone(),
}; };
let mut clients = self.clients.lock().unwrap_or_else(PoisonError::into_inner); let mut clients = self.clients.lock().unwrap_or_else(PoisonError::into_inner);
clients clients
.entry(key) .entry(key)
.or_insert_with(|| { .or_insert_with(|| record_client(kind, record.host.as_deref(), &resolved))
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)
})
.clone() .clone()
} }
} }
@@ -353,6 +418,118 @@ mod tests {
(dispatcher, accounts, notifications) (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 /// The client cache pays off when two watches share their
/// coordinates: one client, not two. /// coordinates: one client, not two.
#[tokio::test] #[tokio::test]
+27 -4
View File
@@ -24,13 +24,14 @@ use std::path::{Path, PathBuf};
use std::sync::{Arc, Mutex, PoisonError}; use std::sync::{Arc, Mutex, PoisonError};
use crate::config::ForgeKind; use crate::config::ForgeKind;
use crate::forge::record_client; use crate::forge::{record_client, resolve_watch_token};
use crate::triage::Role; use crate::triage::Role;
use crate::watch_store::ClawStore; use crate::watch_store::ClawStore;
use rhema_agent_tools::AllowAll; use rhema_agent_tools::AllowAll;
use rhema_autonomous_agent::{Handler, SessionContext, async_trait}; use rhema_autonomous_agent::{Handler, SessionContext, async_trait};
use rhema_coding_agent::{CodingAgent, CodingAgentConfig}; use rhema_coding_agent::{CodingAgent, CodingAgentConfig};
use rhema_core::{Content, Event as ModelEvent, EventSink, Message, Session}; use rhema_core::{Content, Event as ModelEvent, EventSink, Message, Session};
use rhema_identity::IdentityStore;
use rhema_forge_integration::{ use rhema_forge_integration::{
EventKind, Forge, ForgeError, ForgeEvent, Notification, RepoRef, Snapshot, ThreadState, decode, EventKind, Forge, ForgeError, ForgeEvent, Notification, RepoRef, Snapshot, ThreadState, decode,
parse_key, parse_key,
@@ -58,6 +59,10 @@ pub struct ClawHandler {
backends: Arc<BackendRegistry>, backends: Arc<BackendRegistry>,
workspace_root: PathBuf, workspace_root: PathBuf,
watches: Arc<ClawStore>, watches: Arc<ClawStore>,
/// 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<dyn IdentityStore>,
clients: Mutex<HashMap<String, Arc<dyn Forge>>>, clients: Mutex<HashMap<String, Arc<dyn Forge>>>,
} }
@@ -68,11 +73,13 @@ impl ClawHandler {
backends: Arc<BackendRegistry>, backends: Arc<BackendRegistry>,
workspace_root: impl Into<PathBuf>, workspace_root: impl Into<PathBuf>,
watches: Arc<ClawStore>, watches: Arc<ClawStore>,
accounts: Arc<dyn IdentityStore>,
) -> Self { ) -> Self {
Self { Self {
backends, backends,
workspace_root: workspace_root.into(), workspace_root: workspace_root.into(),
watches, watches,
accounts,
clients: Mutex::new(HashMap::new()), clients: Mutex::new(HashMap::new()),
} }
} }
@@ -97,11 +104,22 @@ impl ClawHandler {
.ok_or_else(|| format!("no watch configured for {key}"))?; .ok_or_else(|| format!("no watch configured for {key}"))?;
let kind = ForgeKind::parse(&record.forge) let kind = ForgeKind::parse(&record.forge)
.ok_or_else(|| format!("watch entry {watch} names an unknown 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); let mut clients = self.clients.lock().unwrap_or_else(PoisonError::into_inner);
Ok(clients Ok(clients
.entry(cache_key) .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()) .clone())
} }
@@ -736,7 +754,12 @@ mod tests {
) )
.expect("constructs"), .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: // A watch added to the table AFTER the handler was built resolves:
// the table is the source of truth, not a boot-time snapshot. // the table is the source of truth, not a boot-time snapshot.