Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
13b576da26 |
@@ -1,6 +1,7 @@
|
||||
mod blob;
|
||||
mod content;
|
||||
mod dwn;
|
||||
mod model_proxy;
|
||||
mod node_message;
|
||||
mod proxy;
|
||||
mod remote_input;
|
||||
@@ -433,6 +434,17 @@ impl ApiHandler {
|
||||
// RPC — auth is handled inside rpc handler per-method
|
||||
(Method::POST, "/rpc/v1") => self.rpc_handler.clone().handle(req_with_bytes).await,
|
||||
|
||||
// AIUI model proxy — session-gated forwarder to Claude/Ollama,
|
||||
// replacing the unauthenticated claude-api-proxy.py sidecar and
|
||||
// the /aiui/api/openrouter/ open relay (13-02-PLAN.md,
|
||||
// T-13-08/T-13-09/T-13-10/T-13-11). The daemon re-derives auth
|
||||
// from the cookie inside handle_model_proxy — it does not trust
|
||||
// nginx to have gated the request already, the same "don't trust
|
||||
// the front door" discipline as /lnd-connect-info below.
|
||||
(_, p) if p.starts_with("/aiui/api/claude/") || p.starts_with("/aiui/api/ollama/") => {
|
||||
self.handle_model_proxy(req_with_bytes, p).await
|
||||
}
|
||||
|
||||
// Health — unauthenticated, returns JSON with service status
|
||||
(Method::GET, "/health") => {
|
||||
let recovery_complete = crate::crash_recovery::is_recovery_complete();
|
||||
|
||||
@@ -0,0 +1,400 @@
|
||||
//! Session-gated forwarder for `/aiui/api/claude/*` and `/aiui/api/ollama/*`.
|
||||
//!
|
||||
//! Replaces `claude-api-proxy.py` — a standalone Python process on port 3142
|
||||
//! holding its **own** copy of the Anthropic API key, reachable with **no
|
||||
//! session gate** — and retires the `/aiui/api/openrouter/` open relay
|
||||
//! entirely (13-02-PLAN.md, T-13-08/T-13-09/T-13-10/T-13-11/T-13-12). Anyone
|
||||
//! who could reach the node's web port could spend the owner's API budget.
|
||||
//!
|
||||
//! The daemon re-derives auth from the request's own session cookie — it
|
||||
//! does not trust nginx to have gated the request already, the same
|
||||
//! discipline `/lnd-connect-info`'s doc comment spells out for exactly this
|
||||
//! reason (a second front door, or a misconfigured proxy, must not become a
|
||||
//! silent bypass). It reads the node's single Claude key ledger
|
||||
//! (`data_dir/secrets/claude-api-key`) fresh on every call rather than
|
||||
//! caching it, and never forwards an inbound `x-api-key`, `authorization`
|
||||
//! or `cookie` header upstream (T-13-14) — a caller must not be able to
|
||||
//! bill a different account or leak the node's session to Anthropic.
|
||||
|
||||
use super::ApiHandler;
|
||||
use crate::session::{self, SessionStore};
|
||||
use anyhow::Result;
|
||||
use hyper::{Body, HeaderMap, Method, Request, Response, StatusCode};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::time::Duration;
|
||||
|
||||
/// Anthropic Messages API base. The node's single key ledger
|
||||
/// (`data_dir/secrets/claude-api-key`) authenticates every forwarded call.
|
||||
const CLAUDE_UPSTREAM: &str = "https://api.anthropic.com/";
|
||||
/// Local Ollama. No key — the session gate exists purely to stop anonymous
|
||||
/// consumption of local GPU/CPU inference (T-13-11), not to protect a secret.
|
||||
const OLLAMA_UPSTREAM: &str = "http://127.0.0.1:11434/";
|
||||
/// Generous enough for a multi-turn tool-call round trip; `mesh/listener/
|
||||
/// assist.rs`'s OLLAMA_TIMEOUT (60s) is airtime-tuned for LoRa and not
|
||||
/// reusable here — this path has no such constraint (13-AI-SPEC.md Pitfall 6).
|
||||
const FORWARD_TIMEOUT_SECS: u64 = 180;
|
||||
|
||||
impl ApiHandler {
|
||||
/// Entry point wired into the `/aiui/api/claude/` and `/aiui/api/ollama/`
|
||||
/// arms in `mod.rs`. Kept as a thin method so it can read
|
||||
/// `self.session_store` / `self.config.data_dir`; the actual routing and
|
||||
/// forwarding logic lives in free functions below so it is unit-testable
|
||||
/// without constructing a full `ApiHandler` (RpcHandler + orchestrators +
|
||||
/// blob store) in every test.
|
||||
pub(super) async fn handle_model_proxy(
|
||||
&self,
|
||||
req: Request<Body>,
|
||||
path: &str,
|
||||
) -> Result<Response<Body>> {
|
||||
route_model_proxy(&self.session_store, &self.config.data_dir, req, path).await
|
||||
}
|
||||
}
|
||||
|
||||
/// Routing + auth gate, factored out of the `ApiHandler` method so tests can
|
||||
/// exercise it with `SessionStore::new_for_tests` and a `tempfile` data_dir.
|
||||
async fn route_model_proxy(
|
||||
session_store: &SessionStore,
|
||||
data_dir: &Path,
|
||||
req: Request<Body>,
|
||||
path: &str,
|
||||
) -> Result<Response<Body>> {
|
||||
if !is_authenticated(session_store, req.headers()).await {
|
||||
tracing::warn!("401 model proxy {} — session invalid or missing", path);
|
||||
return Ok(unauthorized());
|
||||
}
|
||||
if let Some(rest) = path.strip_prefix("/aiui/api/claude/") {
|
||||
forward_claude(req, rest, data_dir).await
|
||||
} else if let Some(rest) = path.strip_prefix("/aiui/api/ollama/") {
|
||||
forward_ollama(req, rest).await
|
||||
} else {
|
||||
// Unreachable given the caller's prefix match in mod.rs, but never
|
||||
// fall through to an unauthenticated 200 on an unrecognized path.
|
||||
Ok(unauthorized())
|
||||
}
|
||||
}
|
||||
|
||||
/// Re-derive session auth from the request's own cookie. Deliberately not a
|
||||
/// call back into `ApiHandler::is_authenticated` — keeping this small and
|
||||
/// dependency-free is what makes the 401 behaviour unit-testable without
|
||||
/// paying for a full `ApiHandler` in every test.
|
||||
async fn is_authenticated(session_store: &SessionStore, headers: &HeaderMap) -> bool {
|
||||
match session::extract_session_cookie(headers) {
|
||||
Some(token) => session_store.validate(&token).await,
|
||||
None => false,
|
||||
}
|
||||
}
|
||||
|
||||
fn unauthorized() -> Response<Body> {
|
||||
let body = serde_json::json!({ "error": "Unauthorized" });
|
||||
Response::builder()
|
||||
.status(StatusCode::UNAUTHORIZED)
|
||||
.header("Content-Type", "application/json")
|
||||
.body(Body::from(serde_json::to_vec(&body).unwrap_or_default()))
|
||||
.unwrap_or_else(|_| Response::new(Body::from("Unauthorized")))
|
||||
}
|
||||
|
||||
/// A plain-language 503 naming the missing key — never a 500, and never the
|
||||
/// key's filesystem path (that would hand an authenticated-but-untrusted
|
||||
/// caller a hint about the node's on-disk layout for no benefit to them).
|
||||
fn key_not_configured() -> Response<Body> {
|
||||
let body = serde_json::json!({
|
||||
"error": "Claude is not configured on this node yet — set an API key in Settings."
|
||||
});
|
||||
Response::builder()
|
||||
.status(StatusCode::SERVICE_UNAVAILABLE)
|
||||
.header("Content-Type", "application/json")
|
||||
.body(Body::from(serde_json::to_vec(&body).unwrap_or_default()))
|
||||
.unwrap_or_else(|_| Response::new(Body::from("Claude is not configured")))
|
||||
}
|
||||
|
||||
fn bad_gateway(msg: &str) -> Response<Body> {
|
||||
let body = serde_json::json!({ "error": msg });
|
||||
Response::builder()
|
||||
.status(StatusCode::BAD_GATEWAY)
|
||||
.header("Content-Type", "application/json")
|
||||
.body(Body::from(serde_json::to_vec(&body).unwrap_or_default()))
|
||||
.unwrap_or_else(|_| Response::new(Body::from(msg.to_string())))
|
||||
}
|
||||
|
||||
/// Forward an already-authenticated request to Anthropic's Messages API.
|
||||
/// `rest` is the path remainder after `/aiui/api/claude/` has been stripped
|
||||
/// by the caller (e.g. `v1/messages`).
|
||||
async fn forward_claude(req: Request<Body>, rest: &str, data_dir: &Path) -> Result<Response<Body>> {
|
||||
let key_path: PathBuf = data_dir.join("secrets/claude-api-key");
|
||||
let api_key = match tokio::fs::read_to_string(&key_path).await {
|
||||
Ok(k) if !k.trim().is_empty() => k.trim().to_string(),
|
||||
_ => {
|
||||
tracing::warn!("model proxy: claude key ledger missing, refusing forward");
|
||||
return Ok(key_not_configured());
|
||||
}
|
||||
};
|
||||
forward(
|
||||
req,
|
||||
rest,
|
||||
CLAUDE_UPSTREAM,
|
||||
"api.anthropic.com",
|
||||
&[
|
||||
("x-api-key", api_key),
|
||||
("anthropic-version", "2023-06-01".to_string()),
|
||||
],
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
/// Forward an already-authenticated request to the node's local Ollama.
|
||||
/// `rest` is the path remainder after `/aiui/api/ollama/` has been stripped.
|
||||
async fn forward_ollama(req: Request<Body>, rest: &str) -> Result<Response<Body>> {
|
||||
forward(req, rest, OLLAMA_UPSTREAM, "127.0.0.1:11434", &[]).await
|
||||
}
|
||||
|
||||
/// Shared forwarding core for both backends. Copies ONLY the inbound
|
||||
/// `content-type`/`accept` request headers plus whatever `extra_headers`
|
||||
/// the caller supplies (the Claude key + version pin) — the inbound
|
||||
/// `x-api-key`, `authorization` and `cookie` headers are never read, let
|
||||
/// alone forwarded (T-13-14). Streams the upstream response back rather
|
||||
/// than buffering it, matching `proxy.rs`'s peer-content streaming shape,
|
||||
/// so token-by-token replies still stream to the browser.
|
||||
async fn forward(
|
||||
req: Request<Body>,
|
||||
rest: &str,
|
||||
upstream_base: &str,
|
||||
upstream_host_for_log: &str,
|
||||
extra_headers: &[(&str, String)],
|
||||
) -> Result<Response<Body>> {
|
||||
let method = req.method().clone();
|
||||
let (parts, body) = req.into_parts();
|
||||
let content_type = parts
|
||||
.headers
|
||||
.get(hyper::header::CONTENT_TYPE)
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.unwrap_or("application/json")
|
||||
.to_string();
|
||||
let accept = parts
|
||||
.headers
|
||||
.get(hyper::header::ACCEPT)
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.map(|s| s.to_string());
|
||||
let payload = hyper::body::to_bytes(body)
|
||||
.await
|
||||
.map_err(|e| anyhow::anyhow!("read request payload: {e}"))?;
|
||||
|
||||
let client = reqwest::Client::builder()
|
||||
.timeout(Duration::from_secs(FORWARD_TIMEOUT_SECS))
|
||||
.build()
|
||||
.map_err(|e| anyhow::anyhow!("client build: {e}"))?;
|
||||
|
||||
let reqwest_method = reqwest::Method::from_bytes(method.as_str().as_bytes())
|
||||
.unwrap_or(reqwest::Method::POST);
|
||||
let url = format!("{}{}", upstream_base, rest);
|
||||
let mut upstream_req = client
|
||||
.request(reqwest_method, &url)
|
||||
.header("content-type", content_type);
|
||||
for (name, value) in extra_headers {
|
||||
upstream_req = upstream_req.header(*name, value);
|
||||
}
|
||||
if let Some(accept) = accept {
|
||||
upstream_req = upstream_req.header("accept", accept);
|
||||
}
|
||||
// GET requests to Ollama carry no payload; avoid sending an empty body
|
||||
// on GET, which some servers treat differently from "no body at all".
|
||||
if method != Method::GET || !payload.is_empty() {
|
||||
upstream_req = upstream_req.body(payload.to_vec());
|
||||
}
|
||||
|
||||
match upstream_req.send().await {
|
||||
Ok(resp) => {
|
||||
let status = resp.status().as_u16();
|
||||
tracing::info!(
|
||||
"model proxy: forwarded to {}, status={}",
|
||||
upstream_host_for_log,
|
||||
status
|
||||
);
|
||||
stream_response(resp)
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!(
|
||||
"model proxy: upstream request to {} failed: {}",
|
||||
upstream_host_for_log,
|
||||
e
|
||||
);
|
||||
Ok(bad_gateway("upstream request failed"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Stream the upstream response straight through instead of buffering it —
|
||||
/// same shape as `proxy.rs`'s peer-content Range streamer — so a
|
||||
/// token-by-token reply doesn't wait for the full response before the first
|
||||
/// byte reaches the browser.
|
||||
fn stream_response(resp: reqwest::Response) -> Result<Response<Body>> {
|
||||
let status = resp.status().as_u16();
|
||||
let headers = resp.headers().clone();
|
||||
let mut builder = Response::builder().status(status);
|
||||
for h in ["content-type", "content-length"] {
|
||||
if let Some(v) = headers.get(h).and_then(|v| v.to_str().ok()) {
|
||||
builder = builder.header(h, v);
|
||||
}
|
||||
}
|
||||
builder
|
||||
.body(Body::wrap_stream(resp.bytes_stream()))
|
||||
.map_err(|e| anyhow::anyhow!("response build: {e}"))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::Mutex as TokioMutex;
|
||||
|
||||
/// Unique suffix for a per-test temp file path (matches the pattern
|
||||
/// `session.rs`'s own tests already use — not key material, just a
|
||||
/// filename component, drawn unguarded).
|
||||
fn uniq() -> u64 {
|
||||
rand::RngCore::next_u64(&mut rand::rngs::OsRng)
|
||||
}
|
||||
|
||||
async fn test_store() -> SessionStore {
|
||||
let dir = std::env::temp_dir();
|
||||
let path = dir.join(format!("archy-model-proxy-test-sessions-{}.json", uniq()));
|
||||
SessionStore::new_for_tests(path)
|
||||
}
|
||||
|
||||
fn req_with_cookie(method: &str, path: &str, cookie: Option<&str>) -> Request<Body> {
|
||||
let mut builder = Request::builder().method(method).uri(path);
|
||||
if let Some(c) = cookie {
|
||||
builder = builder.header("cookie", format!("session={c}"));
|
||||
}
|
||||
builder.body(Body::empty()).unwrap()
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn claude_without_session_is_401() {
|
||||
let store = test_store().await;
|
||||
let data_dir = tempfile::tempdir().unwrap();
|
||||
let req = req_with_cookie("POST", "/aiui/api/claude/v1/messages", None);
|
||||
let resp = route_model_proxy(
|
||||
&store,
|
||||
data_dir.path(),
|
||||
req,
|
||||
"/aiui/api/claude/v1/messages",
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn ollama_without_session_is_401() {
|
||||
let store = test_store().await;
|
||||
let data_dir = tempfile::tempdir().unwrap();
|
||||
let req = req_with_cookie("GET", "/aiui/api/ollama/api/tags", None);
|
||||
let resp = route_model_proxy(&store, data_dir.path(), req, "/aiui/api/ollama/api/tags")
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn claude_with_invalid_session_is_401() {
|
||||
let store = test_store().await;
|
||||
let data_dir = tempfile::tempdir().unwrap();
|
||||
let req = req_with_cookie(
|
||||
"POST",
|
||||
"/aiui/api/claude/v1/messages",
|
||||
Some("not-a-real-token"),
|
||||
);
|
||||
let resp = route_model_proxy(
|
||||
&store,
|
||||
data_dir.path(),
|
||||
req,
|
||||
"/aiui/api/claude/v1/messages",
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn missing_key_is_503_not_500() {
|
||||
let store = test_store().await;
|
||||
let token = store.create().await;
|
||||
// Deliberately no data_dir/secrets/claude-api-key written.
|
||||
let data_dir = tempfile::tempdir().unwrap();
|
||||
let req = req_with_cookie("POST", "/aiui/api/claude/v1/messages", Some(&token));
|
||||
let resp = route_model_proxy(
|
||||
&store,
|
||||
data_dir.path(),
|
||||
req,
|
||||
"/aiui/api/claude/v1/messages",
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::SERVICE_UNAVAILABLE);
|
||||
}
|
||||
|
||||
/// Minimal local capture server (hyper 0.14, same crate `server.rs`
|
||||
/// already builds on) standing in for an upstream — records the headers
|
||||
/// of the one request it receives so the test can assert what actually
|
||||
/// left the node, without adding a mocking dependency.
|
||||
async fn spawn_capture_server() -> (String, Arc<TokioMutex<Option<HeaderMap>>>) {
|
||||
let captured: Arc<TokioMutex<Option<HeaderMap>>> = Arc::new(TokioMutex::new(None));
|
||||
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let addr = listener.local_addr().unwrap();
|
||||
let captured_clone = captured.clone();
|
||||
tokio::spawn(async move {
|
||||
if let Ok((stream, _)) = listener.accept().await {
|
||||
let captured = captured_clone.clone();
|
||||
let service = hyper::service::service_fn(move |req: Request<Body>| {
|
||||
let captured = captured.clone();
|
||||
async move {
|
||||
*captured.lock().await = Some(req.headers().clone());
|
||||
Ok::<_, std::convert::Infallible>(Response::new(Body::from("{}")))
|
||||
}
|
||||
});
|
||||
let _ = hyper::server::conn::Http::new()
|
||||
.serve_connection(stream, service)
|
||||
.await;
|
||||
}
|
||||
});
|
||||
(format!("http://{addr}/"), captured)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn inbound_authorization_header_is_not_forwarded() {
|
||||
let (upstream, captured) = spawn_capture_server().await;
|
||||
let req = Request::builder()
|
||||
.method("POST")
|
||||
.uri("/v1/messages")
|
||||
.header("authorization", "Bearer caller-supplied-secret")
|
||||
.header("x-api-key", "attacker-supplied-key")
|
||||
.header("cookie", "session=some-session-token")
|
||||
.header("content-type", "application/json")
|
||||
.body(Body::from("{}"))
|
||||
.unwrap();
|
||||
let resp = forward(req, "v1/messages", &upstream, "test-upstream", &[])
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(resp.status().is_success());
|
||||
|
||||
// Give the spawned capture task a moment to record the request.
|
||||
for _ in 0..20 {
|
||||
if captured.lock().await.is_some() {
|
||||
break;
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(10)).await;
|
||||
}
|
||||
let headers = captured
|
||||
.lock()
|
||||
.await
|
||||
.clone()
|
||||
.expect("capture server did not receive a request");
|
||||
assert!(headers.get("authorization").is_none());
|
||||
assert!(headers.get("x-api-key").is_none());
|
||||
assert!(headers.get("cookie").is_none());
|
||||
// The one header we DO expect to survive the round trip.
|
||||
assert_eq!(
|
||||
headers.get("content-type").and_then(|v| v.to_str().ok()),
|
||||
Some("application/json")
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,81 +0,0 @@
|
||||
//! `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,14 +444,6 @@ 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,13 +2,7 @@ use crate::session::SessionStore;
|
||||
use std::net::IpAddr;
|
||||
|
||||
/// Methods that do not require a valid session cookie.
|
||||
///
|
||||
/// `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] = &[
|
||||
pub(super) const UNAUTHENTICATED_METHODS: &[&str] = &[
|
||||
"auth.login",
|
||||
"auth.login.totp",
|
||||
"auth.login.backup",
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
mod analytics;
|
||||
mod ark;
|
||||
mod assistant_chat;
|
||||
mod auth;
|
||||
mod backup_rpc;
|
||||
mod bitcoin;
|
||||
@@ -60,15 +59,9 @@ 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,
|
||||
CACHEABLE_METHODS, UNAUTHENTICATED_METHODS,
|
||||
};
|
||||
use response::{cookie_header, json_response, ResponseCache, RpcError, RpcRequest, RpcResponse};
|
||||
|
||||
|
||||
@@ -1049,22 +1049,12 @@ impl RpcHandler {
|
||||
info!("Claude API key saved");
|
||||
}
|
||||
|
||||
// Update the claude-api-proxy environment and restart
|
||||
let env_line = format!("ANTHROPIC_API_KEY={}", value);
|
||||
let env_file = self.config.data_dir.join("secrets/claude-api-proxy.env");
|
||||
tokio::fs::write(&env_file, &env_line).await.ok();
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
std::fs::set_permissions(&env_file, std::fs::Permissions::from_mode(0o600))
|
||||
.ok();
|
||||
}
|
||||
|
||||
// Restart the proxy to pick up the new key
|
||||
let _ = tokio::process::Command::new("sudo")
|
||||
.args(["systemctl", "restart", "claude-api-proxy"])
|
||||
.output()
|
||||
.await;
|
||||
// `secrets/claude-api-key` (above) is deliberately the ONLY
|
||||
// Claude key ledger on this node (13-02-PLAN.md). A second
|
||||
// copy used to be written alongside it for a standalone,
|
||||
// unauthenticated sidecar process on port 3142 — that
|
||||
// sidecar and its key copy are retired; the session-gated
|
||||
// Rust daemon reads this one file directly.
|
||||
|
||||
Ok(serde_json::json!({ "saved": true }))
|
||||
}
|
||||
|
||||
@@ -1,207 +0,0 @@
|
||||
//! The Claude leg of the D-04 backend chain — Anthropic Messages API with
|
||||
//! `tools`/`tool_use`/`tool_result`. Modeled on
|
||||
//! `mesh/listener/assist.rs::call_claude`'s HTTP client construction and
|
||||
//! `api/rpc/mesh/assistant.rs`'s key-path convention, but NOT extended
|
||||
//! in place: this is a new, tool-calling-capable request/response shape,
|
||||
//! and its constants are new (AI-SPEC §3 Pitfall 6 — the mesh constants are
|
||||
//! airtime-tuned for LoRa and must not be reused here).
|
||||
|
||||
use std::path::PathBuf;
|
||||
use std::time::Duration;
|
||||
|
||||
use anyhow::Result;
|
||||
use async_trait::async_trait;
|
||||
use serde_json::{json, Value};
|
||||
|
||||
use super::{Backend, BackendTurn};
|
||||
use crate::assistant::tools::{ChatMessage, Role, ToolCall, ToolDef};
|
||||
|
||||
const CLAUDE_URL: &str = "https://api.anthropic.com/v1/messages";
|
||||
/// Kept in sync with `mesh/listener/assist.rs::CLAUDE_DEFAULT_MODEL` —
|
||||
/// cheap and already proven fast enough; D-07 makes backend choice a
|
||||
/// privacy/cost decision, not a capability-need one, so there is no reason
|
||||
/// to default to a stronger model here.
|
||||
const CLAUDE_MODEL: &str = "claude-haiku-4-5-20251001";
|
||||
/// New, separate constant for the AIUI path's multi-turn tool loop (which
|
||||
/// may include a network round trip) — NOT `assist.rs`'s `OLLAMA_TIMEOUT`
|
||||
/// (60s, LoRa-airtime-tuned).
|
||||
const ASSISTANT_HTTP_TIMEOUT: Duration = Duration::from_secs(180);
|
||||
/// Raised from mesh's `512` — `tool_use` content blocks and multi-turn
|
||||
/// reasoning need more headroom. Never left unbounded.
|
||||
const ASSISTANT_MAX_TOKENS: u32 = 2048;
|
||||
|
||||
pub struct ClaudeBackend {
|
||||
data_dir: PathBuf,
|
||||
}
|
||||
|
||||
impl ClaudeBackend {
|
||||
pub fn new(data_dir: PathBuf) -> Self {
|
||||
Self { data_dir }
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Backend for ClaudeBackend {
|
||||
async fn send(
|
||||
&self,
|
||||
system: &str,
|
||||
tools: &[ToolDef],
|
||||
history: &[ChatMessage],
|
||||
) -> Result<BackendTurn> {
|
||||
// SAME key path `api/rpc/mesh/assistant.rs` probes — do not
|
||||
// introduce a second key location (D-01, one key ledger).
|
||||
let key = tokio::fs::read_to_string(self.data_dir.join("secrets/claude-api-key"))
|
||||
.await
|
||||
.map_err(|_| anyhow::anyhow!("Claude API key not configured on this node"))?;
|
||||
let key = key.trim();
|
||||
if key.is_empty() {
|
||||
anyhow::bail!("Claude API key is empty");
|
||||
}
|
||||
|
||||
let messages: Vec<Value> = history.iter().filter_map(message_to_wire).collect();
|
||||
|
||||
let claude_tools: Vec<Value> = tools
|
||||
.iter()
|
||||
.map(|t| {
|
||||
json!({
|
||||
"name": t.name,
|
||||
"description": t.description,
|
||||
"input_schema": t.parameters,
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
|
||||
let mut body = json!({
|
||||
"model": CLAUDE_MODEL,
|
||||
"max_tokens": ASSISTANT_MAX_TOKENS,
|
||||
"system": system,
|
||||
"messages": messages,
|
||||
"stream": false,
|
||||
});
|
||||
if !claude_tools.is_empty() {
|
||||
body["tools"] = json!(claude_tools);
|
||||
// AI-SPEC §3 Pitfall 5: every tool_use.id from one assistant
|
||||
// turn needs a matching tool_result before the next request.
|
||||
// Disabling parallel tool use sidesteps that bookkeeping —
|
||||
// D-06's tools are one deliberate action at a time anyway.
|
||||
body["tool_choice"] = json!({"type": "auto", "disable_parallel_tool_use": true});
|
||||
}
|
||||
|
||||
let client = reqwest::Client::builder()
|
||||
.timeout(ASSISTANT_HTTP_TIMEOUT)
|
||||
.build()?;
|
||||
let resp = client
|
||||
.post(CLAUDE_URL)
|
||||
.header("x-api-key", key)
|
||||
.header("anthropic-version", "2023-06-01")
|
||||
.header("content-type", "application/json")
|
||||
.json(&body)
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
if !resp.status().is_success() {
|
||||
let status = resp.status();
|
||||
let txt = resp.text().await.unwrap_or_default();
|
||||
anyhow::bail!(
|
||||
"Claude API HTTP {}: {}",
|
||||
status,
|
||||
txt.chars().take(180).collect::<String>()
|
||||
);
|
||||
}
|
||||
|
||||
let json: Value = resp.json().await?;
|
||||
let blocks = json
|
||||
.get("content")
|
||||
.and_then(|c| c.as_array())
|
||||
.cloned()
|
||||
.unwrap_or_default();
|
||||
|
||||
let mut tool_calls = Vec::new();
|
||||
let mut text = String::new();
|
||||
for block in &blocks {
|
||||
match block.get("type").and_then(|t| t.as_str()) {
|
||||
Some("tool_use") => {
|
||||
let id = block
|
||||
.get("id")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or_default()
|
||||
.to_string();
|
||||
let name = block
|
||||
.get("name")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or_default()
|
||||
.to_string();
|
||||
let arguments = block.get("input").cloned().unwrap_or_else(|| json!({}));
|
||||
tool_calls.push(ToolCall {
|
||||
id,
|
||||
name,
|
||||
arguments,
|
||||
});
|
||||
}
|
||||
Some("text") => {
|
||||
if let Some(t) = block.get("text").and_then(|v| v.as_str()) {
|
||||
text.push_str(t);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
if !tool_calls.is_empty() {
|
||||
Ok(BackendTurn::ToolCalls(tool_calls))
|
||||
} else {
|
||||
Ok(BackendTurn::Text(text))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Map one internal `ChatMessage` onto an Anthropic Messages API turn.
|
||||
/// `Role::System` returns `None` — the system prompt is sent via the
|
||||
/// top-level `system` field, not as a message in the array.
|
||||
fn message_to_wire(msg: &ChatMessage) -> Option<Value> {
|
||||
match msg.role {
|
||||
Role::System => None,
|
||||
Role::User => Some(json!({
|
||||
"role": "user",
|
||||
"content": msg.text.clone().unwrap_or_default(),
|
||||
})),
|
||||
Role::Assistant => {
|
||||
if !msg.tool_calls.is_empty() {
|
||||
let blocks: Vec<Value> = msg
|
||||
.tool_calls
|
||||
.iter()
|
||||
.map(|c| {
|
||||
json!({
|
||||
"type": "tool_use",
|
||||
"id": c.id,
|
||||
"name": c.name,
|
||||
"input": c.arguments,
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
Some(json!({"role": "assistant", "content": blocks}))
|
||||
} else {
|
||||
Some(json!({
|
||||
"role": "assistant",
|
||||
"content": msg.text.clone().unwrap_or_default(),
|
||||
}))
|
||||
}
|
||||
}
|
||||
Role::Tool => {
|
||||
let blocks: Vec<Value> = msg
|
||||
.tool_results
|
||||
.iter()
|
||||
.map(|r| {
|
||||
json!({
|
||||
"type": "tool_result",
|
||||
"tool_use_id": r.call_id,
|
||||
"content": r.content,
|
||||
"is_error": r.is_error,
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
// Anthropic's tool_result blocks travel back as a "user" turn.
|
||||
Some(json!({"role": "user", "content": blocks}))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,35 +0,0 @@
|
||||
//! The `Backend` trait — the wire-format-agnostic seam every model backend
|
||||
//! (Ollama, Claude, Routstr) implements once. The loop and every tool are
|
||||
//! written against this trait only; wire-format differences live entirely
|
||||
//! inside each adapter.
|
||||
|
||||
use std::path::Path;
|
||||
|
||||
use anyhow::Result;
|
||||
use async_trait::async_trait;
|
||||
|
||||
use super::tools::{ChatMessage, ToolCall, ToolDef};
|
||||
|
||||
pub mod claude;
|
||||
#[cfg(test)]
|
||||
pub mod scripted;
|
||||
|
||||
pub enum BackendTurn {
|
||||
Text(String),
|
||||
ToolCalls(Vec<ToolCall>),
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
pub trait Backend: Send + Sync {
|
||||
async fn send(&self, system: &str, tools: &[ToolDef], history: &[ChatMessage]) -> Result<BackendTurn>;
|
||||
}
|
||||
|
||||
/// D-04's backend chain: local Ollama first (node data never leaves the
|
||||
/// node when a local model is available), then Claude, then Routstr. Only
|
||||
/// the Claude leg is implemented in this tracer — `backends/ollama.rs`
|
||||
/// (13-10) and `backends/routstr.rs` (13-13) slot in ahead of and behind it
|
||||
/// without changing the `Backend` trait; that is the architectural
|
||||
/// commitment this tracer proves.
|
||||
pub fn select_backend(data_dir: &Path) -> Box<dyn Backend> {
|
||||
Box::new(claude::ClaudeBackend::new(data_dir.to_path_buf()))
|
||||
}
|
||||
@@ -1,44 +0,0 @@
|
||||
//! Test-only backend that replays a canned sequence of turns. Never
|
||||
//! compiles into the shipped binary — gated by `#![cfg(test)]` here AND by
|
||||
//! `#[cfg(test)] pub mod scripted;` in `backends/mod.rs`.
|
||||
|
||||
#![cfg(test)]
|
||||
|
||||
use std::sync::Mutex;
|
||||
|
||||
use anyhow::Result;
|
||||
use async_trait::async_trait;
|
||||
|
||||
use super::{Backend, BackendTurn};
|
||||
use crate::assistant::tools::{ChatMessage, ToolDef};
|
||||
|
||||
pub struct ScriptedBackend {
|
||||
turns: Mutex<Vec<BackendTurn>>,
|
||||
}
|
||||
|
||||
impl ScriptedBackend {
|
||||
/// `turns` are consumed in the order given — the first call to `send()`
|
||||
/// returns `turns[0]`, the second `turns[1]`, and so on.
|
||||
pub fn new(turns: Vec<BackendTurn>) -> Self {
|
||||
let mut turns = turns;
|
||||
turns.reverse();
|
||||
Self {
|
||||
turns: Mutex::new(turns),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Backend for ScriptedBackend {
|
||||
async fn send(
|
||||
&self,
|
||||
_system: &str,
|
||||
_tools: &[ToolDef],
|
||||
_history: &[ChatMessage],
|
||||
) -> Result<BackendTurn> {
|
||||
let mut turns = self.turns.lock().expect("ScriptedBackend mutex poisoned");
|
||||
turns
|
||||
.pop()
|
||||
.ok_or_else(|| anyhow::anyhow!("ScriptedBackend exhausted — no more turns queued"))
|
||||
}
|
||||
}
|
||||
@@ -1,237 +0,0 @@
|
||||
//! The multi-turn tool-calling loop (D-01/D-02). No analog exists elsewhere
|
||||
//! in this codebase — this is the first tool-calling agent loop ever
|
||||
//! written here (confirmed by 13-RESEARCH.md/13-AI-SPEC.md); built directly
|
||||
//! from `13-AI-SPEC.md` §3/§4's sketch.
|
||||
//!
|
||||
//! Concurrency discipline inherited from `mesh/listener/assist.rs`'s own
|
||||
//! doc comment ("Spawned off the radio loop so it never blocks"): never
|
||||
//! hold a shared lock across a `.await` that can block for human-response
|
||||
//! time. `execute_tool` below holds no lock at all in this tracer — there
|
||||
//! is nothing yet to hold one across (13-08's confirm gate is what
|
||||
//! introduces that discipline requirement for real).
|
||||
|
||||
use anyhow::Result;
|
||||
|
||||
use super::backends::{Backend, BackendTurn};
|
||||
use super::tools::{ChatMessage, Role, ToolCall, ToolResult};
|
||||
use super::tools::ToolDef;
|
||||
use super::ToolExecCtx;
|
||||
|
||||
/// Hard stop — a looping model must never spin unbounded (D-05).
|
||||
pub const MAX_TURNS: usize = 8;
|
||||
|
||||
pub async fn run_loop(
|
||||
backend: &dyn Backend,
|
||||
system: &str,
|
||||
tools: &[ToolDef],
|
||||
mut history: Vec<ChatMessage>,
|
||||
ctx: &ToolExecCtx,
|
||||
) -> Result<String> {
|
||||
for _ in 0..MAX_TURNS {
|
||||
match backend.send(system, tools, &history).await? {
|
||||
BackendTurn::Text(answer) => return Ok(answer),
|
||||
BackendTurn::ToolCalls(calls) => {
|
||||
history.push(ChatMessage {
|
||||
role: Role::Assistant,
|
||||
text: None,
|
||||
tool_calls: calls.clone(),
|
||||
tool_results: vec![],
|
||||
});
|
||||
let mut results = Vec::with_capacity(calls.len());
|
||||
for call in &calls {
|
||||
results.push(execute_tool(call, ctx).await);
|
||||
}
|
||||
history.push(ChatMessage {
|
||||
role: Role::Tool,
|
||||
text: None,
|
||||
tool_calls: vec![],
|
||||
tool_results: results,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
anyhow::bail!("assistant loop exceeded MAX_TURNS without a final answer — stopping, not looping forever")
|
||||
}
|
||||
|
||||
/// The single choke point every tool call passes through, regardless of
|
||||
/// which backend produced it. Enforces, in order: D-06 (curated allowlist —
|
||||
/// unknown names are refused, never silently ignored), D-16 (default-closed
|
||||
/// category grants — re-checked here even though the system prompt already
|
||||
/// omits ungranted tools; never trust that as the only enforcement layer),
|
||||
/// schema validation (never coerce, never guess), and D-07 (every
|
||||
/// destructive tool suspends for confirmation — 13-08 fills that branch in;
|
||||
/// there are no destructive tools registered yet, so it is unreachable
|
||||
/// today).
|
||||
async fn execute_tool(call: &ToolCall, ctx: &ToolExecCtx) -> ToolResult {
|
||||
let Some(tool) = ctx.registry.get(&call.name) else {
|
||||
return ToolResult {
|
||||
call_id: call.id.clone(),
|
||||
is_error: true,
|
||||
content: format!("no such tool: {}", call.name),
|
||||
};
|
||||
};
|
||||
|
||||
if !ctx.caller.granted_categories().contains(&tool.category) {
|
||||
return ToolResult {
|
||||
call_id: call.id.clone(),
|
||||
is_error: true,
|
||||
content: "not permitted — this category is not granted".to_string(),
|
||||
};
|
||||
}
|
||||
|
||||
if let Err(e) = tool.validate(&call.arguments) {
|
||||
return ToolResult {
|
||||
call_id: call.id.clone(),
|
||||
is_error: true,
|
||||
content: format!("invalid arguments: {e}"),
|
||||
};
|
||||
}
|
||||
|
||||
if tool.destructive {
|
||||
return ToolResult {
|
||||
call_id: call.id.clone(),
|
||||
is_error: true,
|
||||
content: "destructive tool execution is not yet implemented".to_string(),
|
||||
};
|
||||
}
|
||||
|
||||
match call.name.as_str() {
|
||||
// Dispatches to the SAME RpcHandler method every other authenticated
|
||||
// caller uses (no AI-only backdoor) — see `assistant_dispatch_tool`
|
||||
// in `api/rpc/assistant_chat.rs` for why this bridge exists.
|
||||
"system_disk_status" => match ctx
|
||||
.handler
|
||||
.assistant_dispatch_tool("system.disk-status")
|
||||
.await
|
||||
{
|
||||
Ok(v) => ToolResult {
|
||||
call_id: call.id.clone(),
|
||||
is_error: false,
|
||||
content: v.to_string(),
|
||||
},
|
||||
Err(e) => ToolResult {
|
||||
call_id: call.id.clone(),
|
||||
is_error: true,
|
||||
content: format!("tool execution failed: {e}"),
|
||||
},
|
||||
},
|
||||
other => ToolResult {
|
||||
call_id: call.id.clone(),
|
||||
is_error: true,
|
||||
content: format!("no execution wired for tool: {other}"),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::assistant::backends::scripted::ScriptedBackend;
|
||||
use crate::assistant::tools::{registry, system_disk_status_tool};
|
||||
use crate::assistant::{CallerScope, PermissionCategory};
|
||||
use crate::api::rpc::RpcHandler;
|
||||
use serde_json::json;
|
||||
use std::sync::Arc;
|
||||
|
||||
/// A minimal but real `RpcHandler` for tests: a fresh temp `data_dir`
|
||||
/// (no `/var/lib/archipelago` writes), no orchestrator (container RPCs
|
||||
/// aren't exercised here), matching the doc comment on `orchestrator`
|
||||
/// that this is exactly why the field is `Option`.
|
||||
async fn test_rpc_handler() -> (Arc<RpcHandler>, tempfile::TempDir) {
|
||||
let tmp = tempfile::tempdir().expect("tempdir");
|
||||
let mut config = crate::config::Config::default();
|
||||
config.data_dir = tmp.path().to_path_buf();
|
||||
let state_manager = Arc::new(crate::state::StateManager::new());
|
||||
let metrics_store = Arc::new(crate::monitoring::MetricsStore::new());
|
||||
let session_store =
|
||||
crate::session::SessionStore::new_for_tests(tmp.path().join("sessions.json"));
|
||||
let handler = RpcHandler::new(
|
||||
config,
|
||||
state_manager,
|
||||
metrics_store,
|
||||
session_store,
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.expect("RpcHandler::new");
|
||||
(Arc::new(handler), tmp)
|
||||
}
|
||||
|
||||
fn local_operator_ctx(handler: Arc<RpcHandler>) -> ToolExecCtx {
|
||||
ToolExecCtx {
|
||||
registry: registry(),
|
||||
caller: CallerScope::LocalOperator {
|
||||
session_id: "test-session".to_string(),
|
||||
},
|
||||
handler,
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn disk_status_tool_executes() {
|
||||
let (handler, _tmp) = test_rpc_handler().await;
|
||||
|
||||
// The real figures the tool path returns must match what the SAME
|
||||
// handler returns when dispatched directly — proving `execute_tool`
|
||||
// is not a parallel, AI-only code path.
|
||||
let direct = handler
|
||||
.assistant_dispatch_tool("system.disk-status")
|
||||
.await
|
||||
.expect("direct dispatch");
|
||||
|
||||
let ctx = local_operator_ctx(handler.clone());
|
||||
let call = ToolCall {
|
||||
id: "call-1".to_string(),
|
||||
name: "system_disk_status".to_string(),
|
||||
arguments: json!({}),
|
||||
};
|
||||
let result = execute_tool(&call, &ctx).await;
|
||||
assert!(!result.is_error, "tool call errored: {}", result.content);
|
||||
assert_eq!(result.content, direct.to_string());
|
||||
assert!(result.content.contains("total_bytes"));
|
||||
|
||||
// Exercise the whole loop: a ScriptedBackend that names the tool,
|
||||
// then answers — proving the real figures reached the final answer
|
||||
// path (the answer itself is the second scripted turn, matching
|
||||
// AI-SPEC's run_loop shape; the tool result that fed into it is
|
||||
// asserted above).
|
||||
let backend = ScriptedBackend::new(vec![
|
||||
BackendTurn::ToolCalls(vec![call.clone()]),
|
||||
BackendTurn::Text("Disk space report generated.".to_string()),
|
||||
]);
|
||||
let tools_list = vec![system_disk_status_tool()];
|
||||
let answer = run_loop(&backend, "system prompt", &tools_list, vec![], &ctx)
|
||||
.await
|
||||
.expect("run_loop");
|
||||
assert_eq!(answer, "Disk space report generated.");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn unknown_tool_is_refused_not_ignored() {
|
||||
let (handler, _tmp) = test_rpc_handler().await;
|
||||
let ctx = local_operator_ctx(handler);
|
||||
let call = ToolCall {
|
||||
id: "call-1".to_string(),
|
||||
name: "delete_everything".to_string(),
|
||||
arguments: json!({}),
|
||||
};
|
||||
let result = execute_tool(&call, &ctx).await;
|
||||
assert!(result.is_error);
|
||||
assert!(result.content.contains("no such tool"), "{}", result.content);
|
||||
}
|
||||
|
||||
/// Phase-10 hard constraint: `assistant.*` must never be reachable
|
||||
/// unauthenticated. Asserted directly against the live list, not
|
||||
/// assumed.
|
||||
#[test]
|
||||
fn assistant_methods_require_session() {
|
||||
let has_assistant_method = crate::api::rpc::UNAUTHENTICATED_METHODS
|
||||
.iter()
|
||||
.any(|m| m.starts_with("assistant."));
|
||||
assert!(
|
||||
!has_assistant_method,
|
||||
"assistant.* must never be added to UNAUTHENTICATED_METHODS (Phase-10 hard constraint)"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,125 +0,0 @@
|
||||
//! D-02: "one assistant, many front doors." A shared assistant service —
|
||||
//! one curated tool registry, one backend selector, one place the model key
|
||||
//! lives — used today by AIUI chat (`CallerScope::LocalOperator`) and, by
|
||||
//! design, extensible to mesh/LoRa callers (`CallerScope::Mesh`) and later
|
||||
//! Pine voice without recreating a second, divergent security model.
|
||||
//!
|
||||
//! This is the tracer slice for Phase 13 (D-01, D-02, D-06): a typed
|
||||
//! question reaches exactly one curated, read-only tool
|
||||
//! (`tools::system_disk_status_tool`) via the Claude backend, dispatched
|
||||
//! through the SAME `handle_system_disk_status` RPC handler every other
|
||||
//! authenticated caller uses. See `13-01-PLAN.md` for the full spine.
|
||||
|
||||
pub mod backends;
|
||||
pub mod loop_;
|
||||
pub mod tools;
|
||||
|
||||
use std::collections::BTreeSet;
|
||||
use std::sync::Arc;
|
||||
|
||||
use anyhow::Result;
|
||||
|
||||
use crate::api::rpc::RpcHandler;
|
||||
|
||||
/// D-16's ten permission categories. All default-closed on a fresh node —
|
||||
/// nothing is shared with the model until deliberately granted.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
|
||||
pub enum PermissionCategory {
|
||||
Apps,
|
||||
System,
|
||||
Network,
|
||||
Wallet,
|
||||
Files,
|
||||
Media,
|
||||
Search,
|
||||
AiLocal,
|
||||
Notes,
|
||||
Bitcoin,
|
||||
}
|
||||
|
||||
/// D-02's promoted primary noun: a caller identity carrying the permission
|
||||
/// scope its tool calls resolve authority through. "A mesh peer" and "the
|
||||
/// local operator in AIUI" are two variants of it; Pine voice will be a
|
||||
/// third (not built in this phase — no `Voice` variant exists yet, by
|
||||
/// design, until that phase actually needs one).
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum CallerScope {
|
||||
/// A mesh/LoRa peer. Not exercised by this plan (mesh's existing
|
||||
/// `!ai` path is Q&A-only, per `mesh/listener/assist.rs`'s own doc
|
||||
/// comment) — the variant exists so the shape is right when a future
|
||||
/// plan wires mesh callers into the shared loop.
|
||||
Mesh { peer_id: String },
|
||||
/// The authenticated operator using AIUI, identified by their neode-ui
|
||||
/// session. This is the only variant this tracer's `assistant.chat`
|
||||
/// RPC constructs.
|
||||
LocalOperator { session_id: String },
|
||||
}
|
||||
|
||||
impl CallerScope {
|
||||
/// The sole source of tool authority `execute_tool` reads. No
|
||||
/// `execute_tool` branch may read a caller-specific field directly
|
||||
/// instead of going through this — that would reintroduce the
|
||||
/// mesh-only assumption D-02 exists to retire.
|
||||
pub fn granted_categories(&self) -> BTreeSet<PermissionCategory> {
|
||||
match self {
|
||||
// 13-05 replaces this hardcoded default with the persisted
|
||||
// D-16 default-closed grants store — a data-source change, not
|
||||
// an architectural one (per the plan's assumption-delta note).
|
||||
CallerScope::LocalOperator { .. } => {
|
||||
let mut set = BTreeSet::new();
|
||||
set.insert(PermissionCategory::System);
|
||||
set
|
||||
}
|
||||
// Intentionally conservative for this tracer: mesh has no
|
||||
// tool-calling caller path wired up yet (today's mesh `!ai` is
|
||||
// Q&A-only), so there is no real trusted_only/allowed_contacts
|
||||
// grant to resolve. A future plan that wires the Mesh variant
|
||||
// into the shared loop threads those existing per-caller
|
||||
// controls through here — this is explicitly NOT the place a
|
||||
// mesh-only field gets read directly by `execute_tool`.
|
||||
CallerScope::Mesh { .. } => BTreeSet::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Bundles what `execute_tool` needs regardless of which backend produced
|
||||
/// the tool call: the curated registry, the caller's resolved authority,
|
||||
/// and a handle back to the SAME `RpcHandler` every other authenticated
|
||||
/// caller dispatches through — never an AI-only backdoor.
|
||||
pub struct ToolExecCtx {
|
||||
pub registry: tools::ToolRegistry,
|
||||
pub caller: CallerScope,
|
||||
pub handler: Arc<RpcHandler>,
|
||||
}
|
||||
|
||||
/// Entry point: run one chat turn for `caller` through the shared loop.
|
||||
/// Builds the visible-tool set from the caller's granted categories only
|
||||
/// (D-16 — the model should never even see a tool it can't use), selects a
|
||||
/// backend (Claude only, in this tracer), and runs it to a final answer.
|
||||
pub async fn chat(handler: Arc<RpcHandler>, caller: CallerScope, user_text: String) -> Result<String> {
|
||||
let registry = tools::registry();
|
||||
let grants = caller.granted_categories();
|
||||
let visible_tools = registry.visible_to(&grants);
|
||||
|
||||
let backend = backends::select_backend(handler.data_dir());
|
||||
|
||||
let system_prompt = "You are the Archipelago node's operator-control assistant. \
|
||||
Only use the tools explicitly listed for this turn — never invent a tool name or call \
|
||||
one that isn't listed. Every write requires human confirmation you cannot bypass or \
|
||||
pre-approve on the user's behalf.";
|
||||
|
||||
let history = vec![tools::ChatMessage {
|
||||
role: tools::Role::User,
|
||||
text: Some(user_text),
|
||||
tool_calls: vec![],
|
||||
tool_results: vec![],
|
||||
}];
|
||||
|
||||
let ctx = ToolExecCtx {
|
||||
registry,
|
||||
caller,
|
||||
handler,
|
||||
};
|
||||
|
||||
loop_::run_loop(backend.as_ref(), system_prompt, &visible_tools, history, &ctx).await
|
||||
}
|
||||
@@ -1,170 +0,0 @@
|
||||
//! D-06: a curated, hand-written tool registry. Never derived from
|
||||
//! `api::rpc::dispatcher`'s method table — every capability the chat has is
|
||||
//! a deliberate decision recorded here, and the model never sees the full
|
||||
//! RPC surface. No `schemars` — that crate is absent from `Cargo.toml` and
|
||||
//! from 13-RESEARCH.md's Package Legitimacy Audit, so `parameters` below is
|
||||
//! a hand-written JSON Schema object literal instead.
|
||||
|
||||
use std::collections::{BTreeSet, HashMap};
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use serde::Deserialize;
|
||||
use serde_json::{json, Value};
|
||||
|
||||
use super::PermissionCategory;
|
||||
|
||||
/// The backend-agnostic in/out of a tool invocation — the same shape
|
||||
/// regardless of which adapter (Ollama/Claude/Routstr) produced it.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ToolCall {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub arguments: Value,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ToolResult {
|
||||
pub call_id: String,
|
||||
pub content: String,
|
||||
pub is_error: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum Role {
|
||||
System,
|
||||
User,
|
||||
Assistant,
|
||||
Tool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ChatMessage {
|
||||
pub role: Role,
|
||||
/// Plain text, or (a future plan's) D-10-wrapped untrusted content.
|
||||
pub text: Option<String>,
|
||||
/// Assistant-authored tool calls made THIS turn (role: Assistant).
|
||||
pub tool_calls: Vec<ToolCall>,
|
||||
/// Tool results fed back THIS turn (role: Tool).
|
||||
pub tool_results: Vec<ToolResult>,
|
||||
}
|
||||
|
||||
/// D-06: one curated, hand-written tool. Never generated from the RPC
|
||||
/// dispatcher — the curated set IS the D-09 authority boundary.
|
||||
#[derive(Clone)]
|
||||
pub struct ToolDef {
|
||||
pub name: &'static str,
|
||||
pub description: &'static str,
|
||||
/// JSON Schema `{"type":"object","properties":{...},"required":[...]}`,
|
||||
/// hand-written and pinned adjacent to the args struct it must never
|
||||
/// drift from — see `disk_status_schema_round_trips_required_keys`.
|
||||
pub parameters: Value,
|
||||
pub category: PermissionCategory,
|
||||
/// D-07: true => confirm gate, no exceptions. There are no destructive
|
||||
/// tools in this tracer's registry; `execute_tool` refuses this branch
|
||||
/// with a not-yet-implemented error until 13-08 fills it in.
|
||||
pub destructive: bool,
|
||||
}
|
||||
|
||||
/// Args for `system_disk_status` — takes no parameters.
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct SystemDiskStatusArgs {}
|
||||
|
||||
impl ToolDef {
|
||||
/// Deserialize + validate model-produced arguments before ANY
|
||||
/// execution. Never coerce, never guess, never panic on a mismatch —
|
||||
/// refuse and let the caller turn the error into a tool result the
|
||||
/// model can recover from.
|
||||
///
|
||||
/// This tracer's registry has exactly one tool, so this is a direct
|
||||
/// deserialize; a future plan adding a second tool dispatches by
|
||||
/// `self.name` here before deserializing into that tool's own args type.
|
||||
pub fn validate(&self, raw: &Value) -> Result<SystemDiskStatusArgs> {
|
||||
serde_json::from_value(raw.clone())
|
||||
.context("tool arguments did not match the declared schema")
|
||||
}
|
||||
}
|
||||
|
||||
/// `system_disk_status` — category `System`, read-only. Reports free and
|
||||
/// total disk space on this node via the same `system.disk-status` handler
|
||||
/// every other authenticated caller uses.
|
||||
pub fn system_disk_status_tool() -> ToolDef {
|
||||
ToolDef {
|
||||
name: "system_disk_status",
|
||||
description: "Report free and total disk space on this Archipelago node.",
|
||||
parameters: json!({
|
||||
"type": "object",
|
||||
"properties": {},
|
||||
"required": [],
|
||||
}),
|
||||
category: PermissionCategory::System,
|
||||
destructive: false,
|
||||
}
|
||||
}
|
||||
|
||||
/// D-06's curated allowlist, name-indexed.
|
||||
pub struct ToolRegistry {
|
||||
tools: HashMap<&'static str, ToolDef>,
|
||||
}
|
||||
|
||||
impl ToolRegistry {
|
||||
pub fn get(&self, name: &str) -> Option<&ToolDef> {
|
||||
self.tools.get(name)
|
||||
}
|
||||
|
||||
/// The subset of the registry visible to a caller with `grants`. D-16:
|
||||
/// an unconfigured node's system prompt should advertise close to zero
|
||||
/// tools — the model should never even see a tool it can't use.
|
||||
pub fn visible_to(&self, grants: &BTreeSet<PermissionCategory>) -> Vec<ToolDef> {
|
||||
self.tools
|
||||
.values()
|
||||
.filter(|t| grants.contains(&t.category))
|
||||
.cloned()
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
/// The curated D-06 registry. This tracer registers exactly one tool.
|
||||
pub fn registry() -> ToolRegistry {
|
||||
let mut tools = HashMap::new();
|
||||
let tool = system_disk_status_tool();
|
||||
tools.insert(tool.name, tool);
|
||||
ToolRegistry { tools }
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// The schema sent to the model and the struct used to deserialize its
|
||||
/// output must never silently drift apart. Round-trip the schema's
|
||||
/// declared `required` keys through the args struct.
|
||||
#[test]
|
||||
fn disk_status_schema_round_trips_required_keys() {
|
||||
let tool = system_disk_status_tool();
|
||||
let required = tool
|
||||
.parameters
|
||||
.get("required")
|
||||
.and_then(|r| r.as_array())
|
||||
.cloned()
|
||||
.unwrap_or_default();
|
||||
|
||||
let mut obj = serde_json::Map::new();
|
||||
for key in &required {
|
||||
if let Some(k) = key.as_str() {
|
||||
obj.insert(k.to_string(), Value::Null);
|
||||
}
|
||||
}
|
||||
let value = Value::Object(obj);
|
||||
let parsed: Result<SystemDiskStatusArgs, _> = serde_json::from_value(value);
|
||||
assert!(parsed.is_ok(), "schema/args struct drift: {:?}", parsed.err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn registry_visible_to_respects_grants() {
|
||||
let reg = registry();
|
||||
let mut grants = BTreeSet::new();
|
||||
assert!(reg.visible_to(&grants).is_empty());
|
||||
grants.insert(PermissionCategory::System);
|
||||
assert_eq!(reg.visible_to(&grants).len(), 1);
|
||||
}
|
||||
}
|
||||
@@ -27,7 +27,6 @@ use tracing::info;
|
||||
|
||||
mod api;
|
||||
mod app_ops;
|
||||
mod assistant;
|
||||
mod auth;
|
||||
mod avatar;
|
||||
mod backup;
|
||||
|
||||
@@ -46,12 +46,21 @@ server {
|
||||
add_header Cache-Control "public, max-age=31536000, immutable";
|
||||
}
|
||||
|
||||
# AIUI Claude API proxy (API key managed by proxy, no session gate needed)
|
||||
# AIUI Claude API proxy — re-pointed to the Rust daemon (127.0.0.1:5678),
|
||||
# which enforces the session cookie itself and reads the node's single
|
||||
# key ledger (data_dir/secrets/claude-api-key). The old comment here said
|
||||
# "API key managed by proxy, no session gate needed" — that confuses key
|
||||
# *secrecy* with spend *authorization* and is the reasoning error that
|
||||
# made this an unauthenticated door into a paid API (T-13-08/T-13-09).
|
||||
# Do not point this at a standalone process again. No trailing path on
|
||||
# proxy_pass: nginx forwards the request URI unmodified so the daemon's
|
||||
# own prefix match sees the full /aiui/api/claude/... path.
|
||||
location /aiui/api/claude/ {
|
||||
proxy_pass http://127.0.0.1:3142/;
|
||||
proxy_pass http://127.0.0.1:5678;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header Cookie $http_cookie;
|
||||
proxy_buffering off;
|
||||
proxy_cache off;
|
||||
proxy_connect_timeout 120s;
|
||||
@@ -59,25 +68,18 @@ server {
|
||||
proxy_send_timeout 120s;
|
||||
}
|
||||
|
||||
# AIUI OpenRouter API proxy (API key managed by proxy, no session gate needed)
|
||||
location /aiui/api/openrouter/ {
|
||||
set $upstream_1 "https://openrouter.ai/api/";
|
||||
|
||||
proxy_pass $upstream_1;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host openrouter.ai;
|
||||
proxy_ssl_server_name on;
|
||||
proxy_connect_timeout 120s;
|
||||
proxy_read_timeout 120s;
|
||||
proxy_send_timeout 120s;
|
||||
}
|
||||
|
||||
# AIUI Ollama (local AI) proxy — localhost:11434
|
||||
# AIUI Ollama (local AI) proxy — same daemon, same session gate as above.
|
||||
# The standalone AIUI OpenRouter relay that used to live here is deleted
|
||||
# outright: the node holds no key for that backend, it is not in the
|
||||
# model backend chain, and an unauthenticated proxy_pass to a paid
|
||||
# third-party API from the node's IP was a plain open relay (T-13-10).
|
||||
# AIUI's own standalone/dev mode keeps its own proxy and is unaffected.
|
||||
location /aiui/api/ollama/ {
|
||||
proxy_pass http://127.0.0.1:11434/;
|
||||
proxy_pass http://127.0.0.1:5678;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header Cookie $http_cookie;
|
||||
proxy_buffering off;
|
||||
proxy_cache off;
|
||||
proxy_connect_timeout 120s;
|
||||
@@ -958,11 +960,17 @@ server {
|
||||
try_files $uri $uri/ /aiui/index.html;
|
||||
add_header Cache-Control "no-cache, no-store, must-revalidate";
|
||||
}
|
||||
# See the HTTP server block above for the full rationale: re-pointed to
|
||||
# the session-gated Rust daemon (T-13-08/T-13-09), OpenRouter relay
|
||||
# deleted outright (T-13-10). Both server blocks must carry this fix —
|
||||
# a change applied to only one leaves the exposure live on whichever
|
||||
# block actually serves the request (T-13-15).
|
||||
location /aiui/api/claude/ {
|
||||
proxy_pass http://127.0.0.1:3142/;
|
||||
proxy_pass http://127.0.0.1:5678;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header Cookie $http_cookie;
|
||||
proxy_buffering off;
|
||||
proxy_cache off;
|
||||
proxy_connect_timeout 120s;
|
||||
@@ -970,27 +978,17 @@ server {
|
||||
proxy_send_timeout 120s;
|
||||
}
|
||||
location /aiui/api/ollama/ {
|
||||
proxy_pass http://127.0.0.1:11434/;
|
||||
proxy_pass http://127.0.0.1:5678;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header Cookie $http_cookie;
|
||||
proxy_buffering off;
|
||||
proxy_cache off;
|
||||
proxy_connect_timeout 120s;
|
||||
proxy_read_timeout 300s;
|
||||
proxy_send_timeout 120s;
|
||||
}
|
||||
location /aiui/api/openrouter/ {
|
||||
set $upstream_6 "https://openrouter.ai/api/";
|
||||
|
||||
proxy_pass $upstream_6;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host openrouter.ai;
|
||||
proxy_ssl_server_name on;
|
||||
proxy_connect_timeout 120s;
|
||||
proxy_read_timeout 120s;
|
||||
proxy_send_timeout 120s;
|
||||
}
|
||||
|
||||
# Icons, favicon, manifest — always revalidate (no heuristic caching)
|
||||
location ~* ^/(favicon\.ico|manifest\.webmanifest|assets/icon/) {
|
||||
|
||||
@@ -5,7 +5,6 @@ import type {
|
||||
AIContextCategory,
|
||||
ArchyContextResponse,
|
||||
ArchyActionResponse,
|
||||
ArchyChatResponse,
|
||||
} from '@/types/aiui-protocol'
|
||||
import { useAIPermissionsStore } from '@/stores/aiPermissions'
|
||||
import { useAppStore } from '@/stores/app'
|
||||
@@ -82,37 +81,6 @@ export class ContextBroker {
|
||||
case 'theme:request':
|
||||
this.sendTheme()
|
||||
break
|
||||
case 'chat:request':
|
||||
this.handleChatRequest(msg.id, msg.text)
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// Note: no permission category is threaded through here on purpose.
|
||||
// Authority for a chat turn is resolved node-side from the RPC session's
|
||||
// CallerScope (assistant.chat, core/archipelago/src/assistant/mod.rs) —
|
||||
// duplicating a browser-side gate here would recreate the second,
|
||||
// divergent security model D-02 exists to prevent. Do not "helpfully"
|
||||
// add a permission check back into this handler.
|
||||
private async handleChatRequest(id: string, text: string) {
|
||||
try {
|
||||
const result = await rpcClient.call<{ text: string }>({
|
||||
method: 'assistant.chat',
|
||||
params: { text },
|
||||
})
|
||||
this.postToIframe({
|
||||
type: 'chat:response',
|
||||
id,
|
||||
success: true,
|
||||
text: result.text,
|
||||
} satisfies ArchyChatResponse)
|
||||
} catch (err) {
|
||||
this.postToIframe({
|
||||
type: 'chat:response',
|
||||
id,
|
||||
success: false,
|
||||
error: err instanceof Error ? err.message : 'Chat request failed',
|
||||
} satisfies ArchyChatResponse)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -45,24 +45,11 @@ export interface AIUIThemeRequest {
|
||||
type: 'theme:request'
|
||||
}
|
||||
|
||||
/**
|
||||
* A chat turn from AIUI's embedded-mode client. Carries only the raw user
|
||||
* text — tool selection is node-side (D-01/D-03) and must never be
|
||||
* expressible as an AIUI-originated action, so this is deliberately NOT an
|
||||
* `AIActionType` member.
|
||||
*/
|
||||
export interface AIUIChatRequest {
|
||||
type: 'chat:request'
|
||||
id: string
|
||||
text: string
|
||||
}
|
||||
|
||||
export type AIUIRequest =
|
||||
| AIUIContextRequest
|
||||
| AIUIActionRequest
|
||||
| AIUIReadyMessage
|
||||
| AIUIThemeRequest
|
||||
| AIUIChatRequest
|
||||
|
||||
// ─── Archy → AIUI (Responses) ──────────────────────────────────────────────
|
||||
|
||||
@@ -94,22 +81,11 @@ export interface ArchyPermissionsUpdate {
|
||||
categories: AIContextCategory[]
|
||||
}
|
||||
|
||||
/** The node's answer to a `chat:request`. On RPC failure, `error` carries
|
||||
* only the error message — never the raw exception object. */
|
||||
export interface ArchyChatResponse {
|
||||
type: 'chat:response'
|
||||
id: string
|
||||
success: boolean
|
||||
text?: string
|
||||
error?: string
|
||||
}
|
||||
|
||||
export type ArchyResponse =
|
||||
| ArchyContextResponse
|
||||
| ArchyActionResponse
|
||||
| ArchyThemeResponse
|
||||
| ArchyPermissionsUpdate
|
||||
| ArchyChatResponse
|
||||
|
||||
// ─── All messages ───────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
+21
-84
@@ -396,7 +396,6 @@ deploy_secondary() {
|
||||
ssh $SSH_OPTS "$SEC_TARGET" '
|
||||
sudo cp /tmp/nginx-archipelago.conf /etc/nginx/sites-available/archipelago
|
||||
sudo rm -f /etc/nginx/conf.d/external-app-proxies.conf
|
||||
sudo sed -i "s|proxy_pass http://127.0.0.1:3141/;|proxy_pass http://127.0.0.1:3142/;|g" /etc/nginx/sites-available/archipelago
|
||||
rm -f /tmp/nginx-archipelago.conf
|
||||
' 2>/dev/null || true
|
||||
fi
|
||||
@@ -775,9 +774,6 @@ if [ "$LIVE" = true ]; then
|
||||
# Remove old port-based external app proxies config
|
||||
ssh $SSH_OPTS "$TARGET_HOST" 'sudo rm -f /etc/nginx/conf.d/external-app-proxies.conf' 2>/dev/null || true
|
||||
|
||||
# Fix nginx Claude API proxy port (template uses 3141, proxy runs on 3142)
|
||||
ssh $SSH_OPTS "$TARGET_HOST" 'sudo sed -i "s|proxy_pass http://127.0.0.1:3141/;|proxy_pass http://127.0.0.1:3142/;|g" /etc/nginx/sites-available/archipelago' 2>/dev/null || true
|
||||
|
||||
# Validate nginx config after all changes
|
||||
ssh $SSH_OPTS "$TARGET_HOST" 'sudo nginx -t 2>&1 && echo " nginx config OK" || echo " ⚠️ nginx config test failed"' 2>/dev/null || true
|
||||
|
||||
@@ -873,87 +869,28 @@ if [ "$LIVE" = true ]; then
|
||||
' 2>/dev/null || true
|
||||
fi
|
||||
|
||||
# Deploy Claude API proxy (auto-install if missing)
|
||||
progress "Setting up Claude API proxy"
|
||||
# Retire the Claude API proxy sidecar (13-02-PLAN.md — closing a live
|
||||
# production exposure). This used to install/restart a standalone Python
|
||||
# process on port 3142 holding its OWN copy of ANTHROPIC_API_KEY, reachable
|
||||
# with no session gate — anyone who could reach the node's web port could
|
||||
# spend the owner's API budget (T-13-08/T-13-09). AIUI's Claude/Ollama
|
||||
# calls now route through the Rust daemon (127.0.0.1:5678, see the nginx
|
||||
# sync above), which enforces the session cookie and reads the node's
|
||||
# single key ledger (data_dir/secrets/claude-api-key).
|
||||
#
|
||||
# This step must run unconditionally on every deploy, not just fresh
|
||||
# installs: deploying the daemon fix without tearing down an
|
||||
# already-provisioned node's sidecar leaves the old unauthenticated
|
||||
# listener running right alongside the new authenticated one.
|
||||
progress "Removing legacy Claude API proxy sidecar"
|
||||
ssh $SSH_OPTS "$TARGET_HOST" '
|
||||
echo " Updating Claude API proxy on port 3142..."
|
||||
# Check for API key in existing service or setup-aiui-server.sh
|
||||
EXISTING_KEY=$(grep -oP "ANTHROPIC_API_KEY=\K.*" /etc/systemd/system/claude-api-proxy.service 2>/dev/null || true)
|
||||
if [ -z "$EXISTING_KEY" ]; then
|
||||
echo " ⚠️ No ANTHROPIC_API_KEY found — run setup-aiui-server.sh first to configure"
|
||||
else
|
||||
# Proxy script
|
||||
sudo tee /opt/archipelago/claude-api-proxy.py > /dev/null << '\''PYEOF'\''
|
||||
#!/usr/bin/env python3
|
||||
import http.server, json, ssl, sys, os, urllib.request, urllib.error
|
||||
API_KEY = os.environ.get("ANTHROPIC_API_KEY", "")
|
||||
PORT = 3142
|
||||
class Handler(http.server.BaseHTTPRequestHandler):
|
||||
def do_POST(self):
|
||||
if self.path == "/health":
|
||||
self.send_response(200); self.send_header("Content-Type","application/json"); self.end_headers()
|
||||
self.wfile.write(b"{\"status\":\"ok\"}"); return
|
||||
cl = int(self.headers.get("Content-Length", 0))
|
||||
body = self.rfile.read(cl)
|
||||
try: data = json.loads(body)
|
||||
except: data = {}
|
||||
if "max_tokens" not in data: data["max_tokens"] = 8096
|
||||
for f in ["webSearch","web_search"]: data.pop(f, None)
|
||||
# Normalize model IDs — map short/dotted names to full API model IDs
|
||||
MODEL_MAP = {
|
||||
"claude-haiku-4.5": "claude-haiku-4-5-20251001",
|
||||
"claude-haiku-4-5": "claude-haiku-4-5-20251001",
|
||||
"claude-sonnet-4": "claude-sonnet-4-20250514",
|
||||
"claude-sonnet-4.5": "claude-sonnet-4-5-20250514",
|
||||
"claude-sonnet-4-5": "claude-sonnet-4-5-20250514",
|
||||
"claude-opus-4": "claude-opus-4-20250514",
|
||||
}
|
||||
m = data.get("model", "")
|
||||
if m in MODEL_MAP: data["model"] = MODEL_MAP[m]
|
||||
body = json.dumps(data).encode()
|
||||
if not API_KEY:
|
||||
err = json.dumps({"type":"error","error":{"type":"auth_error","message":"AIUI not configured. Set your Anthropic API key in Settings > AIUI to enable AI chat."}}).encode()
|
||||
self.send_response(401); self.send_header("Content-Type","application/json"); self.send_header("Content-Length",str(len(err))); self.end_headers(); self.wfile.write(err); return
|
||||
headers = {"Content-Type":"application/json","x-api-key":API_KEY,"anthropic-version":"2023-06-01","anthropic-dangerous-direct-browser-access":"true"}
|
||||
for h in ["anthropic-version","anthropic-beta"]:
|
||||
if self.headers.get(h): headers[h] = self.headers[h]
|
||||
req = urllib.request.Request("https://api.anthropic.com"+self.path, data=body, headers=headers, method="POST")
|
||||
try:
|
||||
ctx = ssl.create_default_context()
|
||||
resp = urllib.request.urlopen(req, context=ctx, timeout=300)
|
||||
self.send_response(resp.status)
|
||||
is_stream = "text/event-stream" in (resp.headers.get("Content-Type","") or "")
|
||||
for k,v in resp.headers.items():
|
||||
if k.lower() not in ("transfer-encoding","connection"): self.send_header(k,v)
|
||||
if is_stream: self.send_header("Transfer-Encoding","chunked")
|
||||
self.end_headers()
|
||||
if is_stream:
|
||||
while True:
|
||||
chunk = resp.read(4096)
|
||||
if not chunk: break
|
||||
self.wfile.write(b"%x\r\n" % len(chunk)); self.wfile.write(chunk); self.wfile.write(b"\r\n"); self.wfile.flush()
|
||||
self.wfile.write(b"0\r\n\r\n"); self.wfile.flush()
|
||||
else: self.wfile.write(resp.read())
|
||||
except urllib.error.HTTPError as e:
|
||||
self.send_response(e.code); self.send_header("Content-Type","application/json"); self.end_headers(); self.wfile.write(e.read())
|
||||
except Exception as e:
|
||||
self.send_response(502); self.send_header("Content-Type","application/json"); self.end_headers(); self.wfile.write(json.dumps({"error":str(e)}).encode())
|
||||
def do_GET(self):
|
||||
if self.path == "/health":
|
||||
self.send_response(200); self.send_header("Content-Type","application/json"); self.end_headers(); self.wfile.write(b"{\"status\":\"ok\"}")
|
||||
else: self.send_response(404); self.end_headers()
|
||||
def log_message(self, fmt, *args): pass
|
||||
if not API_KEY: print("WARNING: ANTHROPIC_API_KEY not set — AIUI will return setup instructions")
|
||||
server = http.server.HTTPServer(("127.0.0.1", PORT), Handler)
|
||||
print(f"Claude API proxy on port {PORT}")
|
||||
server.serve_forever()
|
||||
PYEOF
|
||||
sudo systemctl daemon-reload
|
||||
sudo systemctl enable claude-api-proxy
|
||||
sudo systemctl restart claude-api-proxy
|
||||
sleep 1
|
||||
echo " Claude API proxy: $(systemctl is-active claude-api-proxy)"
|
||||
fi
|
||||
sudo systemctl stop claude-api-proxy 2>/dev/null || true
|
||||
sudo systemctl disable claude-api-proxy 2>/dev/null || true
|
||||
sudo rm -f /etc/systemd/system/claude-api-proxy.service
|
||||
sudo rm -f /opt/archipelago/claude-api-proxy.py
|
||||
sudo rm -f /var/lib/archipelago/secrets/claude-api-proxy.env
|
||||
sudo systemctl daemon-reload 2>/dev/null || true
|
||||
echo " claude-api-proxy: $(systemctl is-active claude-api-proxy 2>&1)"
|
||||
' 2>/dev/null || true
|
||||
|
||||
# Dev mode for Tailscale HTTP access (cookies need Secure flag disabled over plain HTTP)
|
||||
|
||||
+21
-110
@@ -1,6 +1,6 @@
|
||||
#!/bin/bash
|
||||
#
|
||||
# Setup AIUI + Claude API proxy + FileBrowser on any Archipelago server
|
||||
# Deploy the AIUI (Chat mode iframe) build to an Archipelago server.
|
||||
#
|
||||
# Usage:
|
||||
# ./scripts/setup-aiui-server.sh <host>
|
||||
@@ -8,10 +8,20 @@
|
||||
# ./scripts/setup-aiui-server.sh archipelago@192.168.1.228
|
||||
#
|
||||
# What it does:
|
||||
# 1. Deploys AIUI files (from local build)
|
||||
# 2. Configures nginx Claude API proxy (direct to Anthropic with API key)
|
||||
# 3. Fixes FileBrowser container (removes read-only root if needed)
|
||||
# 4. Reloads nginx
|
||||
# Rsyncs (or tar+scp, if rsync is unavailable on the target) a locally
|
||||
# built AIUI dist/ into /opt/archipelago/web-ui/aiui/ on the target node.
|
||||
#
|
||||
# What it no longer does (13-02-PLAN.md — closing a live production
|
||||
# exposure): it used to also patch nginx to route /aiui/api/claude/ to a
|
||||
# standalone Python proxy holding its own ANTHROPIC_API_KEY, with no session
|
||||
# gate — anyone who could reach the node's web port could spend the owner's
|
||||
# API budget. That proxy, its systemd unit, and this script's nginx-patch
|
||||
# step are all deleted (see scripts/deploy-to-target.sh's "Removing legacy
|
||||
# Claude API proxy sidecar" step). AIUI's Claude/Ollama calls now route
|
||||
# through the Rust daemon (127.0.0.1:5678), which enforces the session
|
||||
# cookie itself and reads the node's single key ledger. Set the key via
|
||||
# `system.settings.set claude_api_key` (Settings > AIUI in neode-ui) — this
|
||||
# script has nothing to do with the key anymore.
|
||||
#
|
||||
# Prerequisites:
|
||||
# - AIUI must be built locally first: cd AIUI/packages/app && VITE_BASE_PATH=/aiui/ npx vite build
|
||||
@@ -24,10 +34,6 @@ PROJECT_DIR="$(dirname "$SCRIPT_DIR")"
|
||||
SSH_KEY="${ARCHIPELAGO_SSH_KEY:-$HOME/.ssh/archipelago-deploy}"
|
||||
SSH_OPTS="-o StrictHostKeyChecking=no -i $SSH_KEY"
|
||||
|
||||
# Anthropic API key used by the AIUI Claude chat proxy. Keep this in the
|
||||
# caller's environment or scripts/deploy-config.sh; never commit live keys.
|
||||
ANTHROPIC_API_KEY="${ANTHROPIC_API_KEY:-}"
|
||||
|
||||
TARGET_HOST="$1"
|
||||
if [ -z "$TARGET_HOST" ]; then
|
||||
echo "Usage: $0 <user@host>"
|
||||
@@ -35,12 +41,6 @@ if [ -z "$TARGET_HOST" ]; then
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ -z "$ANTHROPIC_API_KEY" ]; then
|
||||
echo "ERROR: ANTHROPIC_API_KEY must be set in the environment."
|
||||
echo "Example: ANTHROPIC_API_KEY=<key> $0 $TARGET_HOST"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
AIUI_DIST="$PROJECT_DIR/../AIUI/packages/app/dist"
|
||||
if [ ! -f "$AIUI_DIST/index.html" ]; then
|
||||
echo "ERROR: AIUI build not found at $AIUI_DIST"
|
||||
@@ -51,15 +51,14 @@ fi
|
||||
timestamp() { echo "[$(date +%H:%M:%S)]"; }
|
||||
|
||||
echo "╔════════════════════════════════════════════════════════════╗"
|
||||
echo "║ Archipelago AIUI + Claude API Setup ║"
|
||||
echo "║ Archipelago AIUI deploy ║"
|
||||
echo "║ Target: $TARGET_HOST"
|
||||
echo "╚════════════════════════════════════════════════════════════╝"
|
||||
|
||||
# --- Step 1: Deploy AIUI files ---
|
||||
# --- Deploy AIUI files ---
|
||||
echo ""
|
||||
echo "$(timestamp) 📦 Deploying AIUI files..."
|
||||
|
||||
# Check if rsync is available on remote
|
||||
if ssh $SSH_OPTS "$TARGET_HOST" "which rsync" &>/dev/null; then
|
||||
rsync -avz --delete -e "ssh $SSH_OPTS" "$AIUI_DIST/" "$TARGET_HOST:/opt/archipelago/web-ui/aiui/" 2>&1 | tail -3
|
||||
else
|
||||
@@ -72,105 +71,17 @@ else
|
||||
fi
|
||||
echo " AIUI deployed."
|
||||
|
||||
# --- Step 2: Configure nginx Claude API proxy ---
|
||||
echo ""
|
||||
echo "$(timestamp) 🔧 Configuring nginx Claude API proxy..."
|
||||
|
||||
# Create a Python script to patch nginx config
|
||||
cat << 'PYSCRIPT' > /tmp/patch-nginx-claude.py
|
||||
import sys
|
||||
import re
|
||||
|
||||
API_KEY = sys.argv[1]
|
||||
|
||||
with open("/etc/nginx/sites-available/archipelago") as f:
|
||||
content = f.read()
|
||||
|
||||
# The new Claude API proxy block
|
||||
new_block = '''location /aiui/api/claude/ {
|
||||
if ($cookie_session = "") {
|
||||
return 401 '{"error":"Unauthorized"}';
|
||||
}
|
||||
proxy_pass https://api.anthropic.com/;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host api.anthropic.com;
|
||||
proxy_set_header x-api-key "''' + API_KEY + '''";
|
||||
proxy_set_header anthropic-version "2023-06-01";
|
||||
proxy_set_header anthropic-dangerous-direct-browser-access "true";
|
||||
proxy_ssl_server_name on;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_buffering off;
|
||||
proxy_cache off;
|
||||
proxy_connect_timeout 120s;
|
||||
proxy_read_timeout 300s;
|
||||
proxy_send_timeout 120s;
|
||||
}'''
|
||||
|
||||
# Replace existing Claude API proxy blocks (handles both old proxy and direct patterns)
|
||||
pattern = r'location /aiui/api/claude/ \{[^}]*(?:\{[^}]*\}[^}]*)*\}'
|
||||
content = re.sub(pattern, new_block, content)
|
||||
|
||||
with open("/etc/nginx/sites-available/archipelago", "w") as f:
|
||||
f.write(content)
|
||||
|
||||
# Verify
|
||||
count = content.count("api.anthropic.com")
|
||||
print(f" Patched {count // 2} Claude API proxy blocks (HTTP + HTTPS)")
|
||||
PYSCRIPT
|
||||
|
||||
scp $SSH_OPTS /tmp/patch-nginx-claude.py "$TARGET_HOST:/tmp/patch-nginx-claude.py"
|
||||
ssh $SSH_OPTS "$TARGET_HOST" "sudo python3 /tmp/patch-nginx-claude.py '$ANTHROPIC_API_KEY'"
|
||||
|
||||
# Test and reload nginx
|
||||
echo " Testing nginx config..."
|
||||
ssh $SSH_OPTS "$TARGET_HOST" "sudo nginx -t 2>&1 && sudo systemctl reload nginx && echo ' Nginx reloaded OK'" || {
|
||||
echo " ERROR: nginx config test failed!"
|
||||
exit 1
|
||||
}
|
||||
|
||||
# --- Step 3: Fix FileBrowser container ---
|
||||
echo ""
|
||||
echo "$(timestamp) 📁 Checking FileBrowser..."
|
||||
|
||||
FB_STATUS=$(ssh $SSH_OPTS "$TARGET_HOST" "podman inspect filebrowser 2>/dev/null | grep -oP '\"ReadonlyRootfs\":\s*\K\w+'" 2>/dev/null || echo "not_found")
|
||||
|
||||
if [ "$FB_STATUS" = "true" ]; then
|
||||
echo " FileBrowser has read-only root — recreating..."
|
||||
ssh $SSH_OPTS "$TARGET_HOST" "
|
||||
podman stop filebrowser 2>/dev/null
|
||||
podman rm filebrowser 2>/dev/null
|
||||
sudo mkdir -p /var/lib/archipelago/filebrowser
|
||||
podman run -d --name filebrowser --restart=always \
|
||||
-p 8083:80 \
|
||||
-v /var/lib/archipelago/filebrowser:/srv \
|
||||
filebrowser/filebrowser:v2.27.0
|
||||
" 2>&1 | tail -2
|
||||
echo " FileBrowser recreated."
|
||||
elif [ "$FB_STATUS" = "not_found" ]; then
|
||||
echo " FileBrowser not found — creating..."
|
||||
ssh $SSH_OPTS "$TARGET_HOST" "
|
||||
sudo mkdir -p /var/lib/archipelago/filebrowser
|
||||
podman run -d --name filebrowser --restart=always \
|
||||
-p 8083:80 \
|
||||
-v /var/lib/archipelago/filebrowser:/srv \
|
||||
filebrowser/filebrowser:v2.27.0
|
||||
" 2>&1 | tail -2
|
||||
echo " FileBrowser created."
|
||||
else
|
||||
echo " FileBrowser OK (ReadonlyRootfs: $FB_STATUS)"
|
||||
fi
|
||||
|
||||
# --- Step 4: Verify ---
|
||||
# --- Verify ---
|
||||
echo ""
|
||||
echo "$(timestamp) ✅ Verification..."
|
||||
ssh $SSH_OPTS "$TARGET_HOST" "
|
||||
echo \" AIUI index: \$(ls -la /opt/archipelago/web-ui/aiui/index.html 2>/dev/null | awk '{print \$6,\$7,\$8}')\"
|
||||
echo \" FileBrowser: \$(podman ps --format '{{.Names}} {{.Status}}' | grep filebrowser)\"
|
||||
echo \" Nginx: \$(systemctl is-active nginx)\"
|
||||
echo \" Backend: \$(systemctl is-active archipelago)\"
|
||||
echo \" Claude API test: \$(curl -s -o /dev/null -w '%{http_code}' -X POST http://localhost/aiui/api/claude/v1/messages -H 'Content-Type: application/json' -H 'Cookie: session=test' -d '{\"model\":\"claude-sonnet-4-20250514\",\"max_tokens\":5,\"messages\":[{\"role\":\"user\",\"content\":\"hi\"}]}')\"
|
||||
"
|
||||
|
||||
echo ""
|
||||
echo "$(timestamp) Done! Server configured."
|
||||
echo "$(timestamp) Done! AIUI deployed."
|
||||
echo " Set the Claude API key (if not already set) via Settings > AIUI in"
|
||||
echo " neode-ui — it now lives only at <data_dir>/secrets/claude-api-key."
|
||||
echo " Access: http://$(echo $TARGET_HOST | cut -d@ -f2)"
|
||||
|
||||
Reference in New Issue
Block a user