mirror of
https://github.com/samvallad33/vestige.git
synced 2026-05-08 23:32:37 +02:00
fix: v2.0.1 release — fix broken installs, CI, security, and docs
Critical fixes: - npm postinstall.js: BINARY_VERSION '1.1.3' → '2.0.1' (every install was 404ing) - npm package name: corrected error messages to 'vestige-mcp-server' - README: npm install command pointed to wrong package - MSRV: bumped from 1.85 to 1.91 (uses floor_char_boundary from 1.91) - CI: removed stale 'develop' branch from test.yml triggers Security hardening: - CSP: restricted connect-src from wildcard 'ws: wss:' to localhost-only - Added X-Frame-Options, X-Content-Type-Options, Referrer-Policy, Permissions-Policy headers - Added frame-ancestors 'none', base-uri 'self', form-action 'self' to CSP - Capped retention_distribution endpoint from 10k to 1k nodes - Added debug logging for WebSocket connections without Origin header Maintenance: - All clippy warnings fixed (58 total: redundant closures, collapsible ifs, no-op casts) - All versions harmonized to 2.0.1 across Cargo.toml and package.json - CLAUDE.md updated to match v2.0.1 (21 tools, 29 modules, 1238 tests) - docs/CLAUDE-SETUP.md updated deprecated function names - License corrected to AGPL-3.0-only in root package.json 1,238 tests passing, 0 clippy warnings. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
b03df324da
commit
c6090dc2ba
51 changed files with 343 additions and 490 deletions
|
|
@ -1,6 +1,6 @@
|
|||
[package]
|
||||
name = "vestige-mcp"
|
||||
version = "2.0.0"
|
||||
version = "2.0.1"
|
||||
edition = "2024"
|
||||
description = "Cognitive memory MCP server for Claude - FSRS-6, spreading activation, synaptic tagging, 3D dashboard, and 130 years of memory research"
|
||||
authors = ["samvallad33"]
|
||||
|
|
@ -32,7 +32,7 @@ path = "src/bin/cli.rs"
|
|||
# ============================================================================
|
||||
# Includes: FSRS-6, spreading activation, synaptic tagging, hippocampal indexing,
|
||||
# memory states, context memory, importance signals, dreams, and more
|
||||
vestige-core = { version = "2.0.0", path = "../vestige-core" }
|
||||
vestige-core = { version = "2.0.1", path = "../vestige-core" }
|
||||
|
||||
# ============================================================================
|
||||
# MCP Server Dependencies
|
||||
|
|
@ -61,9 +61,6 @@ tracing-subscriber = { version = "0.3", features = ["env-filter", "json"] }
|
|||
# Platform directories
|
||||
directories = "6"
|
||||
|
||||
# Official Anthropic MCP Rust SDK
|
||||
rmcp = "0.14"
|
||||
|
||||
# CLI
|
||||
clap = { version = "4", features = ["derive"] }
|
||||
colored = "3"
|
||||
|
|
@ -71,7 +68,7 @@ colored = "3"
|
|||
# SQLite (for backup WAL checkpoint)
|
||||
rusqlite = { version = "0.38", features = ["bundled"] }
|
||||
|
||||
# Dashboard (v1.2) - hyper/tower already in Cargo.lock via rmcp/reqwest
|
||||
# Dashboard (v2.0) - HTTP server + WebSocket + embedded SvelteKit
|
||||
axum = { version = "0.8", default-features = false, features = ["json", "query", "tokio", "http1", "ws"] }
|
||||
tower = { version = "0.5", features = ["limit"] }
|
||||
tower-http = { version = "0.6", features = ["cors", "set-header"] }
|
||||
|
|
|
|||
|
|
@ -829,9 +829,10 @@ pub async fn trigger_consolidation(
|
|||
pub async fn retention_distribution(
|
||||
State(state): State<AppState>,
|
||||
) -> Result<Json<Value>, StatusCode> {
|
||||
// Cap at 1000 to prevent excessive memory usage on large databases
|
||||
let nodes = state
|
||||
.storage
|
||||
.get_all_nodes(10000, 0)
|
||||
.get_all_nodes(1000, 0)
|
||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||
|
||||
// Build distribution buckets
|
||||
|
|
|
|||
|
|
@ -48,22 +48,23 @@ pub fn build_router_with_event_tx(
|
|||
|
||||
fn build_router_inner(state: AppState, port: u16) -> (Router, AppState) {
|
||||
|
||||
let origins = vec![
|
||||
#[allow(unused_mut)]
|
||||
let mut origins = vec![
|
||||
format!("http://127.0.0.1:{}", port)
|
||||
.parse::<axum::http::HeaderValue>()
|
||||
.expect("valid origin"),
|
||||
format!("http://localhost:{}", port)
|
||||
.parse::<axum::http::HeaderValue>()
|
||||
.expect("valid origin"),
|
||||
// SvelteKit dev server
|
||||
"http://localhost:5173"
|
||||
.parse::<axum::http::HeaderValue>()
|
||||
.expect("valid origin"),
|
||||
"http://127.0.0.1:5173"
|
||||
.parse::<axum::http::HeaderValue>()
|
||||
.expect("valid origin"),
|
||||
];
|
||||
|
||||
// SvelteKit dev server — only in debug builds
|
||||
#[cfg(debug_assertions)]
|
||||
{
|
||||
origins.push("http://localhost:5173".parse::<axum::http::HeaderValue>().expect("valid origin"));
|
||||
origins.push("http://127.0.0.1:5173".parse::<axum::http::HeaderValue>().expect("valid origin"));
|
||||
}
|
||||
|
||||
let cors = CorsLayer::new()
|
||||
.allow_origin(AllowOrigin::list(origins))
|
||||
.allow_methods([
|
||||
|
|
@ -77,11 +78,39 @@ fn build_router_inner(state: AppState, port: u16) -> (Router, AppState) {
|
|||
axum::http::header::AUTHORIZATION,
|
||||
]);
|
||||
|
||||
// Security: restrict WebSocket connections to localhost only (prevents cross-site WS hijacking)
|
||||
let csp_value = format!(
|
||||
"default-src 'self'; \
|
||||
script-src 'self' 'unsafe-inline'; \
|
||||
style-src 'self' 'unsafe-inline'; \
|
||||
img-src 'self' blob: data:; \
|
||||
connect-src 'self' ws://127.0.0.1:{port} ws://localhost:{port}; \
|
||||
font-src 'self' data:; \
|
||||
frame-ancestors 'none'; \
|
||||
base-uri 'self'; \
|
||||
form-action 'self';"
|
||||
);
|
||||
let csp = SetResponseHeaderLayer::overriding(
|
||||
axum::http::header::CONTENT_SECURITY_POLICY,
|
||||
axum::http::HeaderValue::from_static(
|
||||
"default-src 'self' 'unsafe-inline' 'unsafe-eval' blob: data: ws: wss:",
|
||||
),
|
||||
axum::http::HeaderValue::from_str(&csp_value).expect("valid CSP header"),
|
||||
);
|
||||
|
||||
// Additional security headers
|
||||
let x_frame_options = SetResponseHeaderLayer::overriding(
|
||||
axum::http::header::X_FRAME_OPTIONS,
|
||||
axum::http::HeaderValue::from_static("DENY"),
|
||||
);
|
||||
let x_content_type_options = SetResponseHeaderLayer::overriding(
|
||||
axum::http::header::X_CONTENT_TYPE_OPTIONS,
|
||||
axum::http::HeaderValue::from_static("nosniff"),
|
||||
);
|
||||
let referrer_policy = SetResponseHeaderLayer::overriding(
|
||||
axum::http::HeaderName::from_static("referrer-policy"),
|
||||
axum::http::HeaderValue::from_static("strict-origin-when-cross-origin"),
|
||||
);
|
||||
let permissions_policy = SetResponseHeaderLayer::overriding(
|
||||
axum::http::HeaderName::from_static("permissions-policy"),
|
||||
axum::http::HeaderValue::from_static("camera=(), microphone=(), geolocation=()"),
|
||||
);
|
||||
|
||||
let router = Router::new()
|
||||
|
|
@ -121,7 +150,11 @@ fn build_router_inner(state: AppState, port: u16) -> (Router, AppState) {
|
|||
ServiceBuilder::new()
|
||||
.concurrency_limit(50)
|
||||
.layer(cors)
|
||||
.layer(csp),
|
||||
.layer(csp)
|
||||
.layer(x_frame_options)
|
||||
.layer(x_content_type_options)
|
||||
.layer(referrer_policy)
|
||||
.layer(permissions_policy),
|
||||
)
|
||||
.with_state(state.clone());
|
||||
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@
|
|||
|
||||
use axum::extract::ws::{Message, WebSocket, WebSocketUpgrade};
|
||||
use axum::extract::State;
|
||||
use axum::http::{HeaderMap, StatusCode};
|
||||
use axum::response::IntoResponse;
|
||||
use chrono::Utc;
|
||||
use futures_util::{SinkExt, StreamExt};
|
||||
|
|
@ -15,11 +16,33 @@ use super::events::VestigeEvent;
|
|||
use super::state::AppState;
|
||||
|
||||
/// WebSocket upgrade handler — GET /ws
|
||||
/// Validates Origin header to prevent cross-site WebSocket hijacking.
|
||||
pub async fn ws_handler(
|
||||
headers: HeaderMap,
|
||||
ws: WebSocketUpgrade,
|
||||
State(state): State<AppState>,
|
||||
) -> impl IntoResponse {
|
||||
ws.on_upgrade(move |socket| handle_socket(socket, state))
|
||||
// Validate Origin header (browsers always send it for WebSocket upgrades).
|
||||
// Non-browser clients (curl, wscat) won't have Origin — allowed since localhost-only.
|
||||
match headers.get("origin").and_then(|v| v.to_str().ok()) {
|
||||
Some(origin) => {
|
||||
let allowed = origin.starts_with("http://127.0.0.1:")
|
||||
|| origin.starts_with("http://localhost:");
|
||||
#[cfg(debug_assertions)]
|
||||
let allowed = allowed || origin == "http://localhost:5173" || origin == "http://127.0.0.1:5173";
|
||||
if !allowed {
|
||||
warn!("Rejected WebSocket connection from origin: {}", origin);
|
||||
return StatusCode::FORBIDDEN.into_response();
|
||||
}
|
||||
}
|
||||
None => {
|
||||
debug!("WebSocket connection without Origin header (non-browser client)");
|
||||
}
|
||||
}
|
||||
ws.max_frame_size(64 * 1024)
|
||||
.max_message_size(256 * 1024)
|
||||
.on_upgrade(move |socket| handle_socket(socket, state))
|
||||
.into_response()
|
||||
}
|
||||
|
||||
async fn handle_socket(socket: WebSocket, state: AppState) {
|
||||
|
|
|
|||
|
|
@ -850,14 +850,14 @@ impl McpServer {
|
|||
match tool_name {
|
||||
// -- smart_ingest: memory created/updated --
|
||||
"smart_ingest" | "ingest" | "session_checkpoint" => {
|
||||
// Single mode: result has "action" (created/updated/superseded/reinforced)
|
||||
if let Some(action) = result.get("action").and_then(|a| a.as_str()) {
|
||||
// Single mode: result has "decision" (create/update/supersede/reinforce/merge/replace/add_context)
|
||||
if let Some(decision) = result.get("decision").and_then(|a| a.as_str()) {
|
||||
let id = result.get("nodeId").or(result.get("id"))
|
||||
.and_then(|v| v.as_str()).unwrap_or("").to_string();
|
||||
let preview = result.get("contentPreview").or(result.get("content"))
|
||||
.and_then(|v| v.as_str()).unwrap_or("").to_string();
|
||||
match action {
|
||||
"created" => {
|
||||
match decision {
|
||||
"create" => {
|
||||
let node_type = result.get("nodeType")
|
||||
.and_then(|v| v.as_str()).unwrap_or("fact").to_string();
|
||||
let tags = result.get("tags")
|
||||
|
|
@ -868,9 +868,9 @@ impl McpServer {
|
|||
id, content_preview: preview, node_type, tags, timestamp: now,
|
||||
});
|
||||
}
|
||||
"updated" | "superseded" | "reinforced" => {
|
||||
"update" | "supersede" | "reinforce" | "merge" | "replace" | "add_context" => {
|
||||
self.emit(VestigeEvent::MemoryUpdated {
|
||||
id, content_preview: preview, field: action.to_string(), timestamp: now,
|
||||
id, content_preview: preview, field: decision.to_string(), timestamp: now,
|
||||
});
|
||||
}
|
||||
_ => {}
|
||||
|
|
@ -879,20 +879,20 @@ impl McpServer {
|
|||
// Batch mode: result has "results" array
|
||||
if let Some(results) = result.get("results").and_then(|r| r.as_array()) {
|
||||
for item in results {
|
||||
let action = item.get("action").and_then(|a| a.as_str()).unwrap_or("");
|
||||
let decision = item.get("decision").and_then(|a| a.as_str()).unwrap_or("");
|
||||
let id = item.get("nodeId").or(item.get("id"))
|
||||
.and_then(|v| v.as_str()).unwrap_or("").to_string();
|
||||
let preview = item.get("contentPreview")
|
||||
.and_then(|v| v.as_str()).unwrap_or("").to_string();
|
||||
if action == "created" {
|
||||
if decision == "create" {
|
||||
self.emit(VestigeEvent::MemoryCreated {
|
||||
id, content_preview: preview,
|
||||
node_type: "fact".to_string(), tags: vec![], timestamp: now,
|
||||
});
|
||||
} else if !action.is_empty() {
|
||||
} else if !decision.is_empty() {
|
||||
self.emit(VestigeEvent::MemoryUpdated {
|
||||
id, content_preview: preview,
|
||||
field: action.to_string(), timestamp: now,
|
||||
field: decision.to_string(), timestamp: now,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -1000,7 +1000,7 @@ impl McpServer {
|
|||
let preview = args.as_ref()
|
||||
.and_then(|a| a.get("content"))
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|s| if s.len() > 100 { format!("{}...", &s[..100]) } else { s.to_string() })
|
||||
.map(|s| if s.len() > 100 { format!("{}...", &s[..s.floor_char_boundary(100)]) } else { s.to_string() })
|
||||
.unwrap_or_default();
|
||||
let composite = result.get("compositeScore")
|
||||
.or(result.get("composite_score"))
|
||||
|
|
|
|||
|
|
@ -72,10 +72,10 @@ pub async fn execute(
|
|||
|
||||
if let Some(ref memory_id) = args.memory_id {
|
||||
// Per-memory mode: state transitions for a specific memory
|
||||
execute_per_memory(&storage, memory_id, limit)
|
||||
execute_per_memory(storage, memory_id, limit)
|
||||
} else {
|
||||
// System-wide mode: consolidations + recent transitions
|
||||
execute_system_wide(&storage, limit)
|
||||
execute_system_wide(storage, limit)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -204,7 +204,7 @@ async fn execute_remember_decision(
|
|||
// Build content with structured format (ADR-like)
|
||||
let mut content = format!(
|
||||
"# Decision: {}\n\n## Context\n\n{}\n\n## Decision\n\n{}",
|
||||
&decision[..decision.len().min(50)],
|
||||
&decision[..decision.floor_char_boundary(50)],
|
||||
rationale,
|
||||
decision
|
||||
);
|
||||
|
|
|
|||
|
|
@ -219,7 +219,7 @@ pub async fn execute(
|
|||
.map(|n| {
|
||||
let c = n.content.replace('\n', " ");
|
||||
if c.len() > 120 {
|
||||
format!("{}...", &c[..120])
|
||||
format!("{}...", &c[..c.floor_char_boundary(120)])
|
||||
} else {
|
||||
c
|
||||
}
|
||||
|
|
|
|||
|
|
@ -96,7 +96,7 @@ pub async fn execute(
|
|||
if intent_result.confidence > 0.5 {
|
||||
let intent_tag = format!("intent:{:?}", intent_result.primary_intent);
|
||||
let intent_tag = if intent_tag.len() > 50 {
|
||||
format!("{}...", &intent_tag[..47])
|
||||
format!("{}...", &intent_tag[..intent_tag.floor_char_boundary(47)])
|
||||
} else {
|
||||
intent_tag
|
||||
};
|
||||
|
|
|
|||
|
|
@ -297,7 +297,7 @@ async fn execute_set(
|
|||
if intent_result.confidence > 0.5 {
|
||||
let intent_tag = format!("intent:{:?}", intent_result.primary_intent);
|
||||
let intent_tag = if intent_tag.len() > 50 {
|
||||
format!("{}...", &intent_tag[..47])
|
||||
format!("{}...", &intent_tag[..intent_tag.floor_char_boundary(47)])
|
||||
} else {
|
||||
intent_tag
|
||||
};
|
||||
|
|
|
|||
|
|
@ -249,7 +249,7 @@ pub async fn execute_system_status(
|
|||
let last_dream = storage.get_last_dream().ok().flatten();
|
||||
let saves_since_last_dream = match &last_dream {
|
||||
Some(dt) => storage.count_memories_since(*dt).unwrap_or(0),
|
||||
None => stats.total_nodes as i64,
|
||||
None => stats.total_nodes,
|
||||
};
|
||||
let last_backup = Storage::get_last_backup_timestamp();
|
||||
|
||||
|
|
|
|||
|
|
@ -37,8 +37,10 @@ pub mod session_context;
|
|||
pub mod health;
|
||||
pub mod graph;
|
||||
|
||||
// Deprecated tools - kept for internal backwards compatibility
|
||||
// These modules are intentionally unused in the public API
|
||||
// Deprecated/internal tools — not advertised in the public MCP tools/list,
|
||||
// but some functions are actively dispatched for backwards compatibility
|
||||
// and internal cognitive operations. #[allow(dead_code)] suppresses warnings
|
||||
// for the unused schema/struct items within these modules.
|
||||
#[allow(dead_code)]
|
||||
pub mod checkpoint;
|
||||
#[allow(dead_code)]
|
||||
|
|
|
|||
|
|
@ -218,7 +218,7 @@ pub async fn execute(
|
|||
let last_dream = storage.get_last_dream().ok().flatten();
|
||||
let saves_since_last_dream = match &last_dream {
|
||||
Some(dt) => storage.count_memories_since(*dt).unwrap_or(0),
|
||||
None => stats.total_nodes as i64,
|
||||
None => stats.total_nodes,
|
||||
};
|
||||
let last_backup = Storage::get_last_backup_timestamp();
|
||||
let now = Utc::now();
|
||||
|
|
@ -333,8 +333,8 @@ pub async fn execute(
|
|||
// ====================================================================
|
||||
// 5. Codebase patterns/decisions (if codebase specified)
|
||||
// ====================================================================
|
||||
if let Some(ref ctx) = args.context {
|
||||
if let Some(ref codebase) = ctx.codebase {
|
||||
if let Some(ref ctx) = args.context
|
||||
&& let Some(ref codebase) = ctx.codebase {
|
||||
let codebase_tag = format!("codebase:{}", codebase);
|
||||
let mut cb_lines: Vec<String> = Vec::new();
|
||||
|
||||
|
|
@ -368,7 +368,6 @@ pub async fn execute(
|
|||
context_parts.push(format!("**Codebase ({}):**\n{}", codebase, cb_lines.join("\n")));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ====================================================================
|
||||
// 6. Assemble final response
|
||||
|
|
@ -404,11 +403,10 @@ fn check_intention_triggered(
|
|||
|
||||
match trigger.trigger_type.as_deref() {
|
||||
Some("time") => {
|
||||
if let Some(ref at) = trigger.at {
|
||||
if let Ok(trigger_time) = DateTime::parse_from_rfc3339(at) {
|
||||
if let Some(ref at) = trigger.at
|
||||
&& let Ok(trigger_time) = DateTime::parse_from_rfc3339(at) {
|
||||
return trigger_time.with_timezone(&Utc) <= now;
|
||||
}
|
||||
}
|
||||
if let Some(mins) = trigger.in_minutes {
|
||||
let trigger_time = intention.created_at + Duration::minutes(mins);
|
||||
return trigger_time <= now;
|
||||
|
|
@ -418,29 +416,25 @@ fn check_intention_triggered(
|
|||
Some("context") => {
|
||||
// Check codebase match
|
||||
if let (Some(trigger_cb), Some(current_cb)) = (&trigger.codebase, &ctx.codebase)
|
||||
{
|
||||
if current_cb
|
||||
&& current_cb
|
||||
.to_lowercase()
|
||||
.contains(&trigger_cb.to_lowercase())
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
// Check file pattern match
|
||||
if let (Some(pattern), Some(file)) = (&trigger.file_pattern, &ctx.file) {
|
||||
if file.contains(pattern.as_str()) {
|
||||
if let (Some(pattern), Some(file)) = (&trigger.file_pattern, &ctx.file)
|
||||
&& file.contains(pattern.as_str()) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
// Check topic match
|
||||
if let (Some(topic), Some(topics)) = (&trigger.topic, &ctx.topics) {
|
||||
if topics
|
||||
if let (Some(topic), Some(topics)) = (&trigger.topic, &ctx.topics)
|
||||
&& topics
|
||||
.iter()
|
||||
.any(|t| t.to_lowercase().contains(&topic.to_lowercase()))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
_ => false,
|
||||
|
|
|
|||
|
|
@ -165,7 +165,7 @@ pub async fn execute(
|
|||
let intent_tag = format!("intent:{:?}", intent_result.primary_intent);
|
||||
// Truncate long intent tags
|
||||
let intent_tag = if intent_tag.len() > 50 {
|
||||
format!("{}...", &intent_tag[..47])
|
||||
format!("{}...", &intent_tag[..intent_tag.floor_char_boundary(47)])
|
||||
} else {
|
||||
intent_tag
|
||||
};
|
||||
|
|
@ -338,7 +338,7 @@ async fn execute_batch(
|
|||
if intent_result.confidence > 0.5 {
|
||||
let intent_tag = format!("intent:{:?}", intent_result.primary_intent);
|
||||
let intent_tag = if intent_tag.len() > 50 {
|
||||
format!("{}...", &intent_tag[..47])
|
||||
format!("{}...", &intent_tag[..intent_tag.floor_char_boundary(47)])
|
||||
} else {
|
||||
intent_tag
|
||||
};
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue