feat(claw): move the ask_secret tool spec into a TOOL.yaml manifest

ask_secret loads its spec from an embedded manifest under
crates/rhema-claw/tools; the Rust side keeps only the invoke half. It
stays replay-unsafe: a resumed run must not re-prompt the human or
re-store the credential the first ask already landed.
This commit is contained in:
vhaudiquet committed 2026-10-20 22:32:51 +02:00
1 parent 1df2718032
commit 9bf9d61573
2 files changed
+94 -49

No files matched your search

+57 -49
View File
@@ -17,12 +17,11 @@
//! a stale card. The tool answers a denial or an expiry in-band — a //! a stale card. The tool answers a denial or an expiry in-band — a
//! turn that asked and was refused is a turn that can say so. //! turn that asked and was refused is a turn that can say so.
use std::collections::HashMap; use std::collections::{BTreeMap, HashMap};
use std::sync::{Arc, Mutex, PoisonError}; use std::sync::{Arc, Mutex, PoisonError};
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::ToolSpec;
use rusqlite::params; use rusqlite::params;
use serde::Deserialize; use serde::Deserialize;
use serde_json::{Value, json}; use serde_json::{Value, json};
@@ -368,9 +367,6 @@ pub async fn wait_for_resolution(
} }
} }
/// The tool's name on the wire.
pub const ASK_SECRET: &str = "ask_secret";
/// `ask_secret`: store an account through the human. The tool asks, the /// `ask_secret`: store an account through the human. The tool asks, the
/// chat surface shows a card, the human pastes the secret, the HTTP /// chat surface shows a card, the human pastes the secret, the HTTP
/// route stores it into the identity store, and the tool wakes with the /// route stores it into the identity store, and the tool wakes with the
@@ -402,42 +398,7 @@ struct AskSecretArgs {
} }
#[async_trait] #[async_trait]
impl Tool for AskSecret { impl ToolOp for AskSecret {
fn spec(&self) -> ToolSpec {
ToolSpec::new(
ASK_SECRET,
"Ask the human for a credential and store it in the agent \
accounts: a card appears in their chat where they paste the \
secret. Use it when a task needs an account (a forge token, \
an API key) that identity_list does not show yet — never ask \
for something already stored. The answer carries no secret, \
only whether it was stored and the account's id. The human \
may decline, or answer never; both come back in-band. Act \
with the stored account afterwards through identity_exec.",
json!({
"type": "object",
"properties": {
"service": { "type": "string",
"description": "The service the credential is for, \
e.g. 'github' or an internal tool's name." },
"label": { "type": "string",
"description": "Display name for the account; \
defaults to the service name." },
"username": { "type": "string",
"description": "The login name on the service." },
"url": { "type": "string",
"description": "Base URL, for self-hosted instances." },
"secret_kind": { "type": "string",
"enum": ["token", "password"],
"description": "What the secret is; default token." },
"reason": { "type": "string",
"description": "Why you need it — shown on the card." }
},
"required": ["service"]
}),
)
}
async fn invoke(&self, args: Value) -> Result<String, String> { async fn invoke(&self, args: Value) -> Result<String, String> {
let args: AskSecretArgs = let args: AskSecretArgs =
serde_json::from_value(args).map_err(|err| format!("bad arguments: {err}"))?; serde_json::from_value(args).map_err(|err| format!("bad arguments: {err}"))?;
@@ -516,26 +477,73 @@ impl Tool for AskSecret {
/// The consent tool set over `store`: `ask_secret`. Built per dispatch, /// The consent tool set over `store`: `ask_secret`. Built per dispatch,
/// so the request names the session that asked. /// so the request names the session that asked.
/// The compiled operation behind the manifest, by `run.builtin` id.
fn builtins(
consents: Arc<ConsentStore>,
bridge: Arc<WaitBridge>,
hub: Arc<EventHub>,
key: ResourceKey,
) -> BTreeMap<String, Arc<dyn ToolOp>> {
BTreeMap::from([(
"consent.ask".to_owned(),
Arc::new(AskSecret {
consents,
bridge,
hub,
key,
wait: CONSENT_WAIT,
}) as Arc<dyn ToolOp>,
)])
}
/// The consent tool's `TOOL.yaml`, embedded so the daemon ships it
/// with no filesystem layout.
pub const MANIFESTS: &[(&str, &str)] =
&[("ask_secret", include_str!("../tools/ask_secret/TOOL.yaml"))];
/// The consent tool set: `ask_secret` — its spec from the embedded
/// manifest, the operation compiled here.
pub fn registry( pub fn registry(
consents: Arc<ConsentStore>, consents: Arc<ConsentStore>,
bridge: Arc<WaitBridge>, bridge: Arc<WaitBridge>,
hub: Arc<EventHub>, hub: Arc<EventHub>,
key: ResourceKey, key: ResourceKey,
) -> ToolRegistry { ) -> ToolRegistry {
let (tools, skipped) = rhema_autonomous_agent::manifest::builtin_tools(
MANIFESTS,
&builtins(consents, bridge, hub, key),
);
assert!(
skipped.is_empty(),
"the ask_secret tool manifest must load: {skipped:?}"
);
let mut registry = ToolRegistry::new(); let mut registry = ToolRegistry::new();
registry.register(Arc::new(AskSecret { for tool in tools {
consents, registry.register(tool);
bridge, }
hub,
key,
wait: CONSENT_WAIT,
}));
registry registry
} }
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
use rhema_autonomous_agent::Allowlist;
#[test]
fn the_manifest_loads_against_its_builtin_and_serves_the_registry() {
let resolver = registry(
store(),
Arc::new(WaitBridge::new()),
Arc::new(EventHub::with_capacity(16)),
ResourceKey::new("r"),
)
.resolver_for(&Allowlist::permissive());
assert_eq!(
resolver.replay_safety("ask_secret"),
rhema_core::ReplaySafety::Unsafe,
"re-running an ask on resume would re-prompt and could re-store"
);
}
fn store() -> Arc<ConsentStore> { fn store() -> Arc<ConsentStore> {
Arc::new(ConsentStore::in_memory().expect("store opens")) Arc::new(ConsentStore::in_memory().expect("store opens"))
@@ -0,0 +1,37 @@
# The model-facing spec of the ask_secret tool; the operation is the
# compiled builtin named under `run`.
name: ask_secret
description: >-
Ask the human for a credential and store it in the agent accounts: a
card appears in their chat where they paste the secret. Use it when
a task needs an account (a forge token, an API key) that
identity_list does not show yet — never ask for something already
stored. The answer carries no secret, only whether it was stored and
the account's id. The human may decline, or answer never; both come
back in-band. Act with the stored account afterwards through
identity_exec.
parameters:
type: object
properties:
service:
type: string
description: "The service the credential is for, e.g. 'github' or an internal tool's name."
label:
type: string
description: Display name for the account; defaults to the service name.
username:
type: string
description: The login name on the service.
url:
type: string
description: Base URL, for self-hosted instances.
secret_kind:
type: string
enum: [token, password]
description: What the secret is; default token.
reason:
type: string
description: "Why you need it — shown on the card."
required: [service]
run:
builtin: consent.ask