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:
1 parent
1df2718032
commit
9bf9d61573
2 files changed
+94
-49
No files matched your search
@@ -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
|
||||||
Reference in new issue
Block a user