feat(claw): move the identity store tool specs into TOOL.yaml manifests

identity_save, identity_list, and identity_forget load their specs
from embedded manifests under crates/rhema-claw/tools; the Rust side
keeps only the invoke half. identity_list declares replay_safety:
safe - the store answers with its own redacted records - while save
and forget stay unsafe: a resumed run must not re-store an account or
drop one twice. identity_exec keeps its spec inline; it is a
ToolResolver, the exec seam itself, not a registry tool.
This commit is contained in:
vhaudiquet committed 2026-10-20 21:19:54 +02:00
1 parent da483daadd
commit 2747018b8f
4 files changed
+162 -70

No files matched your search

+96 -70
View File
@@ -16,13 +16,20 @@
//! prompt is text a stranger wrote — never resolves them, so an injected //! prompt is text a stranger wrote — never resolves them, so an injected
//! comment cannot ask its way to the accounts or act with their //! comment cannot ask its way to the accounts or act with their
//! credentials. //! credentials.
//!
//! The three store tools' specs live in `TOOL.yaml`s (one folder under
//! `tools/`); the code here is the operation half only. identity_exec
//! is its own [`ToolResolver`] and carries its spec inline — the one
//! deliberate exception, since its registry shape is the exec seam
//! itself.
use std::collections::BTreeMap;
use std::path::PathBuf; use std::path::PathBuf;
use std::process::Stdio; use std::process::Stdio;
use std::sync::Arc; use std::sync::Arc;
use std::time::Duration; use std::time::Duration;
use rhema_autonomous_agent::{Tool, ToolRegistry, async_trait}; use rhema_autonomous_agent::{ToolOp, ToolRegistry, async_trait};
use rhema_core::{ToolCall, ToolContext, ToolResolver, ToolResult, ToolSpec}; use rhema_core::{ToolCall, ToolContext, ToolResolver, ToolResult, ToolSpec};
use rhema_identity::{Account, AccountDraft, IdentityStore, Secret, SecretKind}; use rhema_identity::{Account, AccountDraft, IdentityStore, Secret, SecretKind};
use serde::Deserialize; use serde::Deserialize;
@@ -158,39 +165,7 @@ pub fn forget_account(
} }
#[async_trait] #[async_trait]
impl Tool for IdentitySave { impl ToolOp for IdentitySave {
fn spec(&self) -> ToolSpec {
ToolSpec::new(
"identity_save",
"Store an account the daemon holds on a service: a label, the service's name (e.g. \
'github'), optionally its URL and the username, and the secret (an API token, or a \
password). Act with a stored account through identity_exec — the credential \
itself is never shown to you. With watch=true and a forge the daemon watches, \
the account's notifications are followed.",
json!({
"type": "object",
"properties": {
"label": { "type": "string",
"description": "Display name, e.g. 'acme releases'." },
"service": { "type": "string",
"description": "The service, lowercase: github, gitlab, gitea, or any name." },
"url": { "type": "string",
"description": "Base URL, for self-hosted instances." },
"username": { "type": "string",
"description": "The login name on the service." },
"secret": { "type": "string",
"description": "The credential to store. Never echoed back." },
"secret_kind": { "type": "string",
"enum": ["token", "password"],
"description": "What the secret is; default token." },
"watch": { "type": "boolean",
"description": "Follow this forge identity's notifications; default false." }
},
"required": ["label", "service", "secret"]
}),
)
}
async fn invoke(&self, args: Value) -> Result<String, String> { async fn invoke(&self, args: Value) -> Result<String, String> {
let args: SaveArgs = let args: SaveArgs =
serde_json::from_value(args).map_err(|err| format!("bad arguments: {err}"))?; serde_json::from_value(args).map_err(|err| format!("bad arguments: {err}"))?;
@@ -228,16 +203,7 @@ struct IdentityList {
} }
#[async_trait] #[async_trait]
impl Tool for IdentityList { impl ToolOp for IdentityList {
fn spec(&self) -> ToolSpec {
ToolSpec::new(
"identity_list",
"List the accounts the daemon holds. Secrets are never shown — only each account's \
label, service, url, username, and what kind of secret it keeps.",
json!({ "type": "object", "properties": {} }),
)
}
async fn invoke(&self, _args: Value) -> Result<String, String> { async fn invoke(&self, _args: Value) -> Result<String, String> {
let accounts = self.accounts.list().map_err(|err| err.to_string())?; let accounts = self.accounts.list().map_err(|err| err.to_string())?;
Ok(json!({ "accounts": accounts }).to_string()) Ok(json!({ "accounts": accounts }).to_string())
@@ -258,22 +224,7 @@ struct ForgetArgs {
} }
#[async_trait] #[async_trait]
impl Tool for IdentityForget { impl ToolOp for IdentityForget {
fn spec(&self) -> ToolSpec {
ToolSpec::new(
"identity_forget",
"Forget a stored account by id: the secret goes with it, and any forge watch that \
followed this identity is removed too.",
json!({
"type": "object",
"properties": {
"id": { "type": "string", "description": "The account id (identity_list's ids)." }
},
"required": ["id"]
}),
)
}
async fn invoke(&self, args: Value) -> Result<String, String> { async fn invoke(&self, args: Value) -> Result<String, String> {
let args: ForgetArgs = let args: ForgetArgs =
serde_json::from_value(args).map_err(|err| format!("bad arguments: {err}"))?; serde_json::from_value(args).map_err(|err| format!("bad arguments: {err}"))?;
@@ -287,17 +238,63 @@ impl Tool for IdentityForget {
} }
} }
/// The identity tool set over the account store and the watch table. /// The compiled operations behind the manifests, by `run.builtin` id.
fn builtins(
accounts: Arc<dyn IdentityStore>,
watches: Arc<ClawStore>,
) -> BTreeMap<String, Arc<dyn ToolOp>> {
BTreeMap::from([
(
"identity.forget".to_owned(),
Arc::new(IdentityForget {
accounts: accounts.clone(),
watches: watches.clone(),
}) as Arc<dyn ToolOp>,
),
(
"identity.list".to_owned(),
Arc::new(IdentityList {
accounts: accounts.clone(),
}) as Arc<dyn ToolOp>,
),
(
"identity.save".to_owned(),
Arc::new(IdentitySave { accounts, watches }) as Arc<dyn ToolOp>,
),
])
}
/// The store tools' `TOOL.yaml`s, embedded so the daemon ships them
/// with no filesystem layout: one folder per tool, named by tool, in
/// the order the registry serves them.
pub const MANIFESTS: &[(&str, &str)] = &[
(
"identity_forget",
include_str!("../tools/identity_forget/TOOL.yaml"),
),
(
"identity_list",
include_str!("../tools/identity_list/TOOL.yaml"),
),
(
"identity_save",
include_str!("../tools/identity_save/TOOL.yaml"),
),
];
/// The identity tool set over the account store and the watch table —
/// specs from the embedded manifests, operations compiled here.
pub fn registry(accounts: Arc<dyn IdentityStore>, watches: Arc<ClawStore>) -> ToolRegistry { pub fn registry(accounts: Arc<dyn IdentityStore>, watches: Arc<ClawStore>) -> ToolRegistry {
let (tools, skipped) =
rhema_autonomous_agent::manifest::builtin_tools(MANIFESTS, &builtins(accounts, watches));
assert!(
skipped.is_empty(),
"the identity tool manifests must all load: {skipped:?}"
);
let mut registry = ToolRegistry::new(); let mut registry = ToolRegistry::new();
registry.register(Arc::new(IdentitySave { for tool in tools {
accounts: accounts.clone(), registry.register(tool);
watches: watches.clone(), }
}));
registry.register(Arc::new(IdentityList {
accounts: accounts.clone(),
}));
registry.register(Arc::new(IdentityForget { accounts, watches }));
registry registry
} }
@@ -587,6 +584,7 @@ impl ToolResolver for IdentityExec {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
use rhema_autonomous_agent::ToolOp;
fn parts() -> (Arc<dyn IdentityStore>, Arc<ClawStore>) { fn parts() -> (Arc<dyn IdentityStore>, Arc<ClawStore>) {
( (
@@ -595,10 +593,38 @@ mod tests {
) )
} }
fn call(tool: &dyn Tool, args: Value) -> Result<String, String> { fn call(tool: &dyn ToolOp, args: Value) -> Result<String, String> {
futures_block_on(tool.invoke(args)) futures_block_on(tool.invoke(args))
} }
#[test]
fn every_manifest_loads_against_its_builtin_and_serves_the_registry() {
let (accounts, watches) = parts();
let (tools, skipped) = rhema_autonomous_agent::manifest::builtin_tools(
MANIFESTS,
&builtins(accounts, watches),
);
assert!(skipped.is_empty(), "{skipped:?}");
assert_eq!(tools.len(), 3);
let (accounts, watches) = parts();
let resolver = registry(accounts, watches)
.resolver_for(&rhema_autonomous_agent::Allowlist::permissive());
assert_eq!(
resolver.replay_safety("identity_list"),
rhema_core::ReplaySafety::Safe
);
assert_eq!(
resolver.replay_safety("identity_save"),
rhema_core::ReplaySafety::Unsafe,
"re-running a save on resume would re-store and re-watch"
);
assert_eq!(
resolver.replay_safety("identity_forget"),
rhema_core::ReplaySafety::Unsafe,
"re-running a forget on resume would drop the account twice-over"
);
}
/// The tools are async over a sync store: one block-on harness for /// The tools are async over a sync store: one block-on harness for
/// the tests. /// the tests.
fn futures_block_on<F: std::future::Future>(future: F) -> F::Output { fn futures_block_on<F: std::future::Future>(future: F) -> F::Output {
@@ -0,0 +1,15 @@
# The model-facing spec of the identity_forget tool; the operation is
# the compiled builtin named under `run`.
name: identity_forget
description: >-
Forget a stored account by id: the secret goes with it, and any
forge watch that followed this identity is removed too.
parameters:
type: object
properties:
id:
type: string
description: The account id (identity_list's ids).
required: [id]
run:
builtin: identity.forget
@@ -0,0 +1,13 @@
# The model-facing spec of the identity_list tool; the operation is the
# compiled builtin named under `run`.
name: identity_list
description: >-
List the accounts the daemon holds. Secrets are never shown — only
each account's label, service, url, username, and what kind of
secret it keeps.
replay_safety: safe
parameters:
type: object
properties: {}
run:
builtin: identity.list
@@ -0,0 +1,38 @@
# The model-facing spec of the identity_save tool; the operation is the
# compiled builtin named under `run`.
name: identity_save
description: >-
Store an account the daemon holds on a service: a label, the
service's name (e.g. 'github'), optionally its URL and the username,
and the secret (an API token, or a password). Act with a stored
account through identity_exec — the credential itself is never shown
to you. With watch=true and a forge the daemon watches, the
account's notifications are followed.
parameters:
type: object
properties:
label:
type: string
description: "Display name, e.g. 'acme releases'."
service:
type: string
description: "The service, lowercase: github, gitlab, gitea, or any name."
url:
type: string
description: Base URL, for self-hosted instances.
username:
type: string
description: The login name on the service.
secret:
type: string
description: The credential to store. Never echoed back.
secret_kind:
type: string
enum: [token, password]
description: What the secret is; default token.
watch:
type: boolean
description: Follow this forge identity's notifications; default false.
required: [label, service, secret]
run:
builtin: identity.save