Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,10 @@

## [Unreleased]

### Fixed

- **`UTEKE_HOME` pointing at another store no longer routes to a running server (#1445)** - with `[server] enabled = true` and `uteke-serve` running, `UTEKE_HOME=/tmp/iso uteke remember ...` was sent to the server and landed in the server's store instead of `/tmp/iso`. The server now reports which store it serves on the new `GET /store` (LAB route, token required when auth is on): `{"id": "<16 hex digits>"}`, a SHA-256 prefix of the store's canonical path, never the path itself. Before routing to a loopback server the CLI compares that id with the store it resolved and runs locally on a mismatch (with a warning on stderr). The probe sends `UTEKE_AUTH_TOKEN` like any routed command; without it an auth-enabled server refuses the command with 401, so nothing lands in the wrong store. A server without the route (404), an unreachable one or a non-loopback one keeps the previous routing. `docs/core-contract.json` gains `/store` in the `lab` list only.

### Changed

- **`tools/list` for a read-only token lists only the tools it can call (#1446)** - MCP over HTTP advertised every tool to read-only callers, which then failed at call time. A read-only caller now sees exactly the read tools; full-access callers, the stdio transport and `tools/call` are unchanged. Contract: uteke-cloud#203 (approved by the owner).
Expand Down
2 changes: 1 addition & 1 deletion crates/uteke-cli/src/commands/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ use crate::cli::FeedbackAction;
use crate::resolve_namespace;
use uteke_core::Uteke;

pub(crate) use server::{is_server_running, run_via_server};
pub(crate) use server::{is_server_running, run_via_server, server_serves_store};

/// Deprecation notice (#1427, phase 1) for write commands that target a
/// non-default namespace (`--namespace`, `UTEKE_NAMESPACE` or config). The
Expand Down
168 changes: 168 additions & 0 deletions crates/uteke-cli/src/commands/server.rs
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,53 @@ pub(crate) fn is_server_running(url: &str) -> bool {
.unwrap_or(false)
}

/// Whether the server at `url` serves the store at `store` (#1445).
///
/// Asks `GET /store` for the served store's id and compares it with the id of
/// the store this invocation resolved (`UTEKE_HOME`, `--store`, `uteke.toml`).
/// A mismatch returns `false` so the command runs against its own store
/// instead of silently reading and writing the server's. Anything that stops
/// the comparison (a server older than this route answers 404, a non-loopback
/// server whose paths are not ours, a timeout, a body that is not an id)
/// returns `true`: routing then behaves as before.
///
/// The probe goes through [`build_client`], so it carries `UTEKE_AUTH_TOKEN`
/// exactly like the command it guards. Without a token an auth-enabled
/// server answers 401 here and the routed command is refused with 401 as
/// well, so nothing can land in the wrong store on that path.
pub(crate) fn server_serves_store(url: &str, store: &str) -> bool {
if !is_loopback_url(url) {
return true;
}
let Ok(resp) = build_client(url)
.get(format!("{url}/store"))
.timeout(std::time::Duration::from_millis(500))
.send()
else {
return true;
};
if !resp.status().is_success() {
return true;
}
let Some(served) = resp
.json::<serde_json::Value>()
.ok()
.and_then(|v| v["id"].as_str().map(str::to_string))
else {
return true;
};
let ours = uteke_core::store_identity(std::path::Path::new(
&crate::config::Config::expand_tilde(store),
));
if served == ours {
return true;
}
tracing::warn!(
"the server at {url} serves a different store than {store}; using the local store"
);
false
}

/// Send a request and check HTTP status before parsing JSON.
fn parse_response<T: serde::de::DeserializeOwned>(
resp: reqwest::blocking::Response,
Expand Down Expand Up @@ -502,3 +549,124 @@ mod recall_flag_tests {
assert_eq!(recall_flags_unsupported_by_server(f), ["--salience"]);
}
}

#[cfg(test)]
#[allow(unsafe_code)] // env var mutation (edition 2024) in one serialized test
mod store_match_tests {
use super::server_serves_store;
use std::io::{Read, Write};
use std::net::TcpListener;
use std::sync::Mutex;

/// The probe reads `UTEKE_AUTH_TOKEN`, so the tests of this module run one
/// at a time.
static ENV: Mutex<()> = Mutex::new(());
fn serialized() -> std::sync::MutexGuard<'static, ()> {
ENV.lock().unwrap_or_else(|e| e.into_inner())
}

/// Serve one canned HTTP response on a loopback port; returns its base URL.
fn stub(status: &str, body: &str) -> String {
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
let url = format!("http://{}", listener.local_addr().unwrap());
let reply = format!(
"HTTP/1.1 {status}\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}",
body.len()
);
std::thread::spawn(move || {
if let Ok((mut conn, _)) = listener.accept() {
let mut buf = [0u8; 2048];
let _ = conn.read(&mut buf);
let _ = conn.write_all(reply.as_bytes());
}
});
url
}

/// Like `stub`, but also hands back the raw request the server received.
fn capturing_stub(body: &str) -> (String, std::sync::mpsc::Receiver<String>) {
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
let url = format!("http://{}", listener.local_addr().unwrap());
let reply = format!(
"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}",
body.len()
);
let (tx, rx) = std::sync::mpsc::channel();
std::thread::spawn(move || {
if let Ok((mut conn, _)) = listener.accept() {
let mut buf = [0u8; 4096];
let n = conn.read(&mut buf).unwrap_or(0);
let _ = tx.send(String::from_utf8_lossy(&buf[..n]).to_string());
let _ = conn.write_all(reply.as_bytes());
}
});
(url, rx)
}

fn id_body(store: &std::path::Path) -> String {
format!(r#"{{"id":"{}"}}"#, uteke_core::store_identity(store))
}

#[test]
fn the_same_store_keeps_routing() {
let _g = serialized();
let dir = tempfile::tempdir().unwrap();
let url = stub("200 OK", &id_body(dir.path()));
assert!(server_serves_store(&url, dir.path().to_str().unwrap()));
}

#[test]
fn another_store_is_not_routed_to_the_server() {
let _g = serialized();
let served = tempfile::tempdir().unwrap();
let ours = tempfile::tempdir().unwrap();
let url = stub("200 OK", &id_body(served.path()));
assert!(!server_serves_store(&url, ours.path().to_str().unwrap()));
}

#[test]
fn anything_that_blocks_the_comparison_keeps_the_old_routing() {
let _g = serialized();
let dir = tempfile::tempdir().unwrap();
let store = dir.path().to_str().unwrap();
// A server older than GET /store answers 404.
assert!(server_serves_store(&stub("404 Not Found", "{}"), store));
// 401 (no token) and a body that is not an id.
assert!(server_serves_store(&stub("401 Unauthorized", "{}"), store));
assert!(server_serves_store(&stub("200 OK", "not json"), store));
assert!(server_serves_store(&stub("200 OK", r#"{"id":7}"#), store));
// Nothing listening.
let gone = TcpListener::bind("127.0.0.1:0").unwrap();
let url = format!("http://{}", gone.local_addr().unwrap());
drop(gone);
assert!(server_serves_store(&url, store));
}

#[test]
fn a_non_loopback_server_is_never_asked() {
let _g = serialized();
// Its paths are not ours, so the ids could never match; no request is made.
// TEST-NET-1 address: never routable, so a stray request would time out.
let url = format!("{}://{}", "http", "192.0.2.1:9");
assert!(server_serves_store(&url, "/tmp/anything"));
}

#[test]
fn the_probe_carries_the_auth_token_like_the_command_it_guards() {
let _g = serialized();
let dir = tempfile::tempdir().unwrap();
let (url, request) = capturing_stub(&id_body(dir.path()));
// SAFETY: serialized by `ENV`; no other thread of this crate reads the
// variable while the test runs.
unsafe { std::env::set_var("UTEKE_AUTH_TOKEN", "probe-token") };
let same = server_serves_store(&url, dir.path().to_str().unwrap());
unsafe { std::env::remove_var("UTEKE_AUTH_TOKEN") };
assert!(same);
let raw = request
.recv_timeout(std::time::Duration::from_secs(2))
.expect("the stub saw a request")
.to_ascii_lowercase();
assert!(raw.starts_with("get /store "), "{raw}");
assert!(raw.contains("authorization: bearer probe-token"), "{raw}");
}
}
5 changes: 4 additions & 1 deletion crates/uteke-cli/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -166,9 +166,12 @@ fn main() {
// Check if uteke server is running — if so, route via HTTP for <50ms latency
let server_url = format!("http://{}:{}", config.server.host, config.server.port);
// Cheap check first: no network probe when --store names another store.
// Then the server must serve the store this invocation resolved (#1445):
// `UTEKE_HOME` pointing elsewhere is not sent to a server for another store.
let server_available =
should_route_via_server(&cli, &default_store_path(&config), config.server.enabled)
&& commands::is_server_running(&server_url);
&& commands::is_server_running(&server_url)
&& commands::server_serves_store(&server_url, &resolve_store_path(&cli, &config));

if server_available {
tracing::info!("Server detected at {server_url}, routing via HTTP");
Expand Down
50 changes: 50 additions & 0 deletions crates/uteke-core/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -412,6 +412,22 @@ fn copy_dir_recursive(src: &Path, dst: &Path) -> Result<(), Error> {
Ok(())
}

/// Identity of the store rooted at `home`: the first 16 hex digits of the
/// SHA-256 of its canonical path (the lexical path when it does not exist
/// yet, trailing slashes trimmed). `GET /store` reports it so a client can tell
/// whether a running server serves the store it resolved (#1445) without the
/// server revealing its path. Two spellings of one directory give the same id.
pub fn store_identity(home: &std::path::Path) -> String {
use sha2::{Digest, Sha256};
let canonical = std::fs::canonicalize(home).unwrap_or_else(|_| {
let lossy = home.to_string_lossy();
let trimmed = lossy.trim_end_matches('/');
PathBuf::from(if trimmed.is_empty() { "/" } else { trimmed })
});
let digest = Sha256::digest(canonical.to_string_lossy().as_bytes());
digest.iter().take(8).map(|b| format!("{b:02x}")).collect()
}

/// ```text
/// UTEKE_HOME=/data → /data
/// (not set) → ~/.codecora/uteke (auto-migrates from ~/.uteke)
Expand Down Expand Up @@ -4567,3 +4583,37 @@ mod fallback_settings_tests {
assert_eq!(got.endpoint_path, "/env-path");
}
}

#[cfg(test)]
mod store_identity_tests {
use super::store_identity;

#[test]
fn spellings_of_one_directory_share_an_id_and_directories_differ() {
let a = tempfile::tempdir().unwrap();
let b = tempfile::tempdir().unwrap();
let plain = store_identity(a.path());
assert_eq!(plain.len(), 16);
assert!(plain.bytes().all(|c| c.is_ascii_hexdigit()));
let with_slash = format!("{}/", a.path().display());
assert_eq!(store_identity(std::path::Path::new(&with_slash)), plain);
let dotted = a.path().join(".");
assert_eq!(store_identity(&dotted), plain);
assert_ne!(store_identity(b.path()), plain);
}

#[test]
fn a_missing_directory_is_identified_by_its_trimmed_path() {
let x = store_identity(std::path::Path::new("/nonexistent/uteke-a"));
assert_eq!(
x,
store_identity(std::path::Path::new("/nonexistent/uteke-a/"))
);
assert_ne!(
x,
store_identity(std::path::Path::new("/nonexistent/uteke-b"))
);
// Pinned value: the id is part of the wire format of GET /store.
assert_eq!(x, "a424b195034e711e");
}
}
11 changes: 11 additions & 0 deletions crates/uteke-server/src/api_registry.rs
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,17 @@ pub const ENDPOINTS: &[Endpoint] = &[
excludes_deprecated: false,
issues: &["#1289"],
},
Endpoint {
method: "GET",
path: "/store",
tier: Tier::Lab,
writes: false,
description: "Identity of the store this server serves: {\"id\": \"<16 hex digits>\"}, the SHA-256 prefix of the store's canonical path (`uteke_core::store_identity`). The CLI compares it with the store it resolved before routing a command here, so `UTEKE_HOME` pointing elsewhere is not sent to this server (#1445). The path itself is never returned. Needs the usual token when auth is on.",
request_type: None,
response_type: None,
excludes_deprecated: false,
issues: &["#1445"],
},
Endpoint {
method: "GET",
path: "/namespaces",
Expand Down
4 changes: 4 additions & 0 deletions crates/uteke-server/src/context.rs
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,9 @@ pub struct ReqCtx {
pub extraction_config: Option<uteke_core::extraction::ExtractionConfig>,
/// `Host` header allowlist against DNS rebinding (#1326).
pub host_guard: HostGuard,
/// Identity of the served store (`uteke_core::store_identity`), reported by
/// `GET /store` so a client can tell which store this server serves (#1445).
pub store_id: String,
}

/// `Host` header policy against DNS rebinding (#1326).
Expand Down Expand Up @@ -365,6 +368,7 @@ mod cors_tests {
recall_config: None,
extraction_config: None,
host_guard: HostGuard::default(),
store_id: String::new(),
}
}

Expand Down
13 changes: 12 additions & 1 deletion crates/uteke-server/src/handlers/health.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
//! Health, guide and route-introspection handlers.
//! Health, guide, route-introspection and store-identity handlers.

use super::*;

Expand Down Expand Up @@ -62,3 +62,14 @@ pub(super) fn get_routes(c: &mut Call) -> Response<Cursor<Vec<u8>>> {
let req = &mut *c.req;
ctx.ok_response_for(req, &api_registry::ENDPOINTS)
}

/// `GET /store` (#1445): which store this server serves, as an id, never a path.
pub(super) fn get_store(c: &mut Call) -> Response<Cursor<Vec<u8>>> {
let ctx = c.ctx;
let req = &mut *c.req;
#[derive(serde::Serialize)]
struct StoreResponse<'a> {
id: &'a str,
}
ctx.ok_response_for(req, &StoreResponse { id: &ctx.store_id })
}
Loading
Loading