semlith 0.36.0

A fast local vector store for AI agents — index files once, keep it current as you save, and answer questions across all of it in milliseconds without leaving the machine.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
//! The MCP endpoint over HTTP, and the two credentials in front of it.
//!
//! From 0.13.0 the daemon answers MCP at `/mcp` as well as over stdio, and the
//! credential that opens it is deliberately not the portal's session token:
//!
//! - The **session token** is per run, cookie-borne, and required by every
//!   `/api/*` route. Rotating it is the Privacy page's button.
//! - The **agent key** is persisted in `~/.semlith/agent.key`, arrives as an
//!   `Authorization: Bearer` header, and opens `/mcp` and nothing else.
//!
//! What is asserted here is the separation itself — that a key in a client's
//! configuration file cannot reach a route that rotates, adopts or upgrades,
//! and that rotating the token does not disconnect an agent. None of these
//! index anything, so none of them needs a model.

use std::io::{BufRead, BufReader, Read, Write};
use std::net::TcpStream;
use std::process::{Child, Command, Stdio};
use std::time::Duration;

struct Daemon {
    child: Child,
    port: u16,
    token: String,
    home: std::path::PathBuf,
}

impl Daemon {
    fn start(home: &std::path::Path, args: &[&str]) -> Self {
        std::fs::create_dir_all(home).unwrap();
        let mut child = Command::new(env!("CARGO_BIN_EXE_semlith"))
            .arg("start")
            .arg("--port")
            .arg("0")
            .args(args)
            .env("SEMLITH_HOME", home)
            .env("HOME", home)
            .env_remove("SEMLITH_STORE")
            .env_remove("SEMLITH_PORT")
            .current_dir(home)
            .stdout(Stdio::piped())
            .stderr(Stdio::null())
            .spawn()
            .expect("semlith start runs");

        let mut line = String::new();
        BufReader::new(child.stdout.as_mut().unwrap())
            .read_line(&mut line)
            .expect("the daemon prints its URL");
        let rest = line
            .trim()
            .strip_prefix("http://127.0.0.1:")
            .expect("a loopback URL");
        let (port, token) = rest.split_once("/?token=").expect("a token in the URL");

        Self {
            child,
            port: port.parse().unwrap(),
            token: token.to_string(),
            home: home.to_path_buf(),
        }
    }

    fn key(&self) -> String {
        std::fs::read_to_string(self.home.join("agent.key"))
            .expect("the daemon wrote an agent key")
            .trim()
            .to_string()
    }

    /// One request, with whatever credential headers the caller wants.
    fn send(&self, method: &str, path: &str, headers: &str, body: &str) -> (u16, String) {
        let mut stream = TcpStream::connect(("127.0.0.1", self.port)).expect("the daemon listens");
        stream
            .set_read_timeout(Some(Duration::from_secs(10)))
            .unwrap();
        let request = format!(
            "{method} {path} HTTP/1.1\r\nHost: 127.0.0.1:{}\r\n{headers}\
             Content-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}",
            self.port,
            body.len(),
        );
        stream.write_all(request.as_bytes()).unwrap();
        stream.flush().unwrap();

        let mut raw = Vec::new();
        stream.read_to_end(&mut raw).unwrap();
        let text = String::from_utf8_lossy(&raw).into_owned();
        let status = text
            .lines()
            .next()
            .and_then(|l| l.split_whitespace().nth(1))
            .and_then(|s| s.parse().ok())
            .unwrap_or(0);
        (status, text)
    }

    fn with_key(&self, key: &str, body: &str) -> (u16, String) {
        self.send(
            "POST",
            "/mcp",
            &format!("Authorization: Bearer {key}\r\n"),
            body,
        )
    }

    fn with_token(&self, method: &str, path: &str, body: &str) -> (u16, String) {
        self.send(
            method,
            path,
            &format!("Semlith-Token: {}\r\n", self.token),
            body,
        )
    }
}

impl Drop for Daemon {
    fn drop(&mut self) {
        let _ = self.child.kill();
        let _ = self.child.wait();
    }
}

const LIST: &str = r#"{"jsonrpc":"2.0","id":1,"method":"tools/list"}"#;

