feat(13-01): Rust assistant spine — one curated tool, one backend, one RPC surface

D-01/D-02/D-06 tracer slice: a new crate::assistant module (CallerScope,
PermissionCategory, ToolExecCtx, chat()) runs a multi-turn tool-calling loop
(run_loop/execute_tool, MAX_TURNS=8) against a curated single-tool registry
(system_disk_status, hand-written JSON Schema — no schemars) via a Claude
Messages API backend. execute_tool is the single choke point: unknown tools
are refused not ignored, D-16 category grants are re-checked even though the
system prompt already omits ungranted tools, and every real tool dispatches
through the SAME handle_system_disk_status RPC handler every other
authenticated caller uses (assistant_dispatch_tool bridge in
api/rpc/assistant_chat.rs) — never an AI-only backdoor.

assistant.chat is registered in dispatcher.rs as a single guarded
`m if m.starts_with("assistant.")` arm reached only after the existing
session-cookie + CSRF + role.can_access() gate in api/rpc/mod.rs — asserted
directly by assistant_methods_require_session against the live
UNAUTHENTICATED_METHODS list (visibility only widened to pub(crate) for that
assertion; the list's contents are untouched, per the Phase-10 hard
constraint).

Key read from data_dir/secrets/claude-api-key — the same path
mesh/rpc/mesh/assistant.rs already probes — never a second key location.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
archipelago
2026-08-03 14:37:16 -04:00
co-authored by Claude Opus 5
parent 15774d266f
commit fe6ccff73c
11 changed files with 924 additions and 2 deletions
@@ -0,0 +1,81 @@
//! `assistant.*` RPC surface (D-01/D-02) — the front door onto the shared
//! assistant service in `crate::assistant`. Every later `assistant.*`
//! method (13-05's `list-tools`/`grants-*`, 13-08's `confirm-tool`, 13-10's
//! `history`) is added inside this file; `dispatcher.rs` registers exactly
//! one guarded arm for the whole `assistant.` prefix (see
//! `grep -c 'starts_with("assistant.")' dispatcher.rs` == 1), never a new
//! per-method literal arm.
use super::RpcHandler;
use anyhow::Result;
use std::sync::Arc;
impl RpcHandler {
/// Prefix sub-dispatcher for `assistant.*`. Reached only after the
/// caller has already passed the session-cookie + CSRF +
/// `role.can_access()` gate in `api/rpc/mod.rs:264-330` — no bespoke
/// auth here (asserted by
/// `assistant::loop_::tests::assistant_methods_require_session`, which
/// confirms `assistant.*` is absent from `UNAUTHENTICATED_METHODS`).
pub(in crate::api::rpc) async fn handle_assistant(
self: &Arc<Self>,
method: &str,
params: Option<serde_json::Value>,
session_token: &Option<String>,
) -> Result<serde_json::Value> {
match method {
"assistant.chat" => self.handle_assistant_chat(params, session_token).await,
other => anyhow::bail!("no such assistant method: {other}"),
}
}
/// assistant.chat — a single chat turn from the authenticated local
/// operator. Params: `{ "text": string }`. Returns `{ "text": string }`.
async fn handle_assistant_chat(
self: &Arc<Self>,
params: Option<serde_json::Value>,
session_token: &Option<String>,
) -> Result<serde_json::Value> {
let text = params
.as_ref()
.and_then(|p| p.get("text"))
.and_then(|v| v.as_str())
.map(|s| s.to_string())
.ok_or_else(|| anyhow::anyhow!("text is required"))?;
// The caller's authenticated session identifies this LocalOperator —
// authority is resolved node-side from CallerScope, never from
// anything the browser or the model asserts about itself.
let session_id = session_token.clone().unwrap_or_default();
let caller = crate::assistant::CallerScope::LocalOperator { session_id };
let answer = crate::assistant::chat(Arc::clone(self), caller, text).await?;
Ok(serde_json::json!({ "text": answer }))
}
/// Internal-only bridge: executes a curated assistant tool against the
/// SAME `RpcHandler` method every authenticated RPC caller dispatches
/// through (never an AI-only backdoor). NOT itself an RPC method — only
/// `assistant::loop_::execute_tool` calls this, and only for tool names
/// present in the curated D-06 registry.
///
/// Rust module privacy is what requires this thin bridge:
/// `handle_system_disk_status` is `pub(in crate::api::rpc)`, so
/// `crate::assistant` (outside that module subtree) cannot call it
/// directly. This function lives inside `api::rpc` so it CAN call the
/// private handler, and re-exposes only the one curated method name a
/// tool call is allowed to reach — not the general RPC surface.
pub(crate) async fn assistant_dispatch_tool(&self, method: &str) -> Result<serde_json::Value> {
match method {
"system.disk-status" => self.handle_system_disk_status().await,
other => anyhow::bail!("assistant_dispatch_tool: no such handler for {other}"),
}
}
/// Read-only `data_dir` accessor for `crate::assistant`, which lives
/// outside `api::rpc`'s module tree and so cannot read the private
/// `config` field directly. Minimal, `pub(crate)`, no behavior change.
pub(crate) fn data_dir(&self) -> &std::path::Path {
&self.config.data_dir
}
}
@@ -444,6 +444,14 @@ impl RpcHandler {
"mesh.deadman-checkin" => self.handle_mesh_deadman_checkin().await,
"mesh.assistant-status" => self.handle_mesh_assistant_status().await,
"mesh.assistant-configure" => self.handle_mesh_assistant_configure(params).await,
// Phase 13 (D-01/D-02): the whole `assistant.*` surface lives in
// assistant_chat.rs, not as new arms here — this is the ONLY
// dispatcher.rs registration point for it. Every later
// assistant.* method (13-05, 13-08, 13-10) is added inside
// assistant_chat.rs's own match, never as a new arm in this file.
m if m.starts_with("assistant.") => {
self.handle_assistant(m, params, session_token).await
}
"mesh.schedule-message" => self.handle_mesh_schedule_message(params).await,
"mesh.list-scheduled" => self.handle_mesh_list_scheduled().await,
"mesh.cancel-scheduled" => self.handle_mesh_cancel_scheduled(params).await,
+7 -1
View File
@@ -2,7 +2,13 @@ use crate::session::SessionStore;
use std::net::IpAddr;
/// Methods that do not require a valid session cookie.
pub(super) const UNAUTHENTICATED_METHODS: &[&str] = &[
///
/// `pub(crate)` (not just `pub(super)`) so `crate::assistant`'s test suite
/// can assert directly against the live list that the assistant RPC prefix
/// is never added to it (Phase-10 hard constraint) — see the re-export in
/// `api/rpc/mod.rs`. Read-visibility only; the list's contents and every
/// other visibility in this module are unchanged.
pub(crate) const UNAUTHENTICATED_METHODS: &[&str] = &[
"auth.login",
"auth.login.totp",
"auth.login.backup",
+8 -1
View File
@@ -1,5 +1,6 @@
mod analytics;
mod ark;
mod assistant_chat;
mod auth;
mod backup_rpc;
mod bitcoin;
@@ -59,9 +60,15 @@ use std::sync::Arc;
use tracing::{debug, error};
pub use middleware::PeerAddr;
// Re-exported `pub(crate)` (not just imported) so `crate::assistant`'s test
// suite can assert directly against the live list that `assistant.*` is
// never added to it — the Phase-10 hard constraint this crate must hold.
// The list's *contents* are unchanged; only its read-visibility widens from
// "this module" to "this crate".
pub(crate) use middleware::UNAUTHENTICATED_METHODS;
use middleware::{
derive_csrf_token, extract_client_ip, extract_cookie, sanitize_error_message,
CACHEABLE_METHODS, UNAUTHENTICATED_METHODS,
CACHEABLE_METHODS,
};
use response::{cookie_header, json_response, ResponseCache, RpcError, RpcRequest, RpcResponse};