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:
co-authored by
Claude Opus 5
parent
15774d266f
commit
fe6ccff73c
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user