/// The whole point of two credentials: the one a client keeps in a file on
/// disk opens the endpoint and cannot reach anything else.
#[test]
fn the_agent_key_opens_mcp_and_nothing_else() {
    let dir = tempfile::tempdir().unwrap();
    let daemon = Daemon::start(&dir.path().join("home"), &[]);
    let key = daemon.key();
    assert!(key.starts_with("sml_"), "{key} is not shaped like a key");

    let (status, body) = daemon.with_key(&key, LIST);
    assert_eq!(status, 200, "the agent key did not open /mcp: {body}");
    assert!(body.contains("semlith_search"), "no tools listed: {body}");

    // The same key at an /api route is refused: a config file cannot rotate a
    // token, adopt a store, start an upgrade, or accept a file the secret
    // scan refused. Known and forbidden, so 403 rather than the held 401.
    for route in [
        "/api/stores",
        "/api/rotate",
        "/api/adopt",
        "/api/upgrade",
        "/api/refused",
        "/api/refused/accept",
        "/api/refused/revoke",
        "/api/refused/decide",
    ] {
        let (status, _) = daemon.send(
            "POST",
            route,
            &format!("Authorization: Bearer {key}\r\n"),
            r#"{"path": "/x/.env", "mode": "as-is", "reviewed": true}"#,
        );
        assert_eq!(status, 403, "the agent key reached {route}");
    }

    // With the session token the accept route takes one path or, from 0.35.0
    // (the owner's bulk decision), a `files` list — each file its own
    // decision. Anything else is refused before a store is even chosen.
    let (status, body) = daemon.with_token(
        "POST",
        "/api/refused/accept",
        r#"{"paths": ["/x/a.rs", "/x/b.rs"], "mode": "as-is", "reviewed": true}"#,
    );
    assert_eq!(status, 400, "a paths key was accepted: {body}");
    let (status, body) = daemon.with_token(
        "POST",
        "/api/refused/accept",
        r#"{"path": ["/x/a.rs", "/x/b.rs"], "mode": "as-is", "reviewed": true}"#,
    );
    assert_eq!(status, 400, "a path that is a list was accepted: {body}");
    // And no MCP tool accepts: no tool is named for accepting or for the
    // not-indexed list.
    let (_, listed) = daemon.with_key(&key, LIST);
    let json = listed.split("\r\n\r\n").last().unwrap_or("");
    let tools: serde_json::Value = serde_json::from_str(json).unwrap_or_default();
    let names: Vec<&str> = tools["result"]["tools"]
        .as_array()
        .map(|t| t.iter().filter_map(|x| x["name"].as_str()).collect())
        .unwrap_or_default();
    assert_eq!(
        names,
        semlith::mcp::listed_names(),
        "the listed tools were not parsed: {json}"
    );
    // Listed or not: the server answers every tool it defines.
    let defined = semlith::mcp::tool_names();
    assert!(
        !defined
            .iter()
            .any(|n| n.contains("accept") || n.contains("refus")),
        "a tool can accept: {defined:?}"
    );

    // And no credential, or the wrong one, opens nothing.
    assert_eq!(daemon.send("POST", "/mcp", "", LIST).0, 401);
    assert_eq!(daemon.with_key("sml_not-the-key", LIST).0, 401);
}

/// A `Host` header that is not loopback is refused before the credential is
/// looked at, on `/mcp` exactly as on every portal route.
#[test]
fn a_foreign_host_is_refused_on_the_endpoint_too() {
    let dir = tempfile::tempdir().unwrap();
    let daemon = Daemon::start(&dir.path().join("home"), &[]);
    let key = daemon.key();

    let mut stream = TcpStream::connect(("127.0.0.1", daemon.port)).unwrap();
    let request = format!(
        "POST /mcp HTTP/1.1\r\nHost: evil.example\r\nAuthorization: Bearer {key}\r\n\
         Content-Length: {}\r\nConnection: close\r\n\r\n{LIST}",
        LIST.len()
    );
    stream.write_all(request.as_bytes()).unwrap();
    let mut raw = Vec::new();
    stream.read_to_end(&mut raw).unwrap();
    assert!(
        String::from_utf8_lossy(&raw).starts_with("HTTP/1.1 400"),
        "a foreign Host was not refused"
    );
}

/// The reason the key is persisted: a client configured once keeps working
/// across a restart, and across a rotation of the portal's session token.
#[test]
fn a_configured_client_survives_a_restart_and_a_token_rotation() {
    let dir = tempfile::tempdir().unwrap();
    let home = dir.path().join("home");

    let key = {
        let daemon = Daemon::start(&home, &[]);
        let key = daemon.key();
        assert_eq!(daemon.with_key(&key, LIST).0, 200);

        // Rotating the session token is the Privacy page's button. It must not
        // touch the endpoint.
        let (status, _) = daemon.with_token("POST", "/api/rotate", "{}");
        assert_eq!(status, 200, "the token did not rotate");
        assert_eq!(
            daemon.with_key(&key, LIST).0,
            200,
            "rotating the session token disconnected the agent"
        );
        key
    };

    // A second daemon on the same home: the key is read, not reminted.
    let daemon = Daemon::start(&home, &[]);
    assert_eq!(daemon.key(), key, "the key changed across a restart");
    assert_eq!(
        daemon.with_key(&key, LIST).0,
        200,
        "the client's stanza went stale across a restart"
    );
}

/// Rotation keeps the previous key valid until the daemon exits, so a session
/// already open finishes rather than failing on the call it was making.
#[test]
fn rotation_keeps_the_previous_key_until_the_daemon_exits() {
    let dir = tempfile::tempdir().unwrap();
    let home = dir.path().join("home");
    let daemon = Daemon::start(&home, &[]);
    let first = daemon.key();

    let (status, body) = daemon.with_token("POST", "/api/key", "{}");
    assert_eq!(status, 200, "the key did not rotate: {body}");
    let second = std::fs::read_to_string(home.join("agent.key"))
        .unwrap()
        .trim()
        .to_string();
    assert_ne!(first, second, "rotation returned the same key");

    assert_eq!(daemon.with_key(&second, LIST).0, 200, "the new key failed");
    assert_eq!(
        daemon.with_key(&first, LIST).0,
        200,
        "the previous key was dropped before the daemon exited"
    );

    // `now` is the deliberate version: it drops the previous key at once.
    let (status, _) = daemon.with_token("POST", "/api/key", r#"{"now":true}"#);
    assert_eq!(status, 200);
    assert_eq!(
        daemon.with_key(&second, LIST).0,
        401,
        "--now did not drop the previous key"
    );
}

/// Closing the endpoint drops the route and nothing else.
#[test]
fn closing_the_endpoint_leaves_the_daemon_running() {
    let dir = tempfile::tempdir().unwrap();
    let daemon = Daemon::start(&dir.path().join("home"), &[]);
    let key = daemon.key();
    assert_eq!(daemon.with_key(&key, LIST).0, 200);

    let (status, _) = daemon.with_token("POST", "/api/endpoint", r#"{"open":false}"#);
    assert_eq!(status, 200);
    assert_eq!(
        daemon.with_key(&key, LIST).0,
        404,
        "a closed endpoint still answered"
    );
    assert_eq!(
        daemon.with_token("GET", "/api/stores", "").0,
        200,
        "closing the endpoint stopped the portal"
    );

    let (status, _) = daemon.with_token("POST", "/api/endpoint", r#"{"open":true}"#);
    assert_eq!(status, 200);
    assert_eq!(daemon.with_key(&key, LIST).0, 200, "it did not reopen");
}

/// `--no-mcp-http` starts with the endpoint closed, and says so.
#[test]
fn the_flag_starts_with_the_endpoint_closed() {
    let dir = tempfile::tempdir().unwrap();
    let daemon = Daemon::start(&dir.path().join("home"), &["--no-mcp-http"]);
    let key = daemon.key();
    assert_eq!(daemon.with_key(&key, LIST).0, 404);
    assert_eq!(daemon.with_token("GET", "/api/stores", "").0, 200);
}

/// The Agents page's list is the daemon's own: a client that has spoken to it
/// appears with the name it gave, its transport and its query count.
#[test]
fn a_connected_client_is_listed_with_what_it_said_about_itself() {
    let dir = tempfile::tempdir().unwrap();
    let daemon = Daemon::start(&dir.path().join("home"), &[]);
    let key = daemon.key();

    let hello = r#"{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","clientInfo":{"name":"Test Client"}}}"#;
    let (status, response) = daemon.with_key(&key, hello);
    assert_eq!(status, 200, "initialize failed: {response}");
    let session = response
        .lines()
        .find_map(|l| l.strip_prefix("Mcp-Session-Id: "))
        .map(|s| s.trim().to_string())
        .expect("the server assigns a session id at initialize");

    let call = r#"{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"semlith_stats","arguments":{}}}"#;
    daemon.send(
        "POST",
        "/mcp",
        &format!("Authorization: Bearer {key}\r\nMcp-Session-Id: {session}\r\n"),
        call,
    );

    let (_, body) = daemon.with_token("GET", "/api/agents", "");
    assert!(
        body.contains("Test Client"),
        "the client is not listed: {body}"
    );
    assert!(
        body.contains("http /mcp"),
        "the transport is missing: {body}"
    );
    assert!(
        body.contains("\"queries\":1"),
        "the query was not counted: {body}"
    );
    assert!(
        body.contains("2025-06-18"),
        "the negotiated revision is missing: {body}"
    );
}

/// The key opens the endpoint, so a page that is merely open must not be handed
/// it. `/api/agents` is read on every visit to the Agents page.
#[test]
fn the_agents_route_does_not_carry_the_key_and_reveal_does() {
    let dir = tempfile::tempdir().unwrap();
    let daemon = Daemon::start(&dir.path().join("home"), &[]);

    let (status, body) = daemon.with_token("GET", "/api/agents", "");
    assert_eq!(status, 200);
    assert!(
        !body.contains("sml_"),
        "/api/agents still carries the key:\n{body}"
    );
    // `with_token` hands back the whole response, headers and all.
    let payload = body.split_once("\r\n\r\n").map(|(_, b)| b).unwrap_or(&body);
    let agents: serde_json::Value = serde_json::from_str(payload).expect("JSON");
    assert_eq!(agents["key_env"], "SEMLITH_AGENT_KEY");
    assert_eq!(
        agents["key_set"], true,
        "the page cannot tell whether a key exists: {agents}"
    );

    // Pressing Reveal is a write, so it carries the same same-origin and JSON
    // rules every other write does, and it hands the key over once.
    let (status, body) = daemon.with_token("POST", "/api/agents/reveal", "{}");
    assert_eq!(status, 200, "{body}");
    let payload = body.split_once("\r\n\r\n").map(|(_, b)| b).unwrap_or(&body);
    let shown: serde_json::Value = serde_json::from_str(payload).expect("JSON");
    assert_eq!(
        shown["key"].as_str().unwrap_or_default(),
        daemon.key(),
        "reveal did not return the live key"
    );
}