15d7f5e873
- notescraft-lint (rules, safe fixes, learned style) and notescraft-blocks (block model and edits); engine lint.* and block.* commands with CLI and MCP parity - Lint tab, underlines, slash menu with preview in Source and Visual, block grip and menu, Settings > Editor and lint, Vim block keys - Visual view: tables, note embeds, %% comments, wrapping property chips - Vault knowledge (Related tab, assistant sources, Settings) and calendar plugin - Sidebar top padding fixed, dot grid follows PaperCraft spacing, right-card tabs no longer cover the collapse button Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
174 lines
9.1 KiB
Rust
174 lines
9.1 KiB
Rust
//! The assistant gate over a live control channel (SPEC 10.3): the tool call
|
|
//! arrives with `source: "assistant"`, is gated by the pump and answers
|
|
//! `{"queued": true, …}`; wrong sources and wrong ids are refused; reads pass.
|
|
|
|
#![allow(dead_code)] // shared fixtures: only some helpers are used here
|
|
|
|
use std::io::Write;
|
|
use std::net::TcpStream;
|
|
|
|
use notescraft_engine::{CommandResult, ControlClient, ControlHandle, ControlOptions, ControlServer, Error, Registry, Session, register_core};
|
|
use serde_json::{Value, json};
|
|
|
|
mod common;
|
|
use common::temp_vault;
|
|
|
|
const ALIVE: std::time::Duration = std::time::Duration::from_secs(30);
|
|
|
|
/// The `test.echo` body: params back out unchanged (from `tests/control.rs`).
|
|
#[expect(clippy::unnecessary_wraps, reason = "the registry signature demands CommandResult")]
|
|
fn echo(_session: &mut Session, params: &Value) -> CommandResult {
|
|
Ok(params.clone())
|
|
}
|
|
|
|
/// A vault, its session, its registry and the live control server over it.
|
|
fn spun_up(name: &str) -> (common::TempVault, Session, Registry<Session>, ControlHandle) {
|
|
let temp = temp_vault(name);
|
|
common::seed(temp.path(), &[("alpha.md", "# Alpha\n\nbody\n")]);
|
|
let mut session = Session::open(temp.path()).expect("open the vault");
|
|
// The app's stored assistant settings are conservative: no writes.
|
|
// The app's stored assistant settings gate reads only; tests turn the
|
|
// knobs they need on their own session.
|
|
session.set_assistant_settings(notescraft_engine::AssistantSettings {
|
|
allow_write: true,
|
|
review_writes: true,
|
|
..notescraft_engine::AssistantSettings::default()
|
|
});
|
|
let mut registry = Registry::new();
|
|
register_core(&mut registry).expect("register the core commands");
|
|
registry
|
|
.register(
|
|
notescraft_engine::CommandSpec::new("test.echo", "Echo", "echoes its params", |_: &Session| true, echo)
|
|
.with_param(notescraft_engine::ParamSpec::optional("question", notescraft_engine::ParamKind::Integer, "number echoed back")),
|
|
)
|
|
.expect("register test.echo");
|
|
let options = ControlOptions { idle_timeout: ALIVE, ..ControlOptions::default() };
|
|
let handle = ControlServer::start_with(session.vault(), options).expect("start the control server");
|
|
(temp, session, registry, handle)
|
|
}
|
|
|
|
/// Pumps until `worker` finishes, then joins it (the test thread is the UI thread).
|
|
fn drive<T>(handle: &mut ControlHandle, session: &mut Session, registry: &Registry<Session>, worker: std::thread::JoinHandle<T>) -> T {
|
|
let started = std::time::Instant::now();
|
|
loop {
|
|
handle.pump(session, registry);
|
|
if worker.is_finished() {
|
|
return worker.join().expect("worker thread did not panic");
|
|
}
|
|
assert!(started.elapsed() < std::time::Duration::from_secs(60), "the worker never finished");
|
|
std::thread::sleep(std::time::Duration::from_millis(1));
|
|
}
|
|
}
|
|
|
|
/// A write with `source: "assistant"` is queued, not executed; the pending
|
|
/// change lands in the session the pump drives.
|
|
#[test]
|
|
fn an_assistant_write_is_queued_over_the_wire() {
|
|
let (keep, mut session, registry, mut handle) = spun_up("assistant-control-queue");
|
|
let root = keep.path().to_owned();
|
|
let worker = std::thread::spawn(move || {
|
|
let mut client = ControlClient::connect(&root).expect("connect");
|
|
let queued = client.run_as("note.write", &json!({ "path": "alpha.md", "content": "agent text" }), "assistant").expect("queue");
|
|
let status = client.run_as("assistant.status", &Value::Null, "assistant").expect("status");
|
|
(queued, status)
|
|
});
|
|
let (queued, status) = drive(&mut handle, &mut session, ®istry, worker);
|
|
assert_eq!(queued.get("queued").and_then(Value::as_bool), Some(true));
|
|
assert_eq!(queued.get("pending_id").and_then(Value::as_u64), Some(1));
|
|
assert_eq!(queued.get("message").and_then(Value::as_str).map(|m| m.starts_with("NOT written yet")), Some(true));
|
|
assert_eq!(status.get("pending").and_then(Value::as_u64), Some(1), "{status}");
|
|
assert_eq!(session.read_note("alpha.md").expect("read"), "# Alpha\n\nbody\n", "nothing was written");
|
|
assert_eq!(session.pending_changes().len(), 1, "the pump stored the pending change");
|
|
let pending = session.pending_changes().first().cloned().expect("a change");
|
|
assert_eq!(pending.old.as_deref(), Some("# Alpha\n\nbody\n"));
|
|
assert_eq!(pending.new.as_deref(), Some("agent text"));
|
|
}
|
|
|
|
/// A write with `source: "cli"` runs like every other user call: ungated.
|
|
#[test]
|
|
fn a_cli_source_write_runs_ungated() {
|
|
let (keep, mut session, registry, mut handle) = spun_up("assistant-control-cli");
|
|
let root = keep.path().to_owned();
|
|
let worker = std::thread::spawn(move || {
|
|
let mut client = ControlClient::connect(&root).expect("connect");
|
|
let cli = client.run_as("note.write", &json!({ "path": "alpha.md", "content": "cli text" }), "cli").expect("run");
|
|
let ui = client.run_as("note.write", &json!({ "path": "alpha.md", "content": "ui text" }), "ui").expect("run");
|
|
let default = client.run("note.write", &json!({ "path": "alpha.md", "content": "default text" })).expect("run");
|
|
(cli, ui, default)
|
|
});
|
|
let (cli, ui, default) = drive(&mut handle, &mut session, ®istry, worker);
|
|
assert_eq!(cli.get("changed").and_then(Value::as_bool), Some(true));
|
|
assert_eq!(ui.get("changed").and_then(Value::as_bool), Some(true));
|
|
assert_eq!(default.get("changed").and_then(Value::as_bool), Some(true));
|
|
assert_eq!(session.pending_changes().len(), 0, "no source means no gate");
|
|
}
|
|
|
|
/// A refused assistant write comes back as the `assistant_denied` command
|
|
/// error, and the session gain the user made is honoured.
|
|
#[test]
|
|
fn a_refused_write_is_an_error_and_a_grant_queues_it() {
|
|
let (keep, mut session, registry, mut handle) = spun_up("assistant-control-deny");
|
|
// Close the write grant; the session grant the user can still open.
|
|
session.set_assistant_settings(notescraft_engine::AssistantSettings::default());
|
|
let root = keep.path().to_owned();
|
|
let worker = std::thread::spawn(move || {
|
|
let mut client = ControlClient::connect(&root).expect("connect");
|
|
// Reads pass even when everything is closed.
|
|
let read = client.run_as("note.read", &json!({ "path": "alpha.md" }), "assistant").expect("read");
|
|
// A write is refused with the gate's own code.
|
|
let denied = client.run_as("note.write", &json!({ "path": "alpha.md", "content": "no" }), "assistant");
|
|
// The user toggles the session grant; now the reviewed write queues.
|
|
client.run("assistant.grant_write", &json!({ "session": true })).expect("grant");
|
|
let queued = client.run_as("note.write", &json!({ "path": "alpha.md", "content": "granted" }), "assistant").expect("queue");
|
|
// The gate also checks deletes separately.
|
|
let delete_denied = client.run_as("note.delete", &json!({ "path": "alpha.md" }), "assistant").err();
|
|
(read, denied, queued, delete_denied)
|
|
});
|
|
let (_read, denied, queued, delete_denied) = drive(&mut handle, &mut session, ®istry, worker);
|
|
match denied {
|
|
Err(Error::Command { source, .. }) => assert_eq!(source.code, "assistant_denied", "{}", source.message),
|
|
other => panic!("expected a command error, found {other:?}"),
|
|
}
|
|
assert!(delete_denied.is_some(), "a delete still needs allow_delete even with the grant");
|
|
assert_eq!(queued.get("queued").and_then(Value::as_bool), Some(true));
|
|
let grant_reason = delete_denied.and_then(|error| match error {
|
|
Error::Command { source, .. } => Some(source.code),
|
|
_ => None,
|
|
});
|
|
assert_eq!(grant_reason.as_deref(), Some("assistant_denied"));
|
|
}
|
|
|
|
/// A wrong `source` value is a protocol refusal, read from a raw socket with
|
|
/// the token a client would present.
|
|
#[test]
|
|
fn a_bad_source_is_a_protocol_refusal() {
|
|
let (_keep, _session, _registry, handle) = spun_up("assistant-control-bad-source");
|
|
let mut stream = TcpStream::connect(("127.0.0.1", handle.port())).expect("connect");
|
|
let line =
|
|
format!("{{\"id\":9,\"token\":\"{}\",\"method\":\"run\",\"params\":{{\"id\":\"note.read\",\"json\":{{}},\"source\":\"spooky\"}}}}\n", handle.token());
|
|
stream.write_all(line.as_bytes()).expect("write the request");
|
|
let reply = read_reply(&mut stream).expect("a reply");
|
|
let reply: Value = serde_json::from_slice(&reply).expect("parse the reply");
|
|
assert_eq!(reply["error"]["code"], json!("invalid_params"), "{reply}");
|
|
assert!(reply["error"]["message"].as_str().is_some_and(|message| message.contains("assistant")), "{reply}");
|
|
assert_eq!(reply["id"], json!(9u64), "the id is echoed");
|
|
}
|
|
|
|
/// One reply line (up to the newline) from a raw socket, `None` at EOF.
|
|
fn read_reply(stream: &mut TcpStream) -> Option<Vec<u8>> {
|
|
use std::io::Read;
|
|
let mut gathered = Vec::new();
|
|
let mut chunk = [0u8; 512];
|
|
loop {
|
|
if let Some(pos) = gathered.iter().position(|&b| b == b'\n') {
|
|
gathered.truncate(pos);
|
|
return Some(gathered);
|
|
}
|
|
let read = stream.read(&mut chunk).ok()?;
|
|
if read == 0 {
|
|
return gathered.is_empty().then_some(gathered);
|
|
}
|
|
gathered.extend_from_slice(&chunk[..read]);
|
|
}
|
|
}
|