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:
1 parent
da483daadd
commit
2747018b8f
4 files changed
+162
-70
No files matched your search
@@ -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
|
||||||
Reference in new issue
Block a user