mirror of
https://github.com/samvallad33/vestige.git
synced 2026-07-26 23:51:02 +02:00
feat(pro): mandatory zero-knowledge cloud sync + Pro upgrade surfaces
- cloud-sync client now REQUIRES VESTIGE_CLOUD_ENCRYPTION_KEY: construction fails without it, every upload is encrypted, downloads reject plaintext - release.yml: Windows + Intel Mac binaries now build with cloud-sync - ci.yml: the paid path is now compiled and tested in CI - Second-Machine Wall: unentitled 'vestige sync --cloud' now explains Pro ($19/mo) with a subscribe link instead of a dead-end error - vestige --help / health footers, npm postinstall note, npm README Pro section - .gitignore: exclude private business docs (contact lists) from the repo Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
parent
89da758985
commit
89b0b35665
7 changed files with 202 additions and 87 deletions
5
.github/workflows/ci.yml
vendored
5
.github/workflows/ci.yml
vendored
|
|
@ -59,6 +59,9 @@ jobs:
|
|||
- name: Test
|
||||
run: cargo test --workspace
|
||||
|
||||
- name: Test paid cloud-sync path
|
||||
run: cargo test --locked --package vestige-core --features cloud-sync cloud --lib
|
||||
|
||||
release-build:
|
||||
name: Release Build (${{ matrix.target }})
|
||||
runs-on: ${{ matrix.os }}
|
||||
|
|
@ -81,7 +84,7 @@ jobs:
|
|||
# runtime linking is a user concern documented in INSTALL-INTEL-MAC.md.
|
||||
- os: macos-14
|
||||
target: x86_64-apple-darwin
|
||||
cargo_flags: "--no-default-features --features ort-dynamic,vector-search"
|
||||
cargo_flags: "--no-default-features --features ort-dynamic,vector-search,cloud-sync"
|
||||
- os: ubuntu-latest
|
||||
target: x86_64-unknown-linux-gnu
|
||||
cargo_flags: ""
|
||||
|
|
|
|||
4
.github/workflows/release.yml
vendored
4
.github/workflows/release.yml
vendored
|
|
@ -38,7 +38,7 @@ jobs:
|
|||
# generates 0 embeddings, and no model download is ever attempted.
|
||||
# usearch builds cleanly on MSVC because vestige-core pins it with
|
||||
# features=["fp16lib"] (see crates/vestige-core/Cargo.toml).
|
||||
cargo_flags: "--no-default-features --features embeddings,ort-download,vector-search"
|
||||
cargo_flags: "--no-default-features --features embeddings,ort-download,vector-search,cloud-sync"
|
||||
needs_onnxruntime: false
|
||||
# Intel Mac uses the ort-dynamic feature to runtime-link against a
|
||||
# system libonnxruntime (Homebrew), sidestepping the missing
|
||||
|
|
@ -48,7 +48,7 @@ jobs:
|
|||
- target: x86_64-apple-darwin
|
||||
os: macos-latest
|
||||
archive: tar.gz
|
||||
cargo_flags: "--no-default-features --features ort-dynamic,vector-search"
|
||||
cargo_flags: "--no-default-features --features ort-dynamic,vector-search,cloud-sync"
|
||||
- target: aarch64-apple-darwin
|
||||
os: macos-latest
|
||||
archive: tar.gz
|
||||
|
|
|
|||
14
.gitignore
vendored
14
.gitignore
vendored
|
|
@ -142,3 +142,17 @@ fastembed-rs/
|
|||
|
||||
.claude/
|
||||
.codebase-memory/
|
||||
|
||||
# Internal AI-council planning scratch + session dumps — never public
|
||||
.council/
|
||||
|
||||
# Private business material: pricing strategy, sales playbooks, and outreach
|
||||
# lists containing third parties' names and contact details. These sit in the
|
||||
# working tree untracked; without this rule a single `git add -A` would publish
|
||||
# real people's personal data to a public repo.
|
||||
docs/business/
|
||||
*.session.jsonl
|
||||
blackbox-proof-*/*.jsonl
|
||||
|
||||
# Hermes agent-runtime conversation dumps (dated hex, tens of MB, embed system prompts)
|
||||
[0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9]_[0-9][0-9][0-9][0-9][0-9][0-9]_[0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f]
|
||||
|
|
|
|||
|
|
@ -24,9 +24,9 @@
|
|||
use std::cell::RefCell;
|
||||
use std::time::Duration;
|
||||
|
||||
use reqwest::StatusCode;
|
||||
use reqwest::blocking::Client;
|
||||
use reqwest::header::{AUTHORIZATION, ETAG, IF_MATCH};
|
||||
use reqwest::StatusCode;
|
||||
|
||||
use super::portable::PortableArchive;
|
||||
use super::sqlite::{PortableSyncBackend, Result, StorageError};
|
||||
|
|
@ -49,10 +49,10 @@ pub struct HttpPortableSyncBackend {
|
|||
endpoint: String,
|
||||
/// Per-user sync key, presented as `Authorization: Bearer <key>`.
|
||||
sync_key: String,
|
||||
/// Optional zero-knowledge passphrase. When set, the archive is encrypted
|
||||
/// before upload and decrypted after download — the server never sees
|
||||
/// plaintext, and this passphrase is never sent to the server.
|
||||
encryption_key: Option<String>,
|
||||
/// Required zero-knowledge passphrase. The archive is always encrypted
|
||||
/// before upload and decrypted after download; the server never receives
|
||||
/// this passphrase or a plaintext archive.
|
||||
encryption_key: String,
|
||||
/// Blocking HTTP client (the trait is synchronous).
|
||||
client: Client,
|
||||
/// ETag captured on the most recent successful read, used as the `If-Match`
|
||||
|
|
@ -62,19 +62,19 @@ pub struct HttpPortableSyncBackend {
|
|||
}
|
||||
|
||||
impl HttpPortableSyncBackend {
|
||||
/// Build a cloud sync backend for `endpoint` authenticated with `sync_key`,
|
||||
/// with no client-side encryption (plaintext upload).
|
||||
/// Plaintext cloud sync is deliberately disabled.
|
||||
///
|
||||
/// A trailing slash on `endpoint` is trimmed so URL joining is predictable.
|
||||
pub fn new(endpoint: impl Into<String>, sync_key: impl Into<String>) -> Result<Self> {
|
||||
Self::new_with_encryption(endpoint, sync_key, None)
|
||||
}
|
||||
|
||||
/// Build a cloud sync backend with optional zero-knowledge encryption.
|
||||
/// Build a cloud sync backend with required zero-knowledge encryption.
|
||||
///
|
||||
/// When `encryption_key` is `Some`, the portable archive is encrypted with
|
||||
/// XChaCha20-Poly1305 (Argon2id-derived key) before upload and decrypted on
|
||||
/// download. The passphrase never leaves this process.
|
||||
/// The portable archive is encrypted with XChaCha20-Poly1305
|
||||
/// (Argon2id-derived key) before upload and decrypted on download. The
|
||||
/// passphrase never leaves this process. Missing or blank passphrases are
|
||||
/// rejected rather than silently uploading plaintext.
|
||||
pub fn new_with_encryption(
|
||||
endpoint: impl Into<String>,
|
||||
sync_key: impl Into<String>,
|
||||
|
|
@ -92,7 +92,15 @@ impl HttpPortableSyncBackend {
|
|||
"cloud sync key is empty (set VESTIGE_CLOUD_SYNC_KEY)".to_string(),
|
||||
));
|
||||
}
|
||||
let encryption_key = encryption_key.filter(|k| !k.is_empty());
|
||||
let encryption_key = encryption_key
|
||||
.filter(|k| !k.trim().is_empty())
|
||||
.ok_or_else(|| {
|
||||
StorageError::Init(
|
||||
"cloud sync encryption key is required; set \
|
||||
VESTIGE_CLOUD_ENCRYPTION_KEY before syncing"
|
||||
.to_string(),
|
||||
)
|
||||
})?;
|
||||
let client = Client::builder()
|
||||
.timeout(REQUEST_TIMEOUT)
|
||||
.user_agent(concat!("vestige-cloud-sync/", env!("CARGO_PKG_VERSION")))
|
||||
|
|
@ -109,7 +117,7 @@ impl HttpPortableSyncBackend {
|
|||
|
||||
/// Whether this backend encrypts client-side (zero-knowledge).
|
||||
pub fn is_encrypted(&self) -> bool {
|
||||
self.encryption_key.is_some()
|
||||
true
|
||||
}
|
||||
|
||||
/// Full blob URL for this backend.
|
||||
|
|
@ -150,27 +158,18 @@ impl PortableSyncBackend for HttpPortableSyncBackend {
|
|||
.bytes()
|
||||
.map_err(|e| StorageError::Init(format!("cloud sync read body failed: {e}")))?;
|
||||
|
||||
// Decrypt if this is a zero-knowledge envelope. If a passphrase
|
||||
// is configured but the remote is still plaintext (legacy), parse
|
||||
// it directly — the next push will encrypt it (transparent upgrade).
|
||||
let plaintext: std::borrow::Cow<'_, [u8]> =
|
||||
if super::cloud_crypto::is_encrypted(&bytes) {
|
||||
let pass = self.encryption_key.as_deref().ok_or_else(|| {
|
||||
StorageError::Init(
|
||||
"remote archive is encrypted but VESTIGE_CLOUD_ENCRYPTION_KEY is \
|
||||
not set on this device"
|
||||
.to_string(),
|
||||
)
|
||||
})?;
|
||||
std::borrow::Cow::Owned(super::cloud_crypto::decrypt(pass, &bytes)?)
|
||||
} else {
|
||||
std::borrow::Cow::Borrowed(&bytes)
|
||||
};
|
||||
if !super::cloud_crypto::is_encrypted(&bytes) {
|
||||
return Err(StorageError::Init(
|
||||
"refusing plaintext cloud archive: Vestige Pro requires \
|
||||
end-to-end encrypted sync"
|
||||
.to_string(),
|
||||
));
|
||||
}
|
||||
let plaintext = super::cloud_crypto::decrypt(&self.encryption_key, &bytes)?;
|
||||
|
||||
let archive: PortableArchive =
|
||||
serde_json::from_slice(&plaintext).map_err(|e| {
|
||||
StorageError::Init(format!("failed to parse cloud sync archive: {e}"))
|
||||
})?;
|
||||
let archive: PortableArchive = serde_json::from_slice(&plaintext).map_err(|e| {
|
||||
StorageError::Init(format!("failed to parse cloud sync archive: {e}"))
|
||||
})?;
|
||||
Ok(Some(archive))
|
||||
}
|
||||
StatusCode::UNAUTHORIZED | StatusCode::FORBIDDEN => Err(StorageError::Init(
|
||||
|
|
@ -188,21 +187,15 @@ impl PortableSyncBackend for HttpPortableSyncBackend {
|
|||
let plaintext = serde_json::to_vec(archive)
|
||||
.map_err(|e| StorageError::Init(format!("failed to serialize archive: {e}")))?;
|
||||
|
||||
// Zero-knowledge: encrypt before upload when a passphrase is set, so the
|
||||
// server only ever stores ciphertext. Content type reflects the payload.
|
||||
let (body, content_type) = match self.encryption_key.as_deref() {
|
||||
Some(pass) => (
|
||||
super::cloud_crypto::encrypt(pass, &plaintext)?,
|
||||
"application/octet-stream",
|
||||
),
|
||||
None => (plaintext, "application/json"),
|
||||
};
|
||||
// Zero-knowledge is mandatory for hosted sync: plaintext never leaves
|
||||
// this process and every successful upload is an encrypted envelope.
|
||||
let body = super::cloud_crypto::encrypt(&self.encryption_key, &plaintext)?;
|
||||
|
||||
let mut req = self
|
||||
.client
|
||||
.put(self.blob_url())
|
||||
.header(AUTHORIZATION, format!("Bearer {}", self.sync_key))
|
||||
.header(reqwest::header::CONTENT_TYPE, content_type)
|
||||
.header(reqwest::header::CONTENT_TYPE, "application/octet-stream")
|
||||
.body(body);
|
||||
|
||||
// Optimistic concurrency: only overwrite the object we pulled. If the
|
||||
|
|
@ -240,13 +233,15 @@ impl PortableSyncBackend for HttpPortableSyncBackend {
|
|||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::super::portable::{PORTABLE_ARCHIVE_FORMAT, PortableArchive, PortableTable};
|
||||
use super::*;
|
||||
use super::super::portable::{PortableArchive, PortableTable, PORTABLE_ARCHIVE_FORMAT};
|
||||
use std::io::{Read, Write};
|
||||
use std::net::TcpListener;
|
||||
use std::sync::mpsc;
|
||||
use std::thread;
|
||||
|
||||
const TEST_PASSPHRASE: &str = "correct horse battery staple";
|
||||
|
||||
fn sample_archive() -> PortableArchive {
|
||||
PortableArchive {
|
||||
archive_format: PORTABLE_ARCHIVE_FORMAT.to_string(),
|
||||
|
|
@ -268,6 +263,7 @@ mod tests {
|
|||
method: String,
|
||||
authorization: Option<String>,
|
||||
if_match: Option<String>,
|
||||
content_type: Option<String>,
|
||||
}
|
||||
|
||||
/// Minimal one-shot HTTP mock. `responder` builds the raw HTTP response
|
||||
|
|
@ -275,7 +271,7 @@ mod tests {
|
|||
/// URL and a receiver for the captured request.
|
||||
fn spawn_mock<F>(responder: F) -> (String, mpsc::Receiver<CapturedRequest>)
|
||||
where
|
||||
F: Fn(&CapturedRequest) -> String + Send + 'static,
|
||||
F: Fn(&CapturedRequest) -> Vec<u8> + Send + 'static,
|
||||
{
|
||||
let listener = TcpListener::bind("127.0.0.1:0").expect("bind mock");
|
||||
let addr = listener.local_addr().expect("addr");
|
||||
|
|
@ -293,10 +289,12 @@ mod tests {
|
|||
cap.authorization = Some(v.trim().to_string());
|
||||
} else if let Some(v) = line.strip_prefix("if-match: ") {
|
||||
cap.if_match = Some(v.trim().to_string());
|
||||
} else if let Some(v) = line.strip_prefix("content-type: ") {
|
||||
cap.content_type = Some(v.trim().to_string());
|
||||
}
|
||||
}
|
||||
let response = responder(&cap);
|
||||
let _ = stream.write_all(response.as_bytes());
|
||||
let _ = stream.write_all(&response);
|
||||
let _ = stream.flush();
|
||||
let _ = tx.send(cap);
|
||||
}
|
||||
|
|
@ -304,30 +302,61 @@ mod tests {
|
|||
(format!("http://{addr}"), rx)
|
||||
}
|
||||
|
||||
fn http_response(status: &str, extra_headers: &str, body: &str) -> String {
|
||||
format!(
|
||||
"HTTP/1.1 {status}\r\nContent-Length: {}\r\n{extra_headers}Connection: close\r\n\r\n{body}",
|
||||
fn http_response(status: &str, extra_headers: &str, body: &str) -> Vec<u8> {
|
||||
http_response_bytes(status, extra_headers, body.as_bytes())
|
||||
}
|
||||
|
||||
fn http_response_bytes(status: &str, extra_headers: &str, body: &[u8]) -> Vec<u8> {
|
||||
let mut response = format!(
|
||||
"HTTP/1.1 {status}\r\nContent-Length: {}\r\n{extra_headers}Connection: close\r\n\r\n",
|
||||
body.len()
|
||||
)
|
||||
.into_bytes();
|
||||
response.extend_from_slice(body);
|
||||
response
|
||||
}
|
||||
|
||||
fn encrypted_backend(endpoint: impl Into<String>, sync_key: &str) -> HttpPortableSyncBackend {
|
||||
HttpPortableSyncBackend::new_with_encryption(
|
||||
endpoint,
|
||||
sync_key,
|
||||
Some(TEST_PASSPHRASE.to_string()),
|
||||
)
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn new_rejects_empty_endpoint_and_key() {
|
||||
assert!(HttpPortableSyncBackend::new("", "key").is_err());
|
||||
assert!(HttpPortableSyncBackend::new("https://x", "").is_err());
|
||||
assert!(HttpPortableSyncBackend::new("https://x", "key").is_ok());
|
||||
fn new_rejects_plaintext_empty_endpoint_and_empty_key() {
|
||||
assert!(HttpPortableSyncBackend::new("https://x", "key").is_err());
|
||||
assert!(
|
||||
HttpPortableSyncBackend::new_with_encryption(
|
||||
"",
|
||||
"key",
|
||||
Some(TEST_PASSPHRASE.to_string())
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
assert!(
|
||||
HttpPortableSyncBackend::new_with_encryption(
|
||||
"https://x",
|
||||
"",
|
||||
Some(TEST_PASSPHRASE.to_string())
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
assert!(encrypted_backend("https://x", "key").is_encrypted());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn endpoint_trailing_slash_trimmed() {
|
||||
let be = HttpPortableSyncBackend::new("https://sync.example/", "k").unwrap();
|
||||
let be = encrypted_backend("https://sync.example/", "k");
|
||||
assert_eq!(be.blob_url(), "https://sync.example/v1/blob");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn read_404_returns_none() {
|
||||
let (base, rx) = spawn_mock(|_| http_response("404 Not Found", "", ""));
|
||||
let be = HttpPortableSyncBackend::new(base, "secret").unwrap();
|
||||
let be = encrypted_backend(base, "secret");
|
||||
let got = be.read_archive().expect("read ok");
|
||||
assert!(got.is_none());
|
||||
let cap = rx.recv().unwrap();
|
||||
|
|
@ -338,21 +367,31 @@ mod tests {
|
|||
#[test]
|
||||
fn read_200_parses_and_captures_etag() {
|
||||
let archive = sample_archive();
|
||||
let body = serde_json::to_string(&archive).unwrap();
|
||||
let plaintext = serde_json::to_vec(&archive).unwrap();
|
||||
let body = super::super::cloud_crypto::encrypt(TEST_PASSPHRASE, &plaintext).unwrap();
|
||||
let (base, _rx) = spawn_mock(move |_| {
|
||||
http_response("200 OK", "ETag: \"v1-abc\"\r\n", &body)
|
||||
http_response_bytes("200 OK", "ETag: \"v1-abc\"\r\n", &body)
|
||||
});
|
||||
let be = HttpPortableSyncBackend::new(base, "secret").unwrap();
|
||||
let be = encrypted_backend(base, "secret");
|
||||
let got = be.read_archive().expect("read ok").expect("some archive");
|
||||
assert_eq!(got.archive_format, PORTABLE_ARCHIVE_FORMAT);
|
||||
// ETag captured for the next If-Match write.
|
||||
assert_eq!(be.last_etag.borrow().as_deref(), Some("\"v1-abc\""));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn read_200_rejects_plaintext_downgrade() {
|
||||
let body = serde_json::to_string(&sample_archive()).unwrap();
|
||||
let (base, _rx) = spawn_mock(move |_| http_response("200 OK", "", &body));
|
||||
let be = encrypted_backend(base, "secret");
|
||||
let err = be.read_archive().unwrap_err();
|
||||
assert!(err.to_string().contains("refusing plaintext"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn read_401_is_error() {
|
||||
let (base, _rx) = spawn_mock(|_| http_response("401 Unauthorized", "", ""));
|
||||
let be = HttpPortableSyncBackend::new(base, "bad").unwrap();
|
||||
let be = encrypted_backend(base, "bad");
|
||||
assert!(be.read_archive().is_err());
|
||||
}
|
||||
|
||||
|
|
@ -360,19 +399,20 @@ mod tests {
|
|||
fn write_sends_if_match_when_etag_present() {
|
||||
// Seed an etag as if a prior read happened.
|
||||
let (base, rx) = spawn_mock(|_| http_response("200 OK", "", ""));
|
||||
let be = HttpPortableSyncBackend::new(base, "secret").unwrap();
|
||||
let be = encrypted_backend(base, "secret");
|
||||
*be.last_etag.borrow_mut() = Some("\"v1-abc\"".to_string());
|
||||
be.write_archive(&sample_archive()).expect("write ok");
|
||||
let cap = rx.recv().unwrap();
|
||||
assert_eq!(cap.method, "PUT");
|
||||
assert_eq!(cap.authorization.as_deref(), Some("Bearer secret"));
|
||||
assert_eq!(cap.if_match.as_deref(), Some("\"v1-abc\""));
|
||||
assert_eq!(cap.content_type.as_deref(), Some("application/octet-stream"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn write_omits_if_match_for_first_create() {
|
||||
let (base, rx) = spawn_mock(|_| http_response("201 Created", "", ""));
|
||||
let be = HttpPortableSyncBackend::new(base, "secret").unwrap();
|
||||
let be = encrypted_backend(base, "secret");
|
||||
// No prior read → no etag → no If-Match (allow create).
|
||||
be.write_archive(&sample_archive()).expect("write ok");
|
||||
let cap = rx.recv().unwrap();
|
||||
|
|
@ -383,7 +423,7 @@ mod tests {
|
|||
#[test]
|
||||
fn write_412_is_conflict_error() {
|
||||
let (base, _rx) = spawn_mock(|_| http_response("412 Precondition Failed", "", ""));
|
||||
let be = HttpPortableSyncBackend::new(base, "secret").unwrap();
|
||||
let be = encrypted_backend(base, "secret");
|
||||
*be.last_etag.borrow_mut() = Some("\"stale\"".to_string());
|
||||
let err = be.write_archive(&sample_archive()).unwrap_err();
|
||||
assert!(err.to_string().contains("conflict"));
|
||||
|
|
|
|||
|
|
@ -26,6 +26,9 @@ use vestige_core::{IngestInput, PortableImportMode, Storage};
|
|||
#[command(
|
||||
long_about = "Vestige is a cognitive memory system based on 130 years of memory research.\n\nIt implements FSRS-6, spreading activation, synaptic tagging, and more."
|
||||
)]
|
||||
#[command(
|
||||
after_help = "Vestige Pro: your memory on every machine, end-to-end encrypted. $19/mo -> https://github.com/samvallad33/vestige#vestige-pro"
|
||||
)]
|
||||
struct Cli {
|
||||
/// Use a specific Vestige data directory for this command.
|
||||
#[arg(long, global = true, value_name = "DIR")]
|
||||
|
|
@ -1751,6 +1754,15 @@ fn run_health() -> anyhow::Result<()> {
|
|||
println!(" {} {}", icon, text);
|
||||
}
|
||||
|
||||
println!();
|
||||
println!(
|
||||
"{} {}",
|
||||
"Pro:".cyan().bold(),
|
||||
"sync this memory across machines, end-to-end encrypted ($19/mo) — \
|
||||
https://github.com/samvallad33/vestige#vestige-pro"
|
||||
.white()
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
|
@ -2323,8 +2335,10 @@ fn run_sync_cloud(endpoint: Option<String>) -> anyhow::Result<()> {
|
|||
.filter(|s| !s.trim().is_empty())
|
||||
.ok_or_else(|| {
|
||||
anyhow::anyhow!(
|
||||
"no cloud endpoint: pass --endpoint or set VESTIGE_CLOUD_ENDPOINT \
|
||||
(e.g. https://sync.vestige.dev)"
|
||||
"Vestige Pro syncs your agent's memory across machines, end-to-end encrypted \
|
||||
($19/month).\n Subscribe: https://github.com/samvallad33/vestige#vestige-pro\n \
|
||||
Already subscribed? Pass --endpoint or set VESTIGE_CLOUD_ENDPOINT from your \
|
||||
welcome email."
|
||||
)
|
||||
})?;
|
||||
let sync_key = std::env::var("VESTIGE_CLOUD_SYNC_KEY")
|
||||
|
|
@ -2332,35 +2346,37 @@ fn run_sync_cloud(endpoint: Option<String>) -> anyhow::Result<()> {
|
|||
.filter(|s| !s.trim().is_empty())
|
||||
.ok_or_else(|| {
|
||||
anyhow::anyhow!(
|
||||
"no sync key: set VESTIGE_CLOUD_SYNC_KEY (issued when you subscribe to \
|
||||
Vestige Cloud)"
|
||||
"no sync key set. Your local memory is free forever; syncing it across machines \
|
||||
is Vestige Pro ($19/month, zero-knowledge encrypted).\n Subscribe: \
|
||||
https://github.com/samvallad33/vestige#vestige-pro\n Already subscribed? Set \
|
||||
VESTIGE_CLOUD_SYNC_KEY from your welcome email."
|
||||
)
|
||||
})?;
|
||||
|
||||
// Optional zero-knowledge encryption passphrase. Never sent to the server.
|
||||
// Required zero-knowledge encryption passphrase. Never sent to the server.
|
||||
let encryption_key = std::env::var("VESTIGE_CLOUD_ENCRYPTION_KEY")
|
||||
.ok()
|
||||
.filter(|s| !s.trim().is_empty());
|
||||
.filter(|s| !s.trim().is_empty())
|
||||
.ok_or_else(|| {
|
||||
anyhow::anyhow!(
|
||||
"no encryption key: set VESTIGE_CLOUD_ENCRYPTION_KEY to a strong passphrase. \
|
||||
Vestige Pro refuses plaintext cloud sync. Use the same passphrase on every \
|
||||
device; it cannot be recovered by Vestige."
|
||||
)
|
||||
})?;
|
||||
|
||||
println!("{}", "=== Vestige Cloud Sync ===".cyan().bold());
|
||||
println!();
|
||||
println!("{}: {}", "Endpoint".white().bold(), endpoint);
|
||||
if encryption_key.is_some() {
|
||||
println!(
|
||||
"{}: {}",
|
||||
"Encryption".white().bold(),
|
||||
"zero-knowledge (XChaCha20-Poly1305) — your data is encrypted before upload".green()
|
||||
);
|
||||
} else {
|
||||
println!(
|
||||
"{}: {}",
|
||||
"Encryption".white().bold(),
|
||||
"OFF — set VESTIGE_CLOUD_ENCRYPTION_KEY for zero-knowledge sync".yellow()
|
||||
);
|
||||
}
|
||||
println!(
|
||||
"{}: {}",
|
||||
"Encryption".white().bold(),
|
||||
"zero-knowledge (XChaCha20-Poly1305) — your data is encrypted before upload".green()
|
||||
);
|
||||
|
||||
let storage = open_storage()?;
|
||||
let report = storage.sync_portable_archive_cloud(&endpoint, &sync_key, encryption_key)?;
|
||||
let report =
|
||||
storage.sync_portable_archive_cloud(&endpoint, &sync_key, Some(encryption_key))?;
|
||||
print_sync_report(&report);
|
||||
Ok(())
|
||||
}
|
||||
|
|
@ -2368,8 +2384,9 @@ fn run_sync_cloud(endpoint: Option<String>) -> anyhow::Result<()> {
|
|||
#[cfg(not(feature = "cloud-sync"))]
|
||||
fn run_sync_cloud(_endpoint: Option<String>) -> anyhow::Result<()> {
|
||||
anyhow::bail!(
|
||||
"this build was compiled without the `cloud-sync` feature; rebuild with \
|
||||
--features cloud-sync to use Vestige Cloud"
|
||||
"this build was compiled without the `cloud-sync` feature. Official binaries from \
|
||||
v2.3.0 include it: run `vestige update` or `npm update -g vestige-mcp-server`, \
|
||||
then retry. Building from source? Add --features cloud-sync."
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -2593,6 +2610,21 @@ fn run_ingest(
|
|||
|
||||
let storage = open_storage()?;
|
||||
|
||||
// Warm up the embedding model so smart_ingest's is_ready() gate passes and
|
||||
// real vector search runs (instead of silently falling back to keyword-only
|
||||
// regular ingest). is_ready() is side-effect free by design, so a fresh CLI
|
||||
// process must init explicitly. Non-fatal: fall back to keyword on failure.
|
||||
#[cfg(feature = "embeddings")]
|
||||
{
|
||||
if let Err(e) = storage.init_embeddings() {
|
||||
eprintln!(
|
||||
" {} Embeddings unavailable: {} (ingest will use keyword-only)",
|
||||
"!".yellow(),
|
||||
e
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Try smart_ingest (PE Gating) if available, otherwise regular ingest
|
||||
#[cfg(all(feature = "embeddings", feature = "vector-search"))]
|
||||
{
|
||||
|
|
|
|||
|
|
@ -22,6 +22,29 @@ This refreshes the binaries only. Optional Claude Code Cognitive Sandwich
|
|||
companion files are refreshed with `vestige update --sandwich-companion` or
|
||||
`vestige sandwich install`.
|
||||
|
||||
## Vestige Pro — your memory on every machine
|
||||
|
||||
The entire cognitive engine is free forever, local, and never metered. Vestige
|
||||
Pro is for when that memory needs to follow you:
|
||||
|
||||
- **End-to-end encrypted continuity** across every machine you work on: your
|
||||
memory graph plus your accountability history (Black Box traces, receipts,
|
||||
memory PRs).
|
||||
- **Zero-knowledge by construction**: your passphrase never leaves your machine.
|
||||
The server only ever stores ciphertext. The client refuses to sync without
|
||||
encryption.
|
||||
- **$19/month.** No annual lock-in, no lifetime gimmicks.
|
||||
|
||||
```bash
|
||||
npm update -g vestige-mcp-server # get the Pro-capable client (v2.3.0+)
|
||||
vestige sync --cloud # walks you through the rest
|
||||
```
|
||||
|
||||
Subscribe: [github.com/samvallad33/vestige#vestige-pro](https://github.com/samvallad33/vestige#vestige-pro)
|
||||
|
||||
One passphrase, chosen by you, for every device. Vestige never receives it. A
|
||||
lost passphrase means the encrypted data is unrecoverable — that is the point.
|
||||
|
||||
### What gets installed
|
||||
|
||||
| Command | Description |
|
||||
|
|
|
|||
|
|
@ -262,6 +262,9 @@ async function main() {
|
|||
console.log(' 2. Restart your MCP client.');
|
||||
console.log(' 3. Test with: "remember that my preferred editor is VS Code"');
|
||||
console.log('');
|
||||
console.log('Local memory is free forever. Vestige Pro syncs it across machines,');
|
||||
console.log('end-to-end encrypted ($19/mo): https://github.com/samvallad33/vestige#vestige-pro');
|
||||
console.log('');
|
||||
|
||||
} catch (err) {
|
||||
console.error('');
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue