feat(claw): the scoped agent token and the client-tool routes

The claw is the server half of the client tools. The middleware now
knows two bearer classes: the human token answers every /v1 route as
before; the agent token answers only the routes the contract
allowlists, and only with the contract header at the matching version
— consents, sessions, ingest, and the phones answer it 403, so a
model holding the token cannot prompt-inject its way past the scoping
to answer its own secret asks. Boot refuses the configuration where
agent_token is the human token: the scoping is the safety, and one
value playing both roles silently unscopes everything the shell
holds.

Three new routes write the tables the resident already reads: push to
the notification queue, schedule and cancel tasks the resident's tick
fires — the tools get native-shaped writes without the daemon growing
a second brain. A JSON fallback gives the one error shape everywhere,
unknown routes included. The daemon composes the tools' environment
(RHEMA_URL, RHEMA_TOKEN, the tools' bin dir on PATH) and hands it to
the resident's shell through the agent-tools env layer; with no
agent_token it provisions nothing, exactly as before.

The proof is the contract's conformance suite run against this router
over real HTTP — same answers as the mock, checked at test time.
This commit is contained in:
vhaudiquet committed 2026-10-24 01:02:28 +02:00
1 parent 2fbd93fd79
commit 98bc0fd623
9 files changed
+422 -28

No files matched your search

+4
View File
@@ -46,6 +46,10 @@ rhema-provider-llama-cpp = { path = "../rhema-provider-llama-cpp" }
rhema-provider-openai-compatible = { path = "../rhema-provider-openai-compatible" } rhema-provider-openai-compatible = { path = "../rhema-provider-openai-compatible" }
rhema-provider-openrouter = { path = "../rhema-provider-openrouter" } rhema-provider-openrouter = { path = "../rhema-provider-openrouter" }
rhema-rsd = { path = "../rhema-rsd" } rhema-rsd = { path = "../rhema-rsd" }
# The tool contract: the agent-token allowlist and version the auth
# middleware enforces (rhema-tools spec). The arrow runs claw ->
# contract only — never the reverse.
rhema-tools-contract = { path = "../../tools/contract" }
# The resident tier's durable memory: its tools join the resolver chain # The resident tier's durable memory: its tools join the resolver chain
rhema-memory = { path = "../rhema-memory" } rhema-memory = { path = "../rhema-memory" }
# The shared ModelCard the backend registry's listings return # The shared ModelCard the backend registry's listings return
+16
View File
@@ -66,6 +66,8 @@ provider: anthropic
model: claude-sonnet-4-5 model: claude-sonnet-4-5
listen: 127.0.0.1:7799 listen: 127.0.0.1:7799
token: change-me # optional; when set, /v1/* requires it token: change-me # optional; when set, /v1/* requires it
agent_token: change-me-too # the client tools' scoped bearer (rhema-tools)
tools_bin: ~/.local/share/rhema-tools/bin # put the client tools on PATH
poll_interval_secs: 120 poll_interval_secs: 120
max_turn_steps: 2048 # tool rounds one chat turn may run max_turn_steps: 2048 # tool rounds one chat turn may run
watches: # a seed: imported while the table is empty watches: # a seed: imported while the table is empty
@@ -101,6 +103,20 @@ its approval seam auto-approved: tool calls, the shell included,
execute without a prompt. The daemon says so at startup, and the execute without a prompt. The daemon says so at startup, and the
bearer-gated surface plus the watch list are the trust boundary. bearer-gated surface plus the watch list are the trust boundary.
`agent_token` provisions the client tools: a second bearer the daemon
hands the agent's shell as `RHEMA_TOKEN` (with `RHEMA_URL`), valid
only for the data routes the tool contract allowlists — watches,
notifications, tasks — and only with the contract header at the
matching version. It cannot reach consents, sessions, ingest, or the
phones, so a model holding the token cannot prompt-inject its way past
the scoping (see `specs/rhema-tools.md`; the daemon runs the
contract's conformance suite against its own router at test time). It
must differ from `token` — the daemon refuses to start when the two
are the same. `tools_bin` layers a directory onto the shell PATH so
the provisioned tools resolve. With no `agent_token`, the daemon
provisions no tool environment: the agent's shell is exactly as it
was.
The native surface: The native surface:
``` ```
+51
View File
@@ -56,6 +56,12 @@ pub enum ConfigError {
/// path or workspace root cannot be located. /// path or workspace root cannot be located.
#[error("cannot locate the state directory: neither XDG_STATE_HOME nor HOME is set")] #[error("cannot locate the state directory: neither XDG_STATE_HOME nor HOME is set")]
NoStateHome, NoStateHome,
/// The agent token is the human token: boot refuses, because the
/// agent token's safety is that it is scoped, and this copy is not.
#[error(
"agent_token is the same value as token; the agent token must be its own secret, scoped to the tool routes"
)]
SameAgentToken,
/// One watch is unusable; the daemon refuses to start rather than /// One watch is unusable; the daemon refuses to start rather than
/// poll a half-configured watch set. /// poll a half-configured watch set.
#[error("watch #{index} ({forge} {repo}): {reason}")] #[error("watch #{index} ({forge} {repo}): {reason}")]
@@ -288,6 +294,16 @@ pub struct ClawConfig {
/// open on its interface; set it when the port is reachable from /// open on its interface; set it when the port is reachable from
/// anywhere but loopback. `/healthz` stays open either way. /// anywhere but loopback. `/healthz` stays open either way.
pub token: Option<String>, pub token: Option<String>,
/// The scoped agent token the client tools hold: it answers only
/// the data routes the tool contract allowlists — watches,
/// notifications, tasks — never consents, sessions, or ingest, so
/// handing it to the model's shell is safe (rhema-tools spec §5).
/// Unset disables the client tools; the daemon then provisions no
/// tool environment.
pub agent_token: Option<String>,
/// A directory of client-tool binaries (the rhema-tools project's)
/// layered onto the agent's shell PATH. Unset leaves PATH alone.
pub tools_bin: Option<PathBuf>,
/// What to watch — the optional seed for the persistent watch table. /// What to watch — the optional seed for the persistent watch table.
/// Imported once, when the table is still empty; after that the /// Imported once, when the table is still empty; after that the
/// table rules, and watches are managed in chat or over HTTP. /// table rules, and watches are managed in chat or over HTTP.
@@ -319,6 +335,8 @@ impl Default for ClawConfig {
model: None, model: None,
listen: None, listen: None,
token: None, token: None,
agent_token: None,
tools_bin: None,
transcript: None, transcript: None,
watches: Vec::new(), watches: Vec::new(),
calendars: Vec::new(), calendars: Vec::new(),
@@ -445,6 +463,14 @@ impl ClawConfig {
/// watches from there; a daemon that starts with nothing to guard /// watches from there; a daemon that starts with nothing to guard
/// still serves chat. /// still serves chat.
pub fn validate(&self) -> Result<(), ConfigError> { pub fn validate(&self) -> Result<(), ConfigError> {
// The agent token's safety is that it is scoped; making it the
// human token silently unscopes everything the model's shell
// holds. Boot refuses the configuration instead.
if let (Some(token), Some(agent_token)) = (&self.token, &self.agent_token) {
if !token.is_empty() && token == agent_token {
return Err(ConfigError::SameAgentToken);
}
}
for (index, watch) in self.watches.iter().enumerate() { for (index, watch) in self.watches.iter().enumerate() {
let fail = |reason: String| ConfigError::Watch { let fail = |reason: String| ConfigError::Watch {
index, index,
@@ -583,6 +609,31 @@ impl ClawConfig {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
#[test]
fn the_agent_token_must_differ_from_the_human_token() {
let config = ClawConfig {
token: Some("same".to_owned()),
agent_token: Some("same".to_owned()),
..ClawConfig::default()
};
assert!(
matches!(config.validate(), Err(ConfigError::SameAgentToken)),
"one token playing both roles is refused"
);
let config = ClawConfig {
agent_token: Some("other".to_owned()),
..config
};
assert!(config.validate().is_ok(), "two tokens pass");
// An agent token with no human token at all is the tools-only
// surface: legal, and scoped by construction.
let tools_only = ClawConfig {
agent_token: Some("agent".to_owned()),
..ClawConfig::default()
};
assert!(tools_only.validate().is_ok());
}
#[test] #[test]
fn the_transcript_is_on_unless_the_config_turns_it_off() { fn the_transcript_is_on_unless_the_config_turns_it_off() {
assert!(ClawConfig::default().transcript_enabled()); assert!(ClawConfig::default().transcript_enabled());
+24
View File
@@ -228,12 +228,34 @@ 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.
// The client tools' connection, handed to the agent's shell as
// environment: the URL the surface answers on, the scoped
// agent token (the middleware keeps it to the contract's data
// routes, so the model holding it cannot reach consents or
// ingest), and the tools' bin dir on PATH. No token, no
// environment — the tools are simply absent.
let mut tool_env = std::collections::BTreeMap::new();
if let Some(agent_token) = &config.agent_token {
tool_env.insert(
"RHEMA_URL".to_owned(),
format!("http://{}", config.listen_addr()),
);
tool_env.insert("RHEMA_TOKEN".to_owned(), agent_token.clone());
}
if let Some(tools_bin) = &config.tools_bin {
let inherited = std::env::var("PATH").unwrap_or_default();
tool_env.insert(
"PATH".to_owned(),
format!("{}:{inherited}", tools_bin.display()),
);
}
let forge_handler = ClawHandler::new( let forge_handler = ClawHandler::new(
backends.clone(), backends.clone(),
workspace_root, workspace_root,
watches.clone(), watches.clone(),
accounts.clone(), accounts.clone(),
config.transcript_enabled(), config.transcript_enabled(),
tool_env.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
@@ -364,6 +386,7 @@ impl Daemon {
store, store,
watches, watches,
notifications, notifications,
tasks,
bootstrap, bootstrap,
hub, hub,
backends.clone(), backends.clone(),
@@ -375,6 +398,7 @@ impl Daemon {
attachments, attachments,
phone, phone,
config.token.clone(), config.token.clone(),
config.agent_token.clone(),
); );
Ok(Self { Ok(Self {
+17 -1
View File
@@ -66,6 +66,10 @@ pub struct ClawHandler {
/// reply client must authenticate as the identity the poll did, or /// reply client must authenticate as the identity the poll did, or
/// the forge refuses the very post the poll was reading. /// the forge refuses the very post the poll was reading.
accounts: Arc<dyn IdentityStore>, accounts: Arc<dyn IdentityStore>,
/// The client tools' environment, layered onto every dispatch's
/// agent shell: the daemon URL, the scoped agent token, the tools'
/// bin dir on PATH. Empty when the config issues no agent token.
tool_env: std::collections::BTreeMap<String, String>,
clients: Mutex<HashMap<String, Arc<dyn Forge>>>, clients: Mutex<HashMap<String, Arc<dyn Forge>>>,
} }
@@ -78,6 +82,7 @@ impl ClawHandler {
watches: Arc<ClawStore>, watches: Arc<ClawStore>,
accounts: Arc<dyn IdentityStore>, accounts: Arc<dyn IdentityStore>,
transcript: bool, transcript: bool,
tool_env: std::collections::BTreeMap<String, String>,
) -> Self { ) -> Self {
Self { Self {
backends, backends,
@@ -85,6 +90,7 @@ impl ClawHandler {
workspace_root: workspace_root.into(), workspace_root: workspace_root.into(),
watches, watches,
accounts, accounts,
tool_env,
clients: Mutex::new(HashMap::new()), clients: Mutex::new(HashMap::new()),
} }
} }
@@ -239,6 +245,7 @@ impl ClawHandler {
config.transcript = self config.transcript = self
.transcript .transcript
.then(rhema_transcript::TranscriptSettings::default); .then(rhema_transcript::TranscriptSettings::default);
config.shell_env = self.tool_env.clone();
config config
} }
@@ -779,6 +786,7 @@ mod tests {
watches, watches,
Arc::new(rhema_identity::VolatileIdentityStore::new()), Arc::new(rhema_identity::VolatileIdentityStore::new()),
true, true,
std::collections::BTreeMap::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:
@@ -820,8 +828,16 @@ mod tests {
watches.clone(), watches.clone(),
accounts.clone(), accounts.clone(),
true, true,
std::collections::BTreeMap::new(),
);
let off = ClawHandler::new(
registry,
"/tmp/w",
watches,
accounts,
false,
std::collections::BTreeMap::new(),
); );
let off = ClawHandler::new(registry, "/tmp/w", watches, accounts, false);
assert!( assert!(
on.agent_config("m", Path::new("/tmp/w")) on.agent_config("m", Path::new("/tmp/w"))
.transcript .transcript
+247 -27
View File
@@ -38,6 +38,9 @@ use rhema_autonomous_agent::Driver;
use rhema_rsd::{ use rhema_rsd::{
DispatchError, Dispatcher, IngestResult, KeyRecord, KeyState, ResourceKey, SqliteStore, Store, DispatchError, Dispatcher, IngestResult, KeyRecord, KeyState, ResourceKey, SqliteStore, Store,
}; };
use rhema_tools_contract::Auth;
use rhema_tools_contract::CONTRACT_HEADER;
use rhema_tools_contract::CONTRACT_VERSION;
use serde::Deserialize; use serde::Deserialize;
use serde_json::{Value, json}; use serde_json::{Value, json};
use tokio::sync::broadcast::error::RecvError; use tokio::sync::broadcast::error::RecvError;
@@ -47,6 +50,7 @@ use crate::backends::BackendRegistry;
use crate::bootstrap::Bootstrap; use crate::bootstrap::Bootstrap;
use crate::hub::{EventHub, TurnEvent}; use crate::hub::{EventHub, TurnEvent};
use crate::notifications::NotificationStore; use crate::notifications::NotificationStore;
use crate::task_store::TaskStore;
use crate::watch_store::{ClawStore, validate_watch}; use crate::watch_store::{ClawStore, validate_watch};
/// How many `run_once` passes one POST may spend draining. /// How many `run_once` passes one POST may spend draining.
@@ -64,6 +68,9 @@ pub struct ClawState {
store: Arc<SqliteStore>, store: Arc<SqliteStore>,
watches: Arc<ClawStore>, watches: Arc<ClawStore>,
notifications: Arc<NotificationStore>, notifications: Arc<NotificationStore>,
/// The scheduled-task table the `/v1/tasks` routes serve — the same
/// rows the resident scheduler fires.
tasks: Arc<TaskStore>,
bootstrap: Arc<Bootstrap>, bootstrap: Arc<Bootstrap>,
hub: Arc<EventHub>, hub: Arc<EventHub>,
backends: Arc<BackendRegistry>, backends: Arc<BackendRegistry>,
@@ -84,6 +91,10 @@ pub struct ClawState {
/// reported state the ingest door feeds. /// reported state the ingest door feeds.
phone: Arc<crate::phone::PhoneStore>, phone: Arc<crate::phone::PhoneStore>,
token: Option<String>, token: Option<String>,
/// The scoped agent token the client tools hold: a second bearer
/// answering only the contract's data routes (see
/// [`require_auth`]).
agent_token: Option<String>,
/// The generic sources `/v1/ingest` has registered on the /// The generic sources `/v1/ingest` has registered on the
/// dispatcher, so a source name registers exactly once however many /// dispatcher, so a source name registers exactly once however many
/// events race in through it. /// events race in through it.
@@ -92,17 +103,19 @@ pub struct ClawState {
impl ClawState { impl ClawState {
/// The state over the interactive dispatcher and its driver, the /// The state over the interactive dispatcher and its driver, the
/// shared dispatch store, the watch table, the notification table, /// shared dispatch store, the watch table, the notification and
/// the bootstrap store, the event hub, the account store, the /// task tables, the bootstrap store, the event hub, the account
/// consent table, the attachment store, and the configured bearer /// store, the consent table, the attachment store, and the
/// `token`. /// configured bearers — the human `token` and the scoped
#[allow(clippy::too_many_arguments)] // fifteen parts, each load-bearing /// `agent_token`.
#[allow(clippy::too_many_arguments)] // seventeen parts, each load-bearing
pub fn new( pub fn new(
dispatcher: Arc<Dispatcher>, dispatcher: Arc<Dispatcher>,
driver: Arc<Driver>, driver: Arc<Driver>,
store: Arc<SqliteStore>, store: Arc<SqliteStore>,
watches: Arc<ClawStore>, watches: Arc<ClawStore>,
notifications: Arc<NotificationStore>, notifications: Arc<NotificationStore>,
tasks: Arc<TaskStore>,
bootstrap: Arc<Bootstrap>, bootstrap: Arc<Bootstrap>,
hub: Arc<EventHub>, hub: Arc<EventHub>,
backends: Arc<BackendRegistry>, backends: Arc<BackendRegistry>,
@@ -114,6 +127,7 @@ impl ClawState {
attachments: Arc<crate::attachments::AttachmentStore>, attachments: Arc<crate::attachments::AttachmentStore>,
phone: Arc<crate::phone::PhoneStore>, phone: Arc<crate::phone::PhoneStore>,
token: Option<String>, token: Option<String>,
agent_token: Option<String>,
) -> Self { ) -> Self {
Self { Self {
dispatcher, dispatcher,
@@ -121,6 +135,7 @@ impl ClawState {
store, store,
watches, watches,
notifications, notifications,
tasks,
bootstrap, bootstrap,
hub, hub,
backends, backends,
@@ -132,6 +147,7 @@ impl ClawState {
attachments, attachments,
phone, phone,
token, token,
agent_token,
generic_sources: Arc::new(Mutex::new(BTreeSet::new())), generic_sources: Arc::new(Mutex::new(BTreeSet::new())),
} }
} }
@@ -224,7 +240,12 @@ pub fn router(state: ClawState) -> Router {
.route("/phones", get(phones_list)) .route("/phones", get(phones_list))
.route("/phones/{device}", delete(phones_forget)) .route("/phones/{device}", delete(phones_forget))
.route("/ingest", post(ingest)) .route("/ingest", post(ingest))
.route("/notifications", get(notifications_list)) .route(
"/notifications",
get(notifications_list).post(notifications_push),
)
.route("/tasks", get(tasks_list).post(tasks_add))
.route("/tasks/{id}", delete(tasks_remove))
.route("/notifications/{id}/ack", post(notifications_ack)) .route("/notifications/{id}/ack", post(notifications_ack))
.route("/agent", get(agent_get).put(agent_put)) .route("/agent", get(agent_get).put(agent_put))
.route( .route(
@@ -244,6 +265,9 @@ pub fn router(state: ClawState) -> Router {
.route("/chat/completions", post(crate::openai::chat_completions)) .route("/chat/completions", post(crate::openai::chat_completions))
.layer(middleware::from_fn_with_state(state.clone(), require_auth)); .layer(middleware::from_fn_with_state(state.clone(), require_auth));
Router::new() Router::new()
// The one error shape everywhere, unknown routes included: a
// client's typo answers JSON it can read, not axum's bare text.
.fallback(not_found)
.route( .route(
"/", "/",
get(|| async { get(|| async {
@@ -264,6 +288,15 @@ pub fn router(state: ClawState) -> Router {
.with_state(state) .with_state(state)
} }
/// The unknown-route answer: the contract's error shape.
async fn not_found() -> Response {
(
StatusCode::NOT_FOUND,
Json(json!({"error": "no such route"})),
)
.into_response()
}
/// Byte-length-safe constant-time equality over the two strings' UTF-8 /// Byte-length-safe constant-time equality over the two strings' UTF-8
/// bytes: every compared byte is XOR-folded into one difference word /// bytes: every compared byte is XOR-folded into one difference word
/// along with the length difference, so the answer takes the same work /// along with the length difference, so the answer takes the same work
@@ -279,32 +312,64 @@ fn tokens_match(presented: &str, expected: &str) -> bool {
diff == 0 diff == 0
} }
/// The bearer check: with no configured token the surface is open; with /// The bearer check, in two classes. With neither token configured the
/// one, `Authorization: Bearer <token>` is required on every `/v1` /// surface is open, as before. The human token answers every `/v1`
/// route. `/healthz` never reaches this. /// route. The agent token — what the client tools hold — answers only
/// the data routes the tool contract allowlists, and only with the
/// contract header at the matching version: a tool cannot reach
/// consents, sessions, or ingest, so a leaked agent token cannot
/// answer the agent's own `ask_secret` (rhema-tools spec §5). Any
/// other bearer is a 401. `/healthz` never reaches this.
async fn require_auth(State(state): State<ClawState>, request: Request, next: Next) -> Response { async fn require_auth(State(state): State<ClawState>, request: Request, next: Next) -> Response {
let authorized = match &state.token { let presented = request
None => true, .headers()
Some(expected) => request .get(axum::http::header::AUTHORIZATION)
.headers() .and_then(|value| value.to_str().ok())
.get(axum::http::header::AUTHORIZATION) .and_then(|value| value.strip_prefix("Bearer "));
.and_then(|value| value.to_str().ok()) let class = match (&state.token, &state.agent_token, presented) {
.is_some_and(|value| { // Nothing configured: the open surface, bearers ignored.
value (None, None, _) => Auth::Human,
.strip_prefix("Bearer ") (Some(expected), _, Some(presented)) if tokens_match(presented, expected) => Auth::Human,
.is_some_and(|presented| tokens_match(presented, expected)) (_, Some(expected), Some(presented)) if tokens_match(presented, expected) => Auth::Agent,
}), // A token configured and no bearer, or a bearer matching
// neither.
(_, _, _) => Auth::Invalid,
}; };
if authorized { match class {
next.run(request).await Auth::Human => next.run(request).await,
} else { // Both mean "no credential": Auth::None never leaves the
// A body, however small: a silent 401 reads as a network fault // classifier, but the match stays total.
// to every client debugging its token. Auth::None | Auth::Invalid => (
(
StatusCode::UNAUTHORIZED, StatusCode::UNAUTHORIZED,
Json(json!({"error": "missing or wrong bearer token"})), Json(json!({"error": "missing or wrong bearer token"})),
) )
.into_response() .into_response(),
Auth::Agent => {
let version = request
.headers()
.get(CONTRACT_HEADER)
.and_then(|value| value.to_str().ok())
.is_some_and(|value| value == CONTRACT_VERSION.to_string());
if !version {
return (
StatusCode::CONFLICT,
Json(json!({"error": format!(
"agent calls carry the {CONTRACT_HEADER} header at version {CONTRACT_VERSION}"
)})),
)
.into_response();
}
if rhema_tools_contract::agent_allowed(request.method().as_str(), request.uri().path())
{
next.run(request).await
} else {
(
StatusCode::FORBIDDEN,
Json(json!({"error": "this route is not the agent token's"})),
)
.into_response()
}
}
} }
} }
@@ -1231,6 +1296,161 @@ async fn notifications_list(
Ok(Json(json!({ "notifications": notifications }))) Ok(Json(json!({ "notifications": notifications })))
} }
/// The body `POST /v1/notifications` takes: the client tools' push.
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct NotificationPushBody {
title: String,
body: String,
device: Option<String>,
}
/// The client tools' push: the same queue the notification poll
/// serves, entered over the surface — one row, delivered at the
/// device's next poll-and-ack (rhema-tools spec §6). The route the
/// `POST /v1/notifications` client speaks.
async fn notifications_push(
State(state): State<ClawState>,
Json(body): Json<NotificationPushBody>,
) -> Response {
if body.title.trim().is_empty() || body.body.trim().is_empty() {
return (
StatusCode::BAD_REQUEST,
Json(json!({"error": "title and body must not be empty"})),
)
.into_response();
}
let device = body
.device
.as_deref()
.map(str::trim)
.filter(|device| !device.is_empty())
.unwrap_or(crate::notifications::DEFAULT_DEVICE);
match state
.notifications
.push(device, body.title.trim(), body.body.trim())
{
Ok(record) => (StatusCode::OK, Json(record)).into_response(),
Err(err) => (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": err.to_string()})),
)
.into_response(),
}
}
/// The body `POST /v1/tasks` takes: the client tools' schedule. `kind`
/// picks the shape, exactly the two the resident scheduler fires.
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct TaskAddBody {
kind: String,
prompt: String,
at_millis: Option<i64>,
interval_millis: Option<i64>,
deliver_to: Option<String>,
}
/// The scheduled-task table, oldest first — the same rows the
/// resident's tick fires.
async fn tasks_list(State(state): State<ClawState>) -> Result<Json<Value>, (StatusCode, String)> {
let tasks = state
.tasks
.list()
.map_err(|err| (StatusCode::INTERNAL_SERVER_ERROR, err.to_string()))?;
Ok(Json(json!({ "tasks": tasks })))
}
/// The client tools' schedule: one task, fired by the resident's tick
/// when due (rhema-tools spec §6). The route the `POST /v1/tasks`
/// client speaks.
async fn tasks_add(State(state): State<ClawState>, Json(body): Json<TaskAddBody>) -> Response {
if body.prompt.trim().is_empty() {
return (
StatusCode::BAD_REQUEST,
Json(json!({"error": "prompt must not be empty"})),
)
.into_response();
}
let now = now_millis();
let scheduled = match body.kind.as_str() {
"at" => {
let Some(at_millis) = body.at_millis else {
return (
StatusCode::BAD_REQUEST,
Json(json!({"error": "a one-shot task needs at_millis"})),
)
.into_response();
};
state.tasks.schedule_at(
body.prompt.trim(),
at_millis,
now,
body.deliver_to.as_deref(),
)
}
"every" => {
let Some(interval_millis) = body.interval_millis else {
return (
StatusCode::BAD_REQUEST,
Json(json!({"error": "a recurring task needs interval_millis"})),
)
.into_response();
};
if interval_millis <= 0 {
return (
StatusCode::BAD_REQUEST,
Json(json!({"error": "interval_millis must be positive"})),
)
.into_response();
}
state.tasks.schedule_every(
body.prompt.trim(),
interval_millis,
now,
body.deliver_to.as_deref(),
)
}
_ => {
return (
StatusCode::BAD_REQUEST,
Json(json!({"error": "kind must be at or every"})),
)
.into_response();
}
};
match scheduled {
Ok(record) => (StatusCode::OK, Json(record)).into_response(),
Err(err) => (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": err.to_string()})),
)
.into_response(),
}
}
/// Milliseconds since the Unix epoch — the task table's clock.
fn now_millis() -> i64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|duration| duration.as_millis() as i64)
.unwrap_or(0)
}
/// Cancels one task. Like the watch removal, the answer is
/// `{"removed": false}` for an id already gone — a retried cancel
/// converges instead of 404ing.
async fn tasks_remove(State(state): State<ClawState>, Path(id): Path<i64>) -> Response {
match state.tasks.remove(id) {
Ok(removed) => (StatusCode::OK, Json(json!({"removed": removed}))).into_response(),
Err(err) => (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": err.to_string()})),
)
.into_response(),
}
}
/// Marks one notification delivered. Idempotent: `false` when the id is /// Marks one notification delivered. Idempotent: `false` when the id is
/// unknown or already acked. /// unknown or already acked.
async fn notifications_ack(State(state): State<ClawState>, Path(id): Path<i64>) -> Response { async fn notifications_ack(State(state): State<ClawState>, Path(id): Path<i64>) -> Response {
+2
View File
@@ -67,6 +67,8 @@ fn config(server: &MockServer, dir: &tempfile::TempDir) -> ClawConfig {
model: Some("test-model".to_owned()), model: Some("test-model".to_owned()),
listen: None, listen: None,
token: None, token: None,
agent_token: None,
tools_bin: None,
transcript: None, transcript: None,
watches: vec![Watch { watches: vec![Watch {
forge: ForgeKind::GitHub, forge: ForgeKind::GitHub,
+2
View File
@@ -55,6 +55,8 @@ fn config(server: &MockServer, dir: &tempfile::TempDir) -> ClawConfig {
model: Some("test-model".to_owned()), model: Some("test-model".to_owned()),
listen: None, listen: None,
token: None, token: None,
agent_token: None,
tools_bin: None,
transcript: None, transcript: None,
watches: vec![Watch { watches: vec![Watch {
forge: ForgeKind::GitHub, forge: ForgeKind::GitHub,
+59
View File
@@ -61,6 +61,8 @@ fn config(dir: &tempfile::TempDir, token: Option<&str>) -> ClawConfig {
model: Some("test-model".to_owned()), model: Some("test-model".to_owned()),
listen: None, listen: None,
token: token.map(str::to_owned), token: token.map(str::to_owned),
agent_token: None,
tools_bin: None,
transcript: None, transcript: None,
watches: Vec::new(), watches: Vec::new(),
calendars: Vec::new(), calendars: Vec::new(),
@@ -1186,6 +1188,63 @@ async fn notifications_round_trip_over_http() {
} }
/// A registry whose only backend is the scripted mock. /// A registry whose only backend is the scripted mock.
/// The tool contract's conformance suite against the daemon's real
/// router, over real HTTP — the same suite the contract crate runs
/// against its mock router. Same results is the definition of the
/// daemon implementing the contract (rhema-tools spec §7): the scoped
/// agent token reads and writes the data domains under the contract
/// header, and consents, sessions, ingest, and the phones stay
/// human-only.
#[tokio::test]
async fn the_surface_speaks_the_tool_contract() {
let dir = tempfile::tempdir().expect("tempdir");
let mut config = config(&dir, Some("human-token"));
config.agent_token = Some("agent-token".to_owned());
let daemon = Daemon::build(
&config,
mock_registry(Arc::new(MockProvider::new(Vec::new()))),
rhema_rsd::DispatcherConfig::default(),
)
.expect("the daemon assembles");
let base = serve(&daemon).await;
let client = reqwest::Client::new();
let result = rhema_tools_contract::conformance(|request: rhema_tools_contract::CallRequest| {
let client = client.clone();
let base = base.clone();
async move {
let method = reqwest::Method::from_bytes(request.method.as_bytes())
.map_err(|e| e.to_string())?;
let builder = client.request(method, format!("{base}{}", request.path));
let builder = match request.auth {
rhema_tools_contract::Auth::None => builder,
rhema_tools_contract::Auth::Human => {
builder.header(reqwest::header::AUTHORIZATION, "Bearer human-token")
}
rhema_tools_contract::Auth::Agent => {
builder.header(reqwest::header::AUTHORIZATION, "Bearer agent-token")
}
rhema_tools_contract::Auth::Invalid => {
builder.header(reqwest::header::AUTHORIZATION, "Bearer a-stranger")
}
};
let builder = match request.contract {
Some(version) => builder.header("x-rhema-contract", version.to_string()),
None => builder,
};
let builder = match request.body {
Some(body) => builder.json(&body),
None => builder,
};
let response = builder.send().await.map_err(|e| e.to_string())?;
let status = response.status().as_u16();
let body = response.json().await.unwrap_or(serde_json::json!({}));
Ok(rhema_tools_contract::CallResponse { status, body })
}
})
.await;
assert_eq!(result, Ok(()), "the daemon speaks the tool contract");
}
fn mock_registry(provider: Arc<dyn rhema_core::ModelProvider>) -> Arc<rhema_claw::BackendRegistry> { fn mock_registry(provider: Arc<dyn rhema_core::ModelProvider>) -> Arc<rhema_claw::BackendRegistry> {
Arc::new( Arc::new(
rhema_claw::BackendRegistry::new( rhema_claw::BackendRegistry::new(