use std::sync::Arc;
use color_eyre::eyre::Result;
use serde_json::{json, Value};
use crate::cli::lint::{
fetch_env_lint_inputs, fetch_stale_platform_issues, run_rules_for_env, EnvLintInputs,
};
use crate::{audit as audit_log, aws, cost_cache, demo_fixture, lint, terraform, util};
mod annotations;
mod setup;
mod tools;
mod writes;
use tools::*;
pub(crate) const PROTOCOL_VERSION: &str = "2025-06-18";
const TOOL_TIMEOUT_SECS: u64 = 30;
const ASK_TIMEOUT_SECS: u64 = 300;
const ASK_WAIT_SECS: u64 = ASK_TIMEOUT_SECS - 20;
const _: () = assert!(
ASK_WAIT_SECS < ASK_TIMEOUT_SECS,
"the ask must resolve inside the call that carries it, or the deny \
and its audit line are both lost to the outer timeout"
);
const _: () = assert!(
ASK_WAIT_SECS > TOOL_TIMEOUT_SECS,
"and it must still be a human-sized wait, not an AWS-sized one"
);
const _: () = assert!(ASK_TIMEOUT_SECS > TOOL_TIMEOUT_SECS * 5);
const _: () = assert!(ASK_TIMEOUT_SECS <= 900);
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum AskOutcome {
Approved,
Declined,
Unanswered,
Undeliverable,
Unsupported,
Unconfirmed,
NotAsked,
}
impl AskOutcome {
pub(crate) fn refuses(self) -> bool {
matches!(
self,
AskOutcome::Declined
| AskOutcome::Unanswered
| AskOutcome::Unconfirmed
| AskOutcome::Unsupported
| AskOutcome::Undeliverable
)
}
pub(crate) fn answer_label(self) -> &'static str {
match self {
AskOutcome::Approved => "approved",
AskOutcome::Declined => "declined",
AskOutcome::Unanswered => "unanswered",
AskOutcome::Unconfirmed => "unconfirmed",
AskOutcome::Unsupported => "unsupported",
AskOutcome::Undeliverable => "undeliverable",
AskOutcome::NotAsked => "not_asked",
}
}
pub(crate) fn guidance(self) -> &'static str {
match self {
AskOutcome::Declined => {
"The plan is spent; do not re-plan the same action unless the operator \
asks for it. Say the confirmation was declined — do NOT tell your \
user a person refused, unless you independently know one was there. \
A non-interactive client (a `-p` run, a CI harness) declares the \
same capability and declines automatically with nobody present, and \
that is indistinguishable from here."
}
AskOutcome::Undeliverable => {
"The plan is spent. The confirmation could not be delivered — your \
client is gone or its channel is closed, so NOBODY was asked. Not a \
decline. Say the connection dropped before the operator could be \
asked."
}
AskOutcome::Unsupported => {
"The plan is spent. Your client could not present the confirmation — \
it answered with an error, so NOBODY was asked and nobody refused. \
Do not report this as a decline. Tell the operator their client \
cannot show ebman's confirmations, and that writes need either a \
client that can or `--allow-writes` on one that cannot be asked."
}
AskOutcome::Unconfirmed => {
"The plan is spent. Nobody declined — the confirmation text did not \
match, which for this verb is required and is typed by the OPERATOR, \
not by you. If their client cannot show a text field, this verb \
cannot be confirmed there at all: say so and suggest the TUI, or \
`--allow-writes` on a client that can. If they simply mistyped, they \
can ask you to try again."
}
AskOutcome::Unanswered => {
"The plan is spent. Nobody answered, which is NOT a refusal — the \
operator may have stepped away, or may never have been shown the \
dialog. Tell them it expired and let them decide. If they ask you \
to try again, plan it fresh; do not re-plan it on your own \
initiative, and do not report this as a refusal."
}
AskOutcome::Approved | AskOutcome::NotAsked => "",
}
}
pub(crate) fn reason(self) -> &'static str {
match self {
AskOutcome::Approved => "approved",
AskOutcome::Declined => "the confirmation was declined",
AskOutcome::Unanswered => "no answer within the ask window",
AskOutcome::Unconfirmed => {
"the typed confirmation did not match (or your client returned none)"
}
AskOutcome::Unsupported => {
"your client could not present the confirmation and returned an error"
}
AskOutcome::Undeliverable => "the confirmation could not be delivered to your client",
AskOutcome::NotAsked => "not asked",
}
}
}
pub(crate) fn ask_outcome_from(reply: &Value) -> AskOutcome {
if reply.get("error").is_some() {
return AskOutcome::Unsupported;
}
match reply
.get("result")
.and_then(|r| r.get("action"))
.and_then(Value::as_str)
{
Some("accept") => AskOutcome::Approved,
Some("decline") | Some("cancel") => AskOutcome::Declined,
_ => AskOutcome::Declined,
}
}
pub(crate) fn call_timeout_secs(tool: &str, client_can_elicit: bool) -> u64 {
if tool == writes::CONFIRM_TOOL && client_can_elicit {
ASK_TIMEOUT_SECS
} else {
TOOL_TIMEOUT_SECS
}
}
#[derive(Debug, PartialEq, Eq)]
struct McpArgs {
demo: bool,
no_redact: bool,
write_scope: WriteScope,
read_only: bool,
}
const MCP_USAGE: &str = "usage: ebman mcp <serve [--demo] [--no-redact] [--read-only] \
[--allow-writes[=verb,verb]] | setup [--allow-writes[=verb,verb]]>";
fn parse_mcp_args(args: &[String]) -> Result<McpArgs, String> {
if args.get(1).map(String::as_str) != Some("serve") {
return Err(MCP_USAGE.into());
}
let mut demo = false;
let mut no_redact = false;
let mut write_scope = WriteScope::None;
let mut saw_write_flag = false;
let mut read_only = false;
let known: Vec<String> = writes::write_verb_names();
let known_refs: Vec<&str> = known.iter().map(String::as_str).collect();
for arg in args.iter().skip(2) {
if arg == "--read-only" {
if read_only {
return Err(format!("ebman mcp: --read-only given twice — {MCP_USAGE}"));
}
read_only = true;
continue;
}
if let Some(rest) = arg.strip_prefix("--allow-writes") {
if saw_write_flag {
return Err(format!(
"ebman mcp: --allow-writes given more than once — a second one \
would silently widen the first. Name every verb in one flag: \
--allow-writes=a,b — {MCP_USAGE}"
));
}
saw_write_flag = true;
let value = match rest {
"" => None,
v => Some(v.strip_prefix('=').ok_or_else(|| {
format!("ebman mcp: expected `--allow-writes=verbs` — {MCP_USAGE}")
})?),
};
write_scope =
parse_write_scope(value, &known_refs).map_err(|e| format!("ebman mcp: {e}"))?;
continue;
}
match arg.as_str() {
"--demo" => demo = true,
"--no-redact" => no_redact = true,
other => return Err(format!("ebman mcp: unknown flag '{other}' — {MCP_USAGE}")),
}
}
if read_only && saw_write_flag {
return Err(format!(
"ebman mcp: --read-only and --allow-writes contradict each other — {MCP_USAGE}"
));
}
if read_only {
write_scope = WriteScope::None;
}
Ok(McpArgs {
demo,
no_redact,
write_scope,
read_only,
})
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) enum WriteScope {
None,
All,
Only(Vec<String>),
}
impl WriteScope {
pub(crate) fn allows(&self, tool: &str) -> bool {
match self {
WriteScope::None => false,
WriteScope::All => true,
WriteScope::Only(v) => v.iter().any(|t| t == tool),
}
}
pub(crate) fn any(&self) -> bool {
match self {
WriteScope::None => false,
WriteScope::All => true,
WriteScope::Only(v) => !v.is_empty(),
}
}
fn agent_summary(
&self,
standing_refusal: Option<&str>,
can_ask: bool,
opened_by_ask: bool,
) -> String {
if let Some(why) = standing_refusal {
return format!(
"Writes are REFUSED on this server, regardless of any grant: {why} No plan \
will dispatch and no confirmation will lift it. Only the operator changing \
that control can. Do not plan writes and do not report this as a fault — \
`doctor` reports it too."
);
}
match self {
WriteScope::None => "This server is READ-ONLY: no write tool is available. ASK the \
operator to restart it with --allow-writes (optionally \
--allow-writes=verb,verb to grant only what you need) — asking \
is the whole of your part in it. Do not edit the MCP config \
yourself: a grant is not yours to make, and in at least one \
client that edit is refused as self-modification, so trying it \
costs a denial and teaches nothing."
.to_string(),
WriteScope::All => {
let mut t = "Writes are ENABLED for every verb, via the two-phase \
plan-then-confirm protocol."
.to_string();
if can_ask {
t.push_str(ASK_NOTE);
}
if opened_by_ask {
t.push_str(OPENED_BY_ASK_NOTE);
}
t
}
WriteScope::Only(v) => {
format!(
"Writes are NARROWLY granted: {} only, via the two-phase plan-then-confirm \
protocol. Any other write verb is absent from this list because it was NOT \
GRANTED, not because ebman lacks it — say so and ask the operator to widen \
the grant rather than reporting it as unsupported. Asking is your part; \
the config edit is theirs. If they say they granted a verb and it is still \
missing here, the likeliest cause is a client that reconnected without \
re-reading its config — ask them to restart the client before either of you \
concludes it is broken.",
v.join(", ")
) + if can_ask { ASK_NOTE } else { "" }
}
}
}
}
const OPENED_BY_ASK_NOTE: &str = "\n\nWORTH SAYING ONCE, EARLY: writes are \
available here because YOUR CLIENT can put a question to the operator, not \
because they passed a flag. They may not know the surface widened — on a bare \
registration it widens when the client reconnects against this build. Tell them \
plainly the first time it becomes relevant, before you plan a write rather than \
after. Name the BREADTH, not just the fact: the default is every verb, \
`terminate` included, so an operator who wanted one narrow thing got the rest \
alongside it. Say that every action will be put to them and they can decline, \
that `--allow-writes=verb,verb` narrows this permanently, and that \
`--read-only` keeps the old posture if they would rather. Do not treat this as a \
reason to avoid proposing work — it is a reason they should not be surprised by \
it.";
const ASK_NOTE: &str = "\n\nEach confirmation is sent to your CLIENT to put to the \
operator. Expect `confirm_action` to take as long as a person takes — or to come \
back at once, which is what a non-interactive client does when it answers for \
itself. A decline is FINAL — not an error, not a missing permission: do not \
re-plan the same action, do not ask for the grant to be widened, and do not \
report it as a fault. Say the confirmation was declined, and stop. Do NOT tell \
your user a person refused unless you independently know one was there: ebman \
cannot tell an operator answering a dialog from a client answering for itself, \
and saying otherwise puts a decision in someone's mouth.\n\nSURFACE THE PLAN; \
DO NOT RESTATE THE CASE FOR IT. The confirmation already names the action, the \
targets, the identity and what it forecloses, and the operator is about to read \
it. Re-deriving the reasoning in your own message — especially reasoning you and \
they settled days ago — turns a gate they approved of into a tax they resent, and \
the tax is charged on every single write. Say what you are about to do in a line, \
and let the dialog do the rest.";
#[cfg(test)]
pub(crate) fn write_verb_names_for_docs() -> Vec<String> {
writes::write_verb_names()
}
pub fn wants_file_logging(args: &[String]) -> bool {
args.get(1).map(String::as_str) == Some("serve")
}
pub(crate) fn should_init_audit(scope: &WriteScope, demo: bool) -> bool {
scope.any() && !demo
}
pub(crate) fn parse_write_scope(value: Option<&str>, known: &[&str]) -> Result<WriteScope, String> {
let Some(v) = value else {
return Ok(WriteScope::All);
};
let mut out = Vec::new();
for raw in v.split(',') {
let name = raw.trim();
if name.is_empty() {
continue;
}
if !known.contains(&name) {
let mut sorted: Vec<&str> = known.to_vec();
sorted.sort_unstable();
return Err(format!(
"unknown write verb '{name}' — known verbs: {}",
sorted.join(", ")
));
}
if !out.iter().any(|e| e == name) {
out.push(name.to_string());
}
}
if out.is_empty() {
return Err("--allow-writes= was given with no verbs — omit the \
`=` to allow all, or name at least one"
.into());
}
Ok(WriteScope::Only(out))
}
fn invalid_request_response(req: &Value) -> Option<String> {
if req.is_object() {
return None;
}
Some(
json!({
"jsonrpc": "2.0",
"id": null,
"error": {"code": -32600, "message": "invalid request: expected a single JSON-RPC object"}
})
.to_string(),
)
}
pub(crate) use crate::util::redact_option_value;
enum Backend {
Aws,
Demo,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct ExeIdentity {
pub path: String,
pub inode: Option<u64>,
}
impl ExeIdentity {
fn of(path: &std::path::Path) -> Self {
use std::os::unix::fs::MetadataExt;
Self {
path: path.display().to_string(),
inode: std::fs::metadata(path).ok().map(|m| m.ino()),
}
}
pub(crate) fn current() -> Option<Self> {
std::env::current_exe().ok().map(|p| Self::of(&p))
}
#[cfg(test)]
pub(crate) fn for_tests(path: &std::path::Path) -> Self {
Self::of(path)
}
fn is_stale(&self) -> bool {
staleness(self.inode, Self::of(std::path::Path::new(&self.path)).inode)
}
}
pub(crate) fn staleness(at_start: Option<u64>, now: Option<u64>) -> bool {
match (at_start, now) {
(Some(a), Some(b)) => a != b,
_ => false,
}
}
pub(crate) fn stale_binary_notice(path: &str) -> String {
format!(
"[ebman {} is running, but a different build is now installed at {}. \
This server keeps the old one until the connection is re-established. \
ASK YOUR OPERATOR to reconnect (in Claude Code: /mcp, Reconnect) — you \
cannot do it yourself: it is a client action, not a tool call, and \
there is no MCP message a server can send to trigger one. Results \
below are from the running build.]",
env!("CARGO_PKG_VERSION"),
path
)
}
pub(crate) struct Server {
backend: Backend,
redact: bool,
write_scope: WriteScope,
safety_cfg: crate::config::Config,
writes: tokio::sync::Mutex<writes::WriteState>,
outbound: std::sync::Mutex<Option<tokio::sync::mpsc::Sender<String>>>,
pending_asks:
std::sync::Mutex<std::collections::HashMap<i64, tokio::sync::oneshot::Sender<Value>>>,
next_ask_id: std::sync::atomic::AtomicI64,
deleted: tokio::sync::Mutex<Vec<writes::DeletedMessage>>,
dispatching: std::sync::atomic::AtomicBool,
client_supports_elicitation: std::sync::atomic::AtomicBool,
mcp_read_only: std::sync::atomic::AtomicBool,
client_name: std::sync::Mutex<String>,
exe: Option<ExeIdentity>,
#[cfg(test)]
injected_client: Option<std::sync::Arc<aws::AwsClient>>,
}
impl Server {
pub(crate) fn with_scope(demo: bool, no_redact: bool, scope: WriteScope) -> Self {
let safety_cfg = if demo {
crate::config::Config::default()
} else {
crate::config::load()
};
Self::with_config(demo, no_redact, scope, safety_cfg)
}
pub(crate) fn with_config(
demo: bool,
no_redact: bool,
scope: WriteScope,
safety_cfg: crate::config::Config,
) -> Self {
Server {
backend: if demo { Backend::Demo } else { Backend::Aws },
redact: !no_redact,
write_scope: scope,
safety_cfg,
writes: tokio::sync::Mutex::new(writes::WriteState::default()),
outbound: std::sync::Mutex::new(None),
pending_asks: std::sync::Mutex::new(std::collections::HashMap::new()),
next_ask_id: std::sync::atomic::AtomicI64::new(1_000_000),
deleted: tokio::sync::Mutex::new(Vec::new()),
dispatching: std::sync::atomic::AtomicBool::new(false),
client_name: std::sync::Mutex::new("unknown".to_string()),
client_supports_elicitation: std::sync::atomic::AtomicBool::new(false),
mcp_read_only: std::sync::atomic::AtomicBool::new(false),
exe: ExeIdentity::current(),
#[cfg(test)]
injected_client: None,
}
}
#[cfg(test)]
pub(crate) fn watching_exe(mut self, path: &std::path::Path) -> Self {
self.exe = Some(ExeIdentity::for_tests(path));
self
}
#[cfg(test)]
pub(crate) fn with_injected_client(
scope: WriteScope,
safety_cfg: crate::config::Config,
client: aws::AwsClient,
) -> Self {
let mut s = Self::with_config(false, false, scope, safety_cfg);
s.injected_client = Some(std::sync::Arc::new(client));
s
}
pub(crate) fn effective_scope(&self) -> WriteScope {
if self
.mcp_read_only
.load(std::sync::atomic::Ordering::Relaxed)
{
return WriteScope::None;
}
if matches!(self.write_scope, WriteScope::None)
&& self
.client_supports_elicitation
.load(std::sync::atomic::Ordering::Relaxed)
{
return WriteScope::All;
}
self.write_scope.clone()
}
async fn notify_cancelled(&self, id: i64, reason: &str) {
let Some(tx) = self.outbound.lock().ok().and_then(|g| g.clone()) else {
return;
};
let frame = json!({
"jsonrpc": "2.0",
"method": "notifications/cancelled",
"params": { "requestId": id, "reason": reason }
});
let _ = tx.try_send(frame.to_string());
}
pub(crate) async fn ask_operator(&self, summary: &str, typed: Option<&str>) -> AskOutcome {
if !self
.client_supports_elicitation
.load(std::sync::atomic::Ordering::Relaxed)
{
return AskOutcome::NotAsked;
}
let Some(tx) = self.outbound.lock().ok().and_then(|g| g.clone()) else {
return AskOutcome::Undeliverable;
};
let id = self
.next_ask_id
.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
let (reply_tx, reply_rx) = tokio::sync::oneshot::channel();
if let Ok(mut map) = self.pending_asks.lock() {
map.insert(id, reply_tx);
}
let schema = match typed {
None => json!({"type": "object", "properties": {}}),
Some(_) => json!({
"type": "object",
"properties": {
"confirm": {
"type": "string",
"description": "Type the environment name exactly, to confirm",
}
},
"required": ["confirm"],
}),
};
let frame = json!({
"jsonrpc": "2.0",
"id": id,
"method": "elicitation/create",
"params": {
"message": summary,
"requestedSchema": schema
}
});
if tx.send(frame.to_string()).await.is_err() {
self.forget_ask(id);
return AskOutcome::Undeliverable;
}
match tokio::time::timeout(std::time::Duration::from_secs(ASK_WAIT_SECS), reply_rx).await {
Ok(Ok(reply)) => match (ask_outcome_from(&reply), typed) {
(AskOutcome::Approved, Some(expected)) => {
let got = reply
.get("result")
.and_then(|r| r.get("content"))
.and_then(|c| c.get("confirm"))
.and_then(Value::as_str)
.unwrap_or_default();
if got == expected {
AskOutcome::Approved
} else {
AskOutcome::Unconfirmed
}
}
(outcome, _) => outcome,
},
_ => {
self.forget_ask(id);
self.notify_cancelled(id, "the ask window expired with no answer")
.await;
AskOutcome::Unanswered
}
}
}
fn forget_ask(&self, id: i64) {
if let Ok(mut map) = self.pending_asks.lock() {
map.remove(&id);
}
}
pub(crate) fn take_ask_reply(&self, frame: &Value) -> bool {
if frame.get("method").is_some() {
return false;
}
let Some(id) = frame.get("id").and_then(Value::as_i64) else {
return false;
};
let waiting = self
.pending_asks
.lock()
.ok()
.and_then(|mut m| m.remove(&id));
match waiting {
Some(tx) => {
let _ = tx.send(frame.clone());
true
}
None => false,
}
}
pub(crate) async fn handle_request(&self, req: &Value) -> Option<Value> {
let id = req.get("id").cloned();
let method = req.get("method").and_then(Value::as_str).unwrap_or("");
match id {
None | Some(Value::Null) => return None,
Some(_) => {}
}
match method {
"initialize" => {
if let Some(name) = req
.get("params")
.and_then(|p| p.get("clientInfo"))
.and_then(|c| c.get("name"))
.and_then(Value::as_str)
{
if let Ok(mut cn) = self.client_name.lock() {
*cn = name.to_string();
}
}
let elicits = req
.get("params")
.and_then(|p| p.get("capabilities"))
.and_then(|c| c.get("elicitation"))
.is_some_and(Value::is_object);
self.client_supports_elicitation
.store(elicits, std::sync::atomic::Ordering::Relaxed);
if self.effective_scope().any() && !matches!(self.backend, Backend::Demo) {
crate::audit::init_from_config_disk();
}
tracing::info!(
target: "ebman::mcp",
client = %self.client_name.lock().map(|c| c.clone()).unwrap_or_default(),
elicitation = elicits,
"MCP client connected"
);
let client_version = req
.get("params")
.and_then(|p| p.get("protocolVersion"))
.and_then(Value::as_str)
.unwrap_or("");
let version = if client_version == PROTOCOL_VERSION {
client_version
} else {
PROTOCOL_VERSION
};
Some(json!({
"jsonrpc": "2.0",
"id": id,
"result": {
"protocolVersion": version,
"capabilities": {"tools": {}},
"serverInfo": {"name": "ebman", "version": env!("CARGO_PKG_VERSION")},
"instructions": format!(
"{}{}\n\n{}",
concat!(
"ebman ", env!("CARGO_PKG_VERSION"),
" — a fleet console for AWS Elastic Beanstalk. This surface exposes reads, ",
"plus two-phase writes. Whether writes are available to YOU is said below; ",
"it depends on this connection, not on the binary.\n\n"),
self.effective_scope().agent_summary(
if !self.safety_cfg.safety_parse_errors.is_empty() {
Some("the safety config could not be parsed, which \
fails closed.")
} else if self.safety_cfg.safety_read_only {
Some("safety.read_only is set in config.toml.")
} else {
None
},
self.client_supports_elicitation
.load(std::sync::atomic::Ordering::Relaxed),
!self.write_scope.any()
&& self
.client_supports_elicitation
.load(std::sync::atomic::Ordering::Relaxed),
),
concat!(
"Check this against the latest release before reporting a capability as missing — ",
"this list describes THIS build.\n\n",
"Capabilities ebman HAS that this surface does NOT expose — ask the operator to run them, ",
"or ask for them to be exposed here:\n",
"- Nothing queue-related: depth and peek are `worker_queues`, and resend / delete / ",
"purge are `dlq_resend` / `dlq_delete` / `dlq_purge` under --allow-writes.\n",
"- A LIVE log tail (streaming, follows new lines): TUI, Detail view, Logs tab. ",
"Point-in-time log queries ARE exposed here, as `recent_logs`.\n\n",
"Tool descriptions carry CAVEATS naming what each tool cannot see. They are accurate and ",
"worth reading: a clean result from a tool does not clear what that tool never checked.\n\n",
"Before reporting ANY capability as missing, call `doctor`. It names this build, what ",
"your client declared, the write surface in force, and the operator's standing ",
"restrictions — which is how you tell \"ebman cannot\" from \"your client cannot\" from ",
"\"the operator said no\". Those three are indistinguishable from where you sit, and only ",
"the first is a bug worth reporting."
))
}
}))
}
"ping" => Some(json!({"jsonrpc": "2.0", "id": id, "result": {}})),
"tools/list" => Some(json!({
"jsonrpc": "2.0",
"id": id,
"result": {"tools": tool_table(&self.effective_scope(), self.safety_cfg.mcp_peek_bodies)}
})),
"tools/call" => {
let params = req.get("params").cloned().unwrap_or_else(|| json!({}));
let name = params
.get("name")
.and_then(Value::as_str)
.unwrap_or("")
.to_string();
let args = params
.get("arguments")
.cloned()
.unwrap_or_else(|| json!({}));
let advertised =
tool_table(&self.effective_scope(), self.safety_cfg.mcp_peek_bodies)
.as_array()
.is_some_and(|t| t.iter().any(|d| d["name"] == name.as_str()));
if !advertised && !writes::write_verb_names().contains(&name) {
return Some(json!({
"jsonrpc": "2.0",
"id": id,
"error": {"code": -32602, "message": format!("unknown tool '{name}'")}
}));
}
let budget = call_timeout_secs(
&name,
self.client_supports_elicitation
.load(std::sync::atomic::Ordering::Relaxed),
);
let outcome = tokio::time::timeout(
std::time::Duration::from_secs(budget),
self.call_tool(&name, &args),
)
.await
.unwrap_or_else(|_| Err(format!("tool '{name}' timed out after {budget}s")));
let (text, is_error) = match outcome {
Ok(body) => (body, false),
Err(msg) => (msg, true),
};
let text = match self.exe.as_ref().filter(|e| e.is_stale()) {
Some(e) => format!("{}\n{text}", stale_binary_notice(&e.path)),
None => text,
};
Some(json!({
"jsonrpc": "2.0",
"id": id,
"result": {
"content": [{"type": "text", "text": text}],
"isError": is_error
}
}))
}
_ => Some(json!({
"jsonrpc": "2.0",
"id": id,
"error": {"code": -32601, "message": format!("method '{method}' not found")}
})),
}
}
}
pub async fn run(args: &[String]) -> Result<()> {
match args.get(1).map(String::as_str) {
Some("setup") => return setup::run(args),
Some("serve") => {}
_ => {
eprintln!("{MCP_USAGE}");
std::process::exit(2);
}
}
let McpArgs {
demo,
no_redact,
write_scope,
read_only,
} = match parse_mcp_args(args) {
Ok(parsed) => parsed,
Err(msg) => {
eprintln!("{msg}");
std::process::exit(2);
}
};
if should_init_audit(&write_scope, demo) {
crate::audit::init_from_config_disk();
}
let server = Arc::new(Server::with_scope(demo, no_redact, write_scope.clone()));
server
.mcp_read_only
.store(read_only, std::sync::atomic::Ordering::Relaxed);
let tool_slots = Arc::new(tokio::sync::Semaphore::new(16));
let (out_tx, mut out_rx) = tokio::sync::mpsc::channel::<String>(256);
if let Ok(mut slot) = server.outbound.lock() {
*slot = Some(out_tx.clone());
}
let writer = tokio::spawn(async move {
use tokio::io::AsyncWriteExt;
let mut stdout = tokio::io::stdout();
while let Some(line) = out_rx.recv().await {
if stdout.write_all(line.as_bytes()).await.is_err()
|| stdout.write_all(b"\n").await.is_err()
|| stdout.flush().await.is_err()
{
eprintln!("ebman mcp: stdout closed — dropping remaining frames");
break;
}
}
});
use futures::StreamExt;
use tokio_util::codec::{FramedRead, LinesCodec};
const MAX_LINE_BYTES: usize = 16 * 1024 * 1024;
let mut lines = FramedRead::new(
tokio::io::stdin(),
LinesCodec::new_with_max_length(MAX_LINE_BYTES),
);
let mut consecutive_read_errors: u32 = 0;
let mut last_was_error = false;
loop {
let line = match lines.next().await {
Some(Ok(line)) => {
consecutive_read_errors = 0;
last_was_error = false;
line
}
None => {
if last_was_error {
last_was_error = false;
continue;
}
break;
}
Some(Err(e)) => {
consecutive_read_errors += 1;
last_was_error = true;
eprintln!("ebman mcp: stdin read error (skipping line): {e}");
if consecutive_read_errors >= 5 {
eprintln!(
"ebman mcp: {consecutive_read_errors} consecutive read errors — exiting"
);
break;
}
continue;
}
};
let line = line.trim();
if line.is_empty() {
continue;
}
let req: Value = match serde_json::from_str(line) {
Ok(v) => v,
Err(_) => {
let _ = out_tx
.send(
json!({
"jsonrpc": "2.0",
"id": null,
"error": {"code": -32700, "message": "parse error"}
})
.to_string(),
)
.await;
continue;
}
};
if server.take_ask_reply(&req) {
continue;
}
if req.get("method").is_none()
&& req.get("id").is_some()
&& (req.get("result").is_some() || req.get("error").is_some())
{
tracing::debug!(
target: "ebman::mcp",
id = ?req.get("id"),
"dropping a reply to an ask that is no longer pending"
);
continue;
}
if let Some(resp) = invalid_request_response(&req) {
let _ = out_tx.send(resp).await;
continue;
}
if req.get("method").and_then(Value::as_str) == Some("tools/call") {
let permit = Arc::clone(&tool_slots).acquire_owned().await;
let server = Arc::clone(&server);
let out_tx = out_tx.clone();
tokio::spawn(async move {
let _permit = permit;
if let Some(resp) = server.handle_request(&req).await {
let _ = out_tx.send(resp.to_string()).await;
}
});
} else if let Some(resp) = server.handle_request(&req).await {
let _ = out_tx.send(resp.to_string()).await;
}
}
if let Ok(mut slot) = server.outbound.lock() {
*slot = None;
}
drop(out_tx);
let _ = writer.await;
if server.effective_scope().any() {
crate::audit::drain_webhooks(std::time::Duration::from_secs(12)).await;
}
Ok(())
}
#[cfg(test)]
mod tests {
#[tokio::test]
async fn the_elicitation_capability_is_detected_per_client() {
use std::sync::atomic::Ordering;
async fn declares(caps: serde_json::Value) -> bool {
let s = Server::with_scope(true, false, WriteScope::None);
let _ = s
.handle_request(&json!({
"jsonrpc": "2.0", "id": 1, "method": "initialize",
"params": {
"protocolVersion": PROTOCOL_VERSION,
"capabilities": caps,
"clientInfo": {"name": "probe", "version": "1"}
}
}))
.await;
s.client_supports_elicitation.load(Ordering::Relaxed)
}
assert!(
declares(json!({"elicitation": {}})).await,
"a client declaring elicitation must be detected, or `ask` \
degrades to a refusal for everyone"
);
for explicit_no in [json!({"elicitation": false}), json!({"elicitation": null})] {
assert!(
!declares(explicit_no.clone()).await,
"{explicit_no} explicitly declines elicitation; treating \
mere presence as support would let `ask` believe a human \
is watching when none is"
);
}
assert!(
!declares(json!({})).await,
"a client declaring nothing must NOT be treated as able to ask"
);
assert!(
!declares(json!({"sampling": {}, "roots": {}})).await,
"other capabilities are not elicitation"
);
}
use super::*;
fn argv(parts: &[&str]) -> Vec<String> {
parts.iter().map(|s| s.to_string()).collect()
}
fn demo_server() -> Server {
Server::with_scope(true, false, WriteScope::None)
}
fn demo_writes_server() -> Server {
Server::with_scope(true, false, WriteScope::All)
}
async fn rpc(server: &Server, frame: Value) -> Option<Value> {
server.handle_request(&frame).await
}
async fn call(server: &Server, name: &str, args: Value) -> (bool, Value) {
let frame = json!({"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":name,"arguments":args}});
let resp = server.handle_request(&frame).await.expect("response");
let result = &resp["result"];
let is_error = result["isError"].as_bool().unwrap_or(false);
let text = result["content"][0]["text"].as_str().unwrap_or("");
let parsed = serde_json::from_str(text).unwrap_or(Value::String(text.to_string()));
(is_error, parsed)
}
#[tokio::test]
async fn write_tools_appear_only_under_allow_writes() {
let list = |s: &Server| {
let arr = tool_table(&s.write_scope, true);
arr.as_array()
.unwrap()
.iter()
.map(|t| t["name"].as_str().unwrap().to_string())
.collect::<Vec<_>>()
};
let reads = list(&demo_server());
assert!(!reads.contains(&"deploy".to_string()));
assert!(!reads.contains(&"confirm_action".to_string()));
let writes = list(&demo_writes_server());
for t in [
"deploy",
"restart",
"rebuild",
"terminate",
"set_option",
"confirm_action",
] {
assert!(writes.contains(&t.to_string()), "missing {t}");
}
}
#[tokio::test]
async fn two_phase_happy_path_demo() {
let s = demo_writes_server();
let envs = demo_fixture::envs();
let env = &envs[0].name;
let (err, plan) = call(&s, "restart", json!({"env": env})).await;
assert!(!err);
assert_eq!(plan["pending"], true);
assert_eq!(plan["plan"]["action"], "Restart");
let token = plan["confirm_token"].as_str().unwrap().to_string();
let (err2, out) = call(&s, "confirm_action", json!({"confirm_token": token})).await;
assert!(!err2);
assert_eq!(out["dispatched"], true);
assert_eq!(out["demo"], true);
}
#[tokio::test]
async fn confirm_token_single_use_and_unknown() {
let s = demo_writes_server();
let env = &demo_fixture::envs()[0].name;
let (_, plan) = call(&s, "restart", json!({"env": env})).await;
let token = plan["confirm_token"].as_str().unwrap().to_string();
assert!(
!call(
&s,
"confirm_action",
json!({"confirm_token": token.clone()})
)
.await
.0
);
assert!(
call(&s, "confirm_action", json!({"confirm_token": token}))
.await
.0
);
assert!(
call(&s, "confirm_action", json!({"confirm_token": "deadbeef"}))
.await
.0
);
assert!(
call(&s, "confirm_action", json!({"confirm_token": "x"}))
.await
.0
);
}
#[tokio::test]
async fn terminate_requires_matching_confirm_name_with_one_retry() {
let s = demo_writes_server();
let env = demo_fixture::envs()[0].name.clone();
let (_, plan) = call(&s, "terminate", json!({"env": env})).await;
let token = plan["confirm_token"].as_str().unwrap().to_string();
assert!(
call(
&s,
"confirm_action",
json!({"confirm_token": token.clone(), "confirm_name": "wrong"})
)
.await
.0
);
let (err, out) = call(
&s,
"confirm_action",
json!({"confirm_token": token, "confirm_name": env}),
)
.await;
assert!(!err);
assert_eq!(out["dispatched"], true);
}
#[tokio::test]
async fn terminate_second_wrong_name_drops_the_plan() {
let s = demo_writes_server();
let env = demo_fixture::envs()[0].name.clone();
let (_, plan) = call(&s, "terminate", json!({"env": env})).await;
let token = plan["confirm_token"].as_str().unwrap().to_string();
assert!(
call(
&s,
"confirm_action",
json!({"confirm_token": token.clone(), "confirm_name": "wrong"})
)
.await
.0
);
assert!(
call(
&s,
"confirm_action",
json!({"confirm_token": token.clone(), "confirm_name": "wrong"})
)
.await
.0
);
let env2 = demo_fixture::envs()[0].name.clone();
assert!(
call(
&s,
"confirm_action",
json!({"confirm_token": token, "confirm_name": env2})
)
.await
.0
);
}
#[tokio::test]
async fn deploy_rejects_unknown_version_and_plan_carries_versions() {
let s = demo_writes_server();
let envs = demo_fixture::envs();
let env = &envs[0];
let known = &demo_fixture::deploys_for_app(&env.application)[0].label;
let (err, plan) = call(&s, "deploy", json!({"env": env.name, "version": known})).await;
assert!(!err);
assert_eq!(plan["plan"]["target_version"], known.as_str());
assert!(plan["plan"]["current_version"].is_string());
let (err2, _) = call(
&s,
"deploy",
json!({"env": env.name, "version": "no-such-999"}),
)
.await;
assert!(err2, "unknown version must refuse");
}
#[tokio::test]
async fn set_option_caps_and_gates_namespaces_and_redacts_old() {
let s = demo_writes_server();
let env = &demo_fixture::envs()[0].name;
let big: Vec<Value> = (0..11)
.map(|i| json!({"namespace": "aws:autoscaling:asg", "name": format!("n{i}"), "value": "1"}))
.collect();
assert!(
call(&s, "set_option", json!({"env": env, "settings": big}))
.await
.0
);
assert!(
call(
&s,
"set_option",
json!({"env": env, "settings": [{"namespace":"made:up","name":"X","value":"1"}]})
)
.await
.0
);
let (err, plan) = call(
&s,
"set_option",
json!({"env": env, "settings": [{"namespace":"aws:autoscaling:asg","name":"MinSize","value":"9"}]}),
)
.await;
assert!(!err);
assert_eq!(plan["plan"]["changes"][0]["new"], "9");
}
#[tokio::test]
async fn dispatching_flag_clears_after_dispatch() {
let s = demo_writes_server();
let env = &demo_fixture::envs()[0].name;
let (_, p1) = call(&s, "restart", json!({"env": env})).await;
let t1 = p1["confirm_token"].as_str().unwrap().to_string();
assert!(
!call(&s, "confirm_action", json!({"confirm_token": t1}))
.await
.0
);
assert!(
!s.dispatching.load(std::sync::atomic::Ordering::SeqCst),
"guard must clear dispatching after dispatch"
);
let (_, p2) = call(&s, "restart", json!({"env": env})).await;
let t2 = p2["confirm_token"].as_str().unwrap().to_string();
assert!(
!call(&s, "confirm_action", json!({"confirm_token": t2}))
.await
.0
);
}
#[tokio::test]
async fn write_serialization_blocks_second_dispatch_slot() {
let s = demo_writes_server();
let env = &demo_fixture::envs()[0].name;
let (_, plan) = call(&s, "restart", json!({"env": env})).await;
let token = plan["confirm_token"].as_str().unwrap().to_string();
s.dispatching
.store(true, std::sync::atomic::Ordering::SeqCst);
assert!(
call(&s, "confirm_action", json!({"confirm_token": token}))
.await
.0,
"confirm must refuse while a dispatch is in flight"
);
}
#[tokio::test]
async fn write_pin_refusal() {
let mut cfg = crate::config::Config::default();
cfg.safety_envs
.insert(demo_fixture::envs()[0].name.clone(), true);
let s = Server::with_config(true, false, WriteScope::All, cfg);
let env = demo_fixture::envs()[0].name.clone();
let (err, plan) = call(&s, "restart", json!({"env": env})).await;
assert!(err, "pinned env must refuse");
assert!(
plan.as_str().unwrap_or("").contains("pinned"),
"refusal names the pin: {plan:?}"
);
}
#[test]
fn mcp_args_require_serve_and_reject_unknown_flags() {
assert!(parse_mcp_args(&argv(&["mcp"])).is_err());
assert!(parse_mcp_args(&argv(&["mcp", "listen"])).is_err());
assert!(parse_mcp_args(&argv(&["mcp", "serve", "--port"])).is_err());
let p = parse_mcp_args(&argv(&["mcp", "serve", "--demo", "--no-redact"])).unwrap();
assert!(p.demo && p.no_redact);
let p = parse_mcp_args(&argv(&["mcp", "serve"])).unwrap();
assert!(!p.demo && !p.no_redact);
}
#[tokio::test]
async fn golden_initialize_frame() {
let resp = rpc(
&demo_server(),
json!({"jsonrpc":"2.0","id":1,"method":"initialize",
"params":{"protocolVersion":"2025-06-18","capabilities":{}}}),
)
.await
.expect("initialize answers");
assert_eq!(resp["id"], 1);
assert_eq!(resp["result"]["protocolVersion"], PROTOCOL_VERSION);
assert_eq!(resp["result"]["serverInfo"]["name"], "ebman");
assert!(resp["result"]["capabilities"]["tools"].is_object());
let resp = rpc(
&demo_server(),
json!({"jsonrpc":"2.0","id":2,"method":"initialize",
"params":{"protocolVersion":"2024-11-05"}}),
)
.await
.unwrap();
assert_eq!(resp["result"]["protocolVersion"], PROTOCOL_VERSION);
}
#[tokio::test]
async fn golden_tools_list_frame() {
let resp = rpc(
&demo_server(),
json!({"jsonrpc":"2.0","id":3,"method":"tools/list"}),
)
.await
.expect("tools/list answers");
let tools = resp["result"]["tools"].as_array().expect("array");
let names: Vec<&str> = tools.iter().map(|t| t["name"].as_str().unwrap()).collect();
assert_eq!(
names,
vec![
"list_environments",
"worker_queues",
"recent_logs",
"why",
"lint",
"get_option_settings",
"drift",
"doctor",
"audit_log",
"recent_events",
"list_versions",
"fleet_cost",
],
"tool registry changed — update docs/headless.md's table"
);
let lint_desc = tools
.iter()
.find(|t| t["name"] == "lint")
.and_then(|t| t["description"].as_str())
.expect("lint is advertised");
assert!(lint_desc.contains("EBL011") && lint_desc.contains("EBL016"));
assert!(
lint_desc.contains("worker_queues"),
"the EBL011 caveat must name the tool that DOES see queues: {lint_desc}"
);
for t in tools {
assert!(t["inputSchema"]["type"] == "object", "schema shape");
assert!(
!t["description"].as_str().unwrap().is_empty(),
"empty description"
);
}
}
#[tokio::test]
async fn notifications_and_unknown_methods_route_correctly() {
let s = demo_server();
assert!(rpc(
&s,
json!({"jsonrpc":"2.0","method":"notifications/initialized"})
)
.await
.is_none());
let resp = rpc(&s, json!({"jsonrpc":"2.0","id":4,"method":"ping"}))
.await
.unwrap();
assert!(resp["result"].is_object());
let resp = rpc(
&s,
json!({"jsonrpc":"2.0","id":5,"method":"resources/list"}),
)
.await
.unwrap();
assert_eq!(resp["error"]["code"], -32601);
assert!(
rpc(&s, json!({"jsonrpc":"2.0","method":"resources/changed"}))
.await
.is_none()
);
let resp = rpc(
&s,
json!({"jsonrpc":"2.0","id":6,"method":"tools/call",
"params":{"name":"terminate_env","arguments":{}}}),
)
.await
.unwrap();
assert_eq!(resp["error"]["code"], -32602);
}
#[tokio::test]
async fn demo_e2e_list_environments_and_lint() {
let s = demo_server();
let resp = rpc(
&s,
json!({"jsonrpc":"2.0","id":7,"method":"tools/call",
"params":{"name":"list_environments","arguments":{}}}),
)
.await
.unwrap();
assert_eq!(resp["result"]["isError"], false);
let body = resp["result"]["content"][0]["text"].as_str().unwrap();
let parsed: Value = serde_json::from_str(body).expect("tool body is valid JSON");
assert!(
!parsed.as_array().unwrap().is_empty(),
"demo fleet non-empty"
);
assert!(parsed[0]["name"].is_string() && parsed[0]["health"].is_string());
let resp = rpc(
&s,
json!({"jsonrpc":"2.0","id":8,"method":"tools/call",
"params":{"name":"lint","arguments":{"rules":"EBL014"}}}),
)
.await
.unwrap();
assert_eq!(resp["result"]["isError"], false);
let body = resp["result"]["content"][0]["text"].as_str().unwrap();
assert!(body.contains("EBL014"), "planted finding surfaced: {body}");
}
#[tokio::test]
async fn demo_e2e_option_settings_redacts_env_vars_by_default() {
let s = demo_server();
let env_name = demo_fixture::envs()[0].name.clone();
let resp = rpc(
&s,
json!({"jsonrpc":"2.0","id":9,"method":"tools/call",
"params":{"name":"get_option_settings","arguments":{"env": env_name}}}),
)
.await
.unwrap();
let body = resp["result"]["content"][0]["text"].as_str().unwrap();
assert!(
!body.contains("hunter2"),
"secret env-var value must not leak: {body}"
);
assert!(body.contains("DATABASE_URL"), "keys stay visible");
assert!(body.contains("(redacted)"));
assert!(body.contains("\"redacted\":true"));
let open = Server::with_scope(true, true, WriteScope::None);
let resp = rpc(
&open,
json!({"jsonrpc":"2.0","id":10,"method":"tools/call",
"params":{"name":"get_option_settings",
"arguments":{"env": demo_fixture::envs()[0].name.clone()}}}),
)
.await
.unwrap();
let body = resp["result"]["content"][0]["text"].as_str().unwrap();
assert!(body.contains("hunter2") && body.contains("\"redacted\":false"));
}
#[tokio::test]
async fn tool_errors_come_back_as_is_error_results_not_rpc_errors() {
let s = demo_server();
let resp = rpc(
&s,
json!({"jsonrpc":"2.0","id":11,"method":"tools/call",
"params":{"name":"get_option_settings","arguments":{"env":"no-such-env"}}}),
)
.await
.unwrap();
assert_eq!(resp["result"]["isError"], true);
let text = resp["result"]["content"][0]["text"].as_str().unwrap();
assert!(text.contains("not found"), "got: {text}");
let resp = rpc(
&s,
json!({"jsonrpc":"2.0","id":12,"method":"tools/call",
"params":{"name":"list_versions","arguments":{}}}),
)
.await
.unwrap();
assert_eq!(resp["result"]["isError"], true);
}
#[test]
fn drift_reports_redact_env_var_secrets() {
let mut reports = vec![(
"prod".to_string(),
true,
vec![
terraform::DriftField {
kind: "option_setting".into(),
namespace: Some("aws:elasticbeanstalk:application:environment".into()),
name: Some("DATABASE_URL".into()),
tf_value: "postgres://u:hunter2@old".into(),
live_value: "postgres://u:hunter2@new".into(),
},
terraform::DriftField {
kind: "option_setting".into(),
namespace: Some("aws:autoscaling:asg".into()),
name: Some("MaxSize".into()),
tf_value: "4".into(),
live_value: "6".into(),
},
terraform::DriftField {
kind: "version_label".into(),
namespace: None,
name: None,
tf_value: "v1".into(),
live_value: "v2".into(),
},
],
)];
redact_drift_reports(&mut reports);
let fields = &reports[0].2;
assert_eq!(fields[0].tf_value, "(redacted)");
assert_eq!(fields[0].live_value, "(redacted)");
assert_eq!(fields[1].live_value, "6", "non-secret options untouched");
assert_eq!(fields[2].tf_value, "v1", "non-option kinds untouched");
let rendered = terraform::render_drift_json(None, None, &reports);
assert!(!rendered.contains("hunter2"), "no secret in the payload");
}
#[test]
fn skipped_envs_spliced_only_when_present() {
let clean = append_skipped_envs("{\"issues\":[]}".to_string(), &[]);
assert_eq!(clean, "{\"issues\":[]}", "common case byte-identical");
let degraded = append_skipped_envs(
"{\"issues\":[]}".to_string(),
&["prod: fetch failed".to_string()],
);
assert_eq!(
degraded,
"{\"issues\":[],\"skipped_envs\":[\"prod: fetch failed\"]}"
);
serde_json::from_str::<Value>(°raded).expect("valid JSON");
}
#[test]
fn audit_jsonl_wraps_into_an_array() {
assert_eq!(jsonl_to_array(""), "[]");
assert_eq!(
jsonl_to_array("{\"a\":1}\n{\"b\":2}\n"),
"[{\"a\":1},{\"b\":2}]"
);
serde_json::from_str::<Value>(&jsonl_to_array("{\"a\":1}")).expect("valid JSON");
}
#[tokio::test]
async fn id_less_requests_are_notifications_and_get_no_response() {
let server = Server::with_scope(true, false, WriteScope::None);
let req: Value = serde_json::from_str(
r#"{"jsonrpc":"2.0","method":"tools/call","params":{"name":"nope"}}"#,
)
.unwrap();
assert!(server.handle_request(&req).await.is_none());
let ping: Value = serde_json::from_str(r#"{"jsonrpc":"2.0","method":"ping"}"#).unwrap();
assert!(server.handle_request(&ping).await.is_none());
let null_id: Value =
serde_json::from_str(r#"{"jsonrpc":"2.0","id":null,"method":"ping"}"#).unwrap();
assert!(server.handle_request(&null_id).await.is_none());
}
#[test]
fn non_object_frames_get_invalid_request() {
let arr: Value =
serde_json::from_str(r#"[{"jsonrpc":"2.0","id":1,"method":"ping"}]"#).unwrap();
let resp = invalid_request_response(&arr).expect("array is invalid");
let parsed: Value = serde_json::from_str(&resp).unwrap();
assert_eq!(parsed["error"]["code"], -32600);
assert!(parsed["id"].is_null());
let scalar: Value = serde_json::from_str("42").unwrap();
assert!(invalid_request_response(&scalar).is_some());
let obj: Value =
serde_json::from_str(r#"{"jsonrpc":"2.0","id":1,"method":"ping"}"#).unwrap();
assert!(invalid_request_response(&obj).is_none());
}
#[test]
fn empty_cost_cache_total_formats_positive_zero() {
let costs: std::collections::HashMap<String, f64> = Default::default();
let total: f64 = costs.values().sum::<f64>() + 0.0;
assert_eq!(format!("{total:.2}"), "0.00");
}
#[test]
fn redaction_covers_env_vars_and_db_password_only() {
let r = |ns, n, v| redact_option_value(ns, n, v, true);
assert_eq!(
r(
"aws:elasticbeanstalk:application:environment",
"API_KEY",
"sk-123"
),
"(redacted)"
);
assert_eq!(r("aws:rds:dbinstance", "DBPassword", "pw"), "(redacted)");
assert_eq!(r("aws:autoscaling:asg", "MaxSize", "6"), "6");
assert_eq!(
redact_option_value(
"aws:elasticbeanstalk:application:environment",
"API_KEY",
"sk-123",
false
),
"sk-123"
);
}
#[tokio::test]
async fn credential_errors_are_rewritten_actionably() {
let msg = tool_error(
&Some("prod-admin".into()),
"list_environments",
"The security token included in the request is expired",
);
assert!(
msg.contains("aws sso login --profile prod-admin"),
"got: {msg}"
);
let msg = tool_error(&None, "op", "some unrelated failure");
assert!(msg.contains("op failed"), "got: {msg}");
}
#[tokio::test]
async fn initialize_names_what_this_surface_cannot_do() {
let s = Server::with_scope(true, false, WriteScope::None);
let resp = s
.handle_request(&json!({
"jsonrpc": "2.0", "id": 1, "method": "initialize",
"params": {"protocolVersion": PROTOCOL_VERSION, "capabilities": {},
"clientInfo": {"name": "probe", "version": "1"}}
}))
.await
.expect("initialize responds");
let instructions = resp["result"]["instructions"]
.as_str()
.expect("initialize must carry instructions")
.to_string();
const TUI_ONLY: &[&str] = &["LIVE log tail"];
for needle in TUI_ONLY {
assert!(
instructions.to_lowercase().contains(&needle.to_lowercase()),
"the instructions must name `{needle}` as available elsewhere: {instructions}"
);
}
assert!(
instructions.contains("TUI"),
"and must say where: {instructions}"
);
assert!(
instructions.contains(env!("CARGO_PKG_VERSION")),
"the instructions must name the build they describe: {instructions}"
);
assert_eq!(
resp["result"]["serverInfo"]["version"],
env!("CARGO_PKG_VERSION"),
"and must agree with serverInfo"
);
let tools = s
.handle_request(&json!({"jsonrpc": "2.0", "id": 2, "method": "tools/list"}))
.await
.expect("tools/list responds");
let names: Vec<String> = tools["result"]["tools"]
.as_array()
.expect("a tool array")
.iter()
.filter_map(|t| t["name"].as_str().map(str::to_string))
.collect();
assert!(
!names
.iter()
.any(|n| n.contains("resend") || n.contains("purge")),
"a dead-letter management tool exists now, so the instructions \
claiming that is TUI-only are stale: {names:?}"
);
}
#[tokio::test]
async fn worker_queues_reports_depth_and_whether_it_looked() {
let s = demo_server();
let envs = rpc(
&s,
json!({"jsonrpc":"2.0","id":1,"method":"tools/call",
"params":{"name":"list_environments","arguments":{}}}),
)
.await
.expect("envs");
let listing = envs["result"]["content"][0]["text"].as_str().expect("text");
let env_name = listing
.split("\"name\":\"")
.nth(1)
.and_then(|r| r.split('"').next())
.expect("an env in the demo fleet")
.to_string();
let resp = rpc(
&s,
json!({"jsonrpc":"2.0","id":2,"method":"tools/call",
"params":{"name":"worker_queues","arguments":{"env": env_name}}}),
)
.await
.expect("worker_queues answers");
let body = resp["result"]["content"][0]["text"]
.as_str()
.expect("text payload");
let parsed: Value = serde_json::from_str(body).expect("valid JSON");
assert!(parsed["main_queue"].is_object(), "{body}");
assert!(parsed["dead_letter_queue"].is_object(), "{body}");
assert_eq!(
parsed["peeked"], false,
"peek defaults off, and the response must say so: {body}"
);
assert!(parsed["messages"].is_array(), "{body}");
}
#[tokio::test]
async fn worker_queues_requires_an_env() {
let resp = rpc(
&demo_server(),
json!({"jsonrpc":"2.0","id":1,"method":"tools/call",
"params":{"name":"worker_queues","arguments":{}}}),
)
.await
.expect("a response");
let text = serde_json::to_string(&resp).expect("serialisable");
assert!(
text.contains("'env' is required"),
"must refuse rather than guess an env: {text}"
);
}
#[tokio::test]
async fn the_queue_tool_warns_that_a_peek_inflates_the_receive_count() {
let resp = rpc(
&demo_server(),
json!({"jsonrpc":"2.0","id":1,"method":"tools/list"}),
)
.await
.expect("tools/list");
let desc = resp["result"]["tools"]
.as_array()
.expect("array")
.iter()
.find(|t| t["name"] == "worker_queues")
.and_then(|t| t["description"].as_str())
.expect("worker_queues is advertised")
.to_string();
assert!(
desc.contains("receive_count"),
"the caveat must name the field it is about: {desc}"
);
assert!(
desc.contains("not a retry count") || desc.contains("NOT a retry count"),
"and must say what it is not: {desc}"
);
assert!(
desc.contains("dead_letter_queue.origin"),
"a derived DLQ url that returns nothing is ordinary; a reported \
one that does is an anomaly — the consumer cannot tell without \
this: {desc}"
);
}
#[tokio::test]
async fn recent_logs_says_whether_it_reached_the_newest() {
let resp = rpc(
&demo_server(),
json!({"jsonrpc":"2.0","id":1,"method":"tools/call",
"params":{"name":"recent_logs","arguments":{"env":"any"}}}),
)
.await
.expect("recent_logs answers");
let body = resp["result"]["content"][0]["text"]
.as_str()
.expect("text payload");
let parsed: Value = serde_json::from_str(body).expect("valid JSON");
assert!(
parsed["complete"].is_boolean(),
"every answer must say whether the window was fully read: {body}"
);
assert!(parsed["events"].is_array(), "{body}");
}
#[tokio::test]
async fn recent_logs_warns_about_oldest_first() {
let resp = rpc(
&demo_server(),
json!({"jsonrpc":"2.0","id":1,"method":"tools/list"}),
)
.await
.expect("tools/list");
let desc = resp["result"]["tools"]
.as_array()
.expect("array")
.iter()
.find(|t| t["name"] == "recent_logs")
.and_then(|t| t["description"].as_str())
.expect("recent_logs is advertised")
.to_string();
assert!(
desc.contains("oldest-first") || desc.contains("oldest first"),
"the trap must be named: {desc}"
);
assert!(
desc.contains("complete"),
"and the field that tells you which you have: {desc}"
);
assert!(
desc.contains("NOT REDACTED"),
"the one read tool that cannot be redacted must say so: {desc}"
);
}
#[tokio::test]
async fn why_returns_every_section_in_one_call() {
let s = demo_server();
let envs = rpc(
&s,
json!({"jsonrpc":"2.0","id":1,"method":"tools/call",
"params":{"name":"list_environments","arguments":{}}}),
)
.await
.expect("envs");
let env_name = envs["result"]["content"][0]["text"]
.as_str()
.and_then(|t| t.split("\"name\":\"").nth(1))
.and_then(|r| r.split('"').next())
.expect("an env")
.to_string();
let resp = rpc(
&s,
json!({"jsonrpc":"2.0","id":2,"method":"tools/call",
"params":{"name":"why","arguments":{"env": env_name}}}),
)
.await
.expect("why answers");
let body = resp["result"]["content"][0]["text"].as_str().expect("text");
let parsed: Value = serde_json::from_str(body).expect("valid JSON");
for section in ["events", "alarms", "instances", "queues", "recent_versions"] {
assert!(
parsed.get(section).is_some(),
"`{section}` must be present even when null — a missing key \
and an empty one read differently: {body}"
);
}
assert!(
parsed["errors"].is_array(),
"a partial bundle must be visibly partial: {body}"
);
}
#[test]
fn a_failed_why_section_is_null_with_its_reason() {
let bundle = super::tools::render_why_json(
"api-prod",
"[]",
"null",
"[]",
"null",
"[]",
&[
("alarms".to_string(), "AccessDenied".to_string()),
("queues".to_string(), "throttled".to_string()),
],
);
let parsed: Value = serde_json::from_str(&bundle).expect("valid JSON");
assert!(parsed["alarms"].is_null(), "{bundle}");
assert!(parsed["queues"].is_null(), "{bundle}");
assert!(
parsed["events"].is_array() && parsed["instances"].is_array(),
"sections that succeeded must still be there: {bundle}"
);
let errs = parsed["errors"].as_array().expect("errors array");
assert_eq!(errs.len(), 2, "{bundle}");
assert!(
errs.iter()
.any(|e| e["section"] == "alarms" && e["error"] == "AccessDenied"),
"the reason must survive, not just the fact of failure: {bundle}"
);
}
#[test]
fn a_failed_section_is_null_and_recorded() {
let mut errors: Vec<(String, String)> = Vec::new();
let ok = super::tools::section_or_error("events", Ok("[1,2]".into()), &mut errors);
assert_eq!(ok, "[1,2]", "a good section passes through untouched");
assert!(errors.is_empty(), "and records nothing");
let bad = super::tools::section_or_error("alarms", Err("AccessDenied".into()), &mut errors);
assert_eq!(
bad, "null",
"a failed section must be null — `[]` would read as \"no alarms\", \
which is the opposite of \"we could not check\""
);
assert_eq!(
errors,
vec![("alarms".to_string(), "AccessDenied".to_string())],
"and the reason must be kept, not just the fact of failure"
);
}
#[test]
fn a_denied_dlq_peek_is_not_an_empty_queue() {
use crate::aws::QueueMessage;
let msg = || QueueMessage {
id: "m-1".into(),
attributes: Vec::new(),
receipt_handle: String::new(),
body: "b".into(),
receive_count: 1,
sent_at: None,
task: None,
};
let mut errors = Vec::new();
let (msgs, peeked) = super::tools::dlq_peek_outcome(Some(Ok(vec![msg()])), &mut errors);
assert_eq!(msgs.len(), 1);
assert!(peeked);
assert!(errors.is_empty());
let mut errors = Vec::new();
let (msgs, peeked) = super::tools::dlq_peek_outcome(
Some(Err("AccessDenied: sqs:ReceiveMessage".into())),
&mut errors,
);
assert!(msgs.is_empty());
assert!(
!peeked,
"a denied peek must not claim we looked — that turns \
'permission missing' into 'queue is clean'"
);
assert_eq!(
errors,
vec![(
"dlq_peek".to_string(),
"AccessDenied: sqs:ReceiveMessage".to_string()
)],
"and the reason must reach the caller, not just the fact"
);
let mut errors = Vec::new();
let (msgs, peeked) = super::tools::dlq_peek_outcome(None, &mut errors);
assert!(msgs.is_empty() && !peeked);
assert!(
errors.is_empty(),
"an env with no dead-letter queue is ordinary, not an error"
);
}
mod orchestration {
use super::*;
use aws_sdk_elasticbeanstalk::Client as EbClient;
use aws_sdk_sqs::Client as SqsClient;
fn client_with(eb: EbClient, sqs: SqsClient) -> crate::aws::AwsClient {
let cfg = aws_config::SdkConfig::builder()
.region(aws_config::Region::new("us-west-1"))
.behavior_version(aws_config::BehaviorVersion::latest())
.build();
crate::aws::AwsClient::for_tests(
eb,
sqs,
aws_sdk_cloudwatch::Client::new(&cfg),
aws_sdk_cloudwatchlogs::Client::new(&cfg),
aws_sdk_s3::Client::new(&cfg),
aws_sdk_ec2::Client::new(&cfg),
)
}
fn env_listing() -> aws_smithy_mocks::Rule {
use aws_sdk_elasticbeanstalk::operation::describe_environments::DescribeEnvironmentsOutput;
use aws_sdk_elasticbeanstalk::types::EnvironmentDescription;
aws_smithy_mocks::mock!(EbClient::describe_environments).then_output(|| {
DescribeEnvironmentsOutput::builder()
.environments(
EnvironmentDescription::builder()
.environment_name("poly-prod-wk")
.application_name("poly")
.status("Ready".into())
.health("Yellow".into())
.tier(
aws_sdk_elasticbeanstalk::types::EnvironmentTier::builder()
.name("Worker")
.build(),
)
.build(),
)
.build()
})
}
#[tokio::test]
async fn worker_queues_peeks_the_dead_letter_queue() {
use aws_sdk_elasticbeanstalk::operation::describe_environment_resources::DescribeEnvironmentResourcesOutput;
use aws_sdk_elasticbeanstalk::types::{EnvironmentResourceDescription, Queue};
use aws_sdk_sqs::operation::get_queue_attributes::GetQueueAttributesOutput;
use aws_sdk_sqs::operation::receive_message::ReceiveMessageOutput;
use aws_sdk_sqs::types::{Message, MessageAttributeValue, QueueAttributeName};
let resources = aws_smithy_mocks::mock!(EbClient::describe_environment_resources)
.then_output(|| {
DescribeEnvironmentResourcesOutput::builder()
.environment_resources(
EnvironmentResourceDescription::builder()
.queues(
Queue::builder()
.name("WorkerQueue")
.url("https://sqs/main")
.build(),
)
.queues(
Queue::builder()
.name("WorkerDeadLetterQueue")
.url("https://sqs/main-dlq")
.build(),
)
.build(),
)
.build()
});
let attrs =
aws_smithy_mocks::mock!(SqsClient::get_queue_attributes).then_output(|| {
GetQueueAttributesOutput::builder()
.attributes(QueueAttributeName::ApproximateNumberOfMessages, "1")
.attributes(
QueueAttributeName::ApproximateNumberOfMessagesNotVisible,
"0",
)
.attributes(QueueAttributeName::ApproximateNumberOfMessagesDelayed, "0")
.build()
});
let peek = aws_smithy_mocks::mock!(SqsClient::receive_message)
.match_requests(|req| req.queue_url() == Some("https://sqs/main-dlq"))
.then_output(|| {
let attr = |v: &str| {
MessageAttributeValue::builder()
.data_type("String")
.string_value(v)
.build()
.expect("valid")
};
ReceiveMessageOutput::builder()
.messages(
Message::builder()
.message_id("m-1")
.receipt_handle("rh-1")
.body("elasticbeanstalk scheduled job")
.message_attributes(
"beanstalk.sqsd.task_name",
attr("ORCHCANARY sweep"),
)
.build(),
)
.build()
});
let eb = aws_smithy_mocks::mock_client!(
aws_sdk_elasticbeanstalk,
aws_smithy_mocks::RuleMode::MatchAny,
[&env_listing(), &resources]
);
let sqs = aws_smithy_mocks::mock_client!(
aws_sdk_sqs,
aws_smithy_mocks::RuleMode::MatchAny,
[&attrs, &peek]
);
let s = Server::with_injected_client(
WriteScope::None,
crate::config::Config::default(),
client_with(eb, sqs),
);
let out = s
.call_tool(
"worker_queues",
&json!({"env": "poly-prod-wk", "peek": true}),
)
.await
.expect("worker_queues answers");
let v: Value = serde_json::from_str(&out).expect("valid JSON");
assert_eq!(v["dead_letter_queue"]["stats"]["visible"], 1);
assert_eq!(v["peeked"], true);
assert_eq!(
v["messages"][0]["task"]["name"], "ORCHCANARY sweep",
"the peek must read the DEAD-LETTER queue and carry the \
task through: {out}"
);
}
#[tokio::test]
async fn worker_queues_does_not_peek_unless_asked() {
use aws_sdk_elasticbeanstalk::operation::describe_environment_resources::DescribeEnvironmentResourcesOutput;
use aws_sdk_elasticbeanstalk::types::{EnvironmentResourceDescription, Queue};
use aws_sdk_sqs::operation::get_queue_attributes::GetQueueAttributesOutput;
use aws_sdk_sqs::types::QueueAttributeName;
let resources = aws_smithy_mocks::mock!(EbClient::describe_environment_resources)
.then_output(|| {
DescribeEnvironmentResourcesOutput::builder()
.environment_resources(
EnvironmentResourceDescription::builder()
.queues(
Queue::builder()
.name("WorkerQueue")
.url("https://sqs/main")
.build(),
)
.build(),
)
.build()
});
let attrs =
aws_smithy_mocks::mock!(SqsClient::get_queue_attributes).then_output(|| {
GetQueueAttributesOutput::builder()
.attributes(QueueAttributeName::ApproximateNumberOfMessages, "0")
.build()
});
let settings = aws_smithy_mocks::mock!(EbClient::describe_configuration_settings)
.then_output(|| {
aws_sdk_elasticbeanstalk::operation::describe_configuration_settings::DescribeConfigurationSettingsOutput::builder().build()
});
let eb = aws_smithy_mocks::mock_client!(
aws_sdk_elasticbeanstalk,
aws_smithy_mocks::RuleMode::MatchAny,
[&env_listing(), &resources, &settings]
);
let sqs = aws_smithy_mocks::mock_client!(
aws_sdk_sqs,
aws_smithy_mocks::RuleMode::MatchAny,
[&attrs]
);
let s = Server::with_injected_client(
WriteScope::None,
crate::config::Config::default(),
client_with(eb, sqs),
);
let out = s
.call_tool("worker_queues", &json!({"env": "poly-prod-wk"}))
.await
.expect("depth-only must not need a receive_message rule");
let v: Value = serde_json::from_str(&out).expect("valid JSON");
assert_eq!(v["peeked"], false, "{out}");
assert!(
v["messages"].as_array().is_some_and(|m| m.is_empty()),
"{out}"
);
}
#[tokio::test]
async fn worker_queues_refuses_an_unknown_env() {
let eb = aws_smithy_mocks::mock_client!(
aws_sdk_elasticbeanstalk,
aws_smithy_mocks::RuleMode::MatchAny,
[&env_listing()]
);
let cfg = aws_config::SdkConfig::builder()
.region(aws_config::Region::new("us-west-1"))
.behavior_version(aws_config::BehaviorVersion::latest())
.build();
let s = Server::with_injected_client(
WriteScope::None,
crate::config::Config::default(),
client_with(eb, SqsClient::new(&cfg)),
);
let err = s
.call_tool("worker_queues", &json!({"env": "no-such-env"}))
.await
.expect_err("an unknown env must be an error");
assert!(err.contains("not found"), "{err}");
}
#[tokio::test]
async fn a_derived_dlq_that_does_not_exist_still_answers() {
use aws_sdk_elasticbeanstalk::operation::describe_environment_resources::DescribeEnvironmentResourcesOutput;
use aws_sdk_elasticbeanstalk::types::{EnvironmentResourceDescription, Queue};
use aws_sdk_sqs::operation::get_queue_attributes::GetQueueAttributesOutput;
use aws_sdk_sqs::types::QueueAttributeName;
let resources = aws_smithy_mocks::mock!(EbClient::describe_environment_resources)
.then_output(|| {
DescribeEnvironmentResourcesOutput::builder()
.environment_resources(
EnvironmentResourceDescription::builder()
.queues(
Queue::builder()
.name("WorkerQueue")
.url("https://sqs/main")
.build(),
)
.build(),
)
.build()
});
let settings = aws_smithy_mocks::mock!(EbClient::describe_configuration_settings)
.then_output(|| {
aws_sdk_elasticbeanstalk::operation::describe_configuration_settings::DescribeConfigurationSettingsOutput::builder().build()
});
let main_attrs = aws_smithy_mocks::mock!(SqsClient::get_queue_attributes)
.match_requests(|req| req.queue_url() == Some("https://sqs/main"))
.then_output(|| {
GetQueueAttributesOutput::builder()
.attributes(QueueAttributeName::ApproximateNumberOfMessages, "3")
.build()
});
let dlq_missing = aws_smithy_mocks::mock!(SqsClient::get_queue_attributes)
.match_requests(|req| req.queue_url() == Some("https://sqs/main-dlq"))
.then_error(|| {
aws_sdk_sqs::operation::get_queue_attributes::GetQueueAttributesError::generic(
aws_smithy_types::error::ErrorMetadata::builder()
.code("AWS.SimpleQueueService.NonExistentQueue")
.message("The specified queue does not exist")
.build(),
)
});
let eb = aws_smithy_mocks::mock_client!(
aws_sdk_elasticbeanstalk,
aws_smithy_mocks::RuleMode::MatchAny,
[&env_listing(), &resources, &settings]
);
let sqs = aws_smithy_mocks::mock_client!(
aws_sdk_sqs,
aws_smithy_mocks::RuleMode::MatchAny,
[&main_attrs, &dlq_missing]
);
let s = Server::with_injected_client(
WriteScope::None,
crate::config::Config::default(),
client_with(eb, sqs),
);
let out = s
.call_tool(
"worker_queues",
&json!({"env": "poly-prod-wk", "peek": true}),
)
.await
.expect("a missing derived DLQ must not fail the call");
let v: Value = serde_json::from_str(&out).expect("valid JSON");
assert_eq!(
v["main_queue"]["stats"]["visible"], 3,
"the depth answer we already had must survive: {out}"
);
assert_eq!(
v["peeked"], false,
"there was nothing to peek, so we did not look — saying \
`true` here claims an empty queue that does not exist: {out}"
);
}
#[tokio::test]
async fn why_records_no_dlq_error_when_there_is_no_dlq() {
use aws_sdk_elasticbeanstalk::operation::describe_environment_resources::DescribeEnvironmentResourcesOutput;
use aws_sdk_elasticbeanstalk::types::{EnvironmentResourceDescription, Queue};
use aws_sdk_sqs::operation::get_queue_attributes::GetQueueAttributesOutput;
use aws_sdk_sqs::types::QueueAttributeName;
let resources = aws_smithy_mocks::mock!(EbClient::describe_environment_resources)
.then_output(|| {
DescribeEnvironmentResourcesOutput::builder()
.environment_resources(
EnvironmentResourceDescription::builder()
.queues(
Queue::builder()
.name("WorkerQueue")
.url("https://sqs/main")
.build(),
)
.build(),
)
.build()
});
let settings = aws_smithy_mocks::mock!(EbClient::describe_configuration_settings)
.then_output(|| {
aws_sdk_elasticbeanstalk::operation::describe_configuration_settings::DescribeConfigurationSettingsOutput::builder().build()
});
let main_attrs = aws_smithy_mocks::mock!(SqsClient::get_queue_attributes)
.match_requests(|req| req.queue_url() == Some("https://sqs/main"))
.then_output(|| {
GetQueueAttributesOutput::builder()
.attributes(QueueAttributeName::ApproximateNumberOfMessages, "0")
.build()
});
let dlq_missing = aws_smithy_mocks::mock!(SqsClient::get_queue_attributes)
.match_requests(|req| req.queue_url() == Some("https://sqs/main-dlq"))
.then_error(|| {
aws_sdk_sqs::operation::get_queue_attributes::GetQueueAttributesError::generic(
aws_smithy_types::error::ErrorMetadata::builder()
.code("AWS.SimpleQueueService.NonExistentQueue")
.message("The specified queue does not exist")
.build(),
)
});
let events = aws_smithy_mocks::mock!(EbClient::describe_events).then_output(|| {
aws_sdk_elasticbeanstalk::operation::describe_events::DescribeEventsOutput::builder(
)
.build()
});
let health = aws_smithy_mocks::mock!(EbClient::describe_instances_health)
.then_output(|| {
aws_sdk_elasticbeanstalk::operation::describe_instances_health::DescribeInstancesHealthOutput::builder().build()
});
let versions = aws_smithy_mocks::mock!(EbClient::describe_application_versions)
.then_output(|| {
aws_sdk_elasticbeanstalk::operation::describe_application_versions::DescribeApplicationVersionsOutput::builder().build()
});
let eb = aws_smithy_mocks::mock_client!(
aws_sdk_elasticbeanstalk,
aws_smithy_mocks::RuleMode::MatchAny,
[
&env_listing(),
&resources,
&settings,
&events,
&health,
&versions
]
);
let sqs = aws_smithy_mocks::mock_client!(
aws_sdk_sqs,
aws_smithy_mocks::RuleMode::MatchAny,
[&main_attrs, &dlq_missing]
);
let s = Server::with_injected_client(
WriteScope::None,
crate::config::Config::default(),
client_with(eb, sqs),
);
let out = s
.call_tool("why", &json!({"env": "poly-prod-wk"}))
.await
.expect("why answers");
let v: Value = serde_json::from_str(&out).expect("valid JSON");
let errors = v["errors"].as_array().expect("errors array");
assert!(
!errors.iter().any(|e| e["section"] == "dlq_peek"),
"an env with no dead-letter queue is ordinary — recording \
`dlq_peek` as a failure is triage noise in the array that \
exists to make partial answers load-bearing: {out}"
);
assert_eq!(
v["queues"]["peeked"], false,
"and we did not look, because there was nothing to look at: {out}"
);
}
#[tokio::test]
async fn a_live_batch_counts_what_it_actually_deleted() {
use aws_sdk_elasticbeanstalk::operation::describe_environment_resources::DescribeEnvironmentResourcesOutput;
use aws_sdk_elasticbeanstalk::types::{EnvironmentResourceDescription, Queue};
use aws_sdk_sqs::operation::get_queue_attributes::GetQueueAttributesOutput;
use aws_sdk_sqs::operation::receive_message::ReceiveMessageOutput;
use aws_sdk_sqs::types::{Message, QueueAttributeName};
let resources = aws_smithy_mocks::mock!(EbClient::describe_environment_resources)
.then_output(|| {
DescribeEnvironmentResourcesOutput::builder()
.environment_resources(
EnvironmentResourceDescription::builder()
.queues(
Queue::builder()
.name("WorkerQueue")
.url("https://sqs/main")
.build(),
)
.queues(
Queue::builder()
.name("WorkerDeadLetterQueue")
.url("https://sqs/main-dlq")
.build(),
)
.build(),
)
.build()
});
let attrs =
aws_smithy_mocks::mock!(SqsClient::get_queue_attributes).then_output(|| {
GetQueueAttributesOutput::builder()
.attributes(QueueAttributeName::ApproximateNumberOfMessages, "2")
.build()
});
let asked_for = std::sync::Arc::new(std::sync::atomic::AtomicI32::new(0));
let seen = std::sync::Arc::clone(&asked_for);
let peek = aws_smithy_mocks::mock!(SqsClient::receive_message)
.match_requests(move |req| {
seen.fetch_max(
req.max_number_of_messages().unwrap_or(0),
std::sync::atomic::Ordering::SeqCst,
);
true
})
.then_output(|| {
ReceiveMessageOutput::builder()
.messages(
Message::builder()
.message_id("m-1")
.receipt_handle("rh-1")
.body("a")
.build(),
)
.messages(
Message::builder()
.message_id("m-2")
.receipt_handle("rh-2")
.body("b")
.build(),
)
.build()
});
let del = aws_smithy_mocks::mock!(SqsClient::delete_message).then_output(|| {
aws_sdk_sqs::operation::delete_message::DeleteMessageOutput::builder().build()
});
let events = aws_smithy_mocks::mock!(EbClient::describe_events).then_output(|| {
aws_sdk_elasticbeanstalk::operation::describe_events::DescribeEventsOutput::builder(
)
.build()
});
let eb = aws_smithy_mocks::mock_client!(
aws_sdk_elasticbeanstalk,
aws_smithy_mocks::RuleMode::MatchAny,
[&env_listing(), &resources, &events]
);
let sqs = aws_smithy_mocks::mock_client!(
aws_sdk_sqs,
aws_smithy_mocks::RuleMode::MatchAny,
[&attrs, &peek, &del]
);
let s = Server::with_injected_client(
WriteScope::All,
crate::config::Config::default(),
client_with(eb, sqs),
);
let plan = s
.call_tool(
"dlq_delete",
&json!({"env": "poly-prod-wk", "message_ids": ["m-1", "m-2"]}),
)
.await
.expect("both are present at plan time");
let token = plan
.split("\"confirm_token\":\"")
.nth(1)
.and_then(|r| r.split('"').next())
.expect("a token")
.to_string();
let out = s
.call_tool("confirm_action", &json!({"confirm_token": token}))
.await
.expect("both deletes succeed");
let body: Value = serde_json::from_str(&out).unwrap_or_else(|e| panic!("{out}: {e}"));
assert_eq!(
body["succeeded"],
json!(2),
"the live counter must count what was actually deleted: {out}"
);
assert_eq!(body["failed"], json!(0), "{out}");
assert_eq!(body["dispatched"], json!(true), "{out}");
assert_eq!(
body["results"].as_array().map(Vec::len),
Some(2),
"one result per message: {out}"
);
assert_eq!(
asked_for.load(std::sync::atomic::Ordering::SeqCst),
10,
"the re-read must ask SQS for its per-call maximum, or a full batch \
cannot be found and approved messages report as missing"
);
}
#[tokio::test]
async fn a_resend_that_could_not_delete_says_a_duplicate_now_exists() {
use aws_sdk_elasticbeanstalk::operation::describe_environment_resources::DescribeEnvironmentResourcesOutput;
use aws_sdk_elasticbeanstalk::types::{EnvironmentResourceDescription, Queue};
use aws_sdk_sqs::operation::get_queue_attributes::GetQueueAttributesOutput;
use aws_sdk_sqs::operation::receive_message::ReceiveMessageOutput;
use aws_sdk_sqs::types::{Message, QueueAttributeName};
let resources = aws_smithy_mocks::mock!(EbClient::describe_environment_resources)
.then_output(|| {
DescribeEnvironmentResourcesOutput::builder()
.environment_resources(
EnvironmentResourceDescription::builder()
.queues(
Queue::builder()
.name("WorkerQueue")
.url("https://sqs/main")
.build(),
)
.queues(
Queue::builder()
.name("WorkerDeadLetterQueue")
.url("https://sqs/main-dlq")
.build(),
)
.build(),
)
.build()
});
let attrs =
aws_smithy_mocks::mock!(SqsClient::get_queue_attributes).then_output(|| {
GetQueueAttributesOutput::builder()
.attributes(QueueAttributeName::ApproximateNumberOfMessages, "1")
.build()
});
let peek = aws_smithy_mocks::mock!(SqsClient::receive_message).then_output(|| {
ReceiveMessageOutput::builder()
.messages(
Message::builder()
.message_id("m-1")
.receipt_handle("rh-m-1")
.body("payload")
.build(),
)
.build()
});
let send = aws_smithy_mocks::mock!(SqsClient::send_message).then_output(|| {
aws_sdk_sqs::operation::send_message::SendMessageOutput::builder().build()
});
let del = aws_smithy_mocks::mock!(SqsClient::delete_message).then_error(|| {
aws_sdk_sqs::operation::delete_message::DeleteMessageError::generic(
aws_smithy_types::error::ErrorMetadata::builder()
.code("ReceiptHandleIsInvalid")
.message("handle expired")
.build(),
)
});
let events = aws_smithy_mocks::mock!(EbClient::describe_events).then_output(|| {
aws_sdk_elasticbeanstalk::operation::describe_events::DescribeEventsOutput::builder(
)
.build()
});
let eb = aws_smithy_mocks::mock_client!(
aws_sdk_elasticbeanstalk,
aws_smithy_mocks::RuleMode::MatchAny,
[&env_listing(), &resources, &events]
);
let sqs = aws_smithy_mocks::mock_client!(
aws_sdk_sqs,
aws_smithy_mocks::RuleMode::MatchAny,
[&attrs, &peek, &send, &del]
);
let s = Server::with_injected_client(
WriteScope::All,
crate::config::Config::default(),
client_with(eb, sqs),
);
let plan = s
.call_tool(
"dlq_resend",
&json!({"env": "poly-prod-wk", "message_id": "m-1"}),
)
.await
.expect("m-1 is present at plan time");
let token = plan
.split("\"confirm_token\":\"")
.nth(1)
.and_then(|r| r.split('"').next())
.expect("a token")
.to_string();
let err = s
.call_tool("confirm_action", &json!({"confirm_token": token}))
.await
.expect_err("nothing succeeded, so the call is an error");
assert!(
err.contains("RESENT BUT NOT REMOVED"),
"the agent must be told the send half worked: {err}"
);
assert!(
err.contains("duplicate now exists"),
"and that a duplicate exists, or `ok: false` reads as nothing \
happened: {err}"
);
assert!(
err.contains("DO NOT resend this id again"),
"and must block the retry the batch report otherwise invites — each \
attempt adds another copy: {err}"
);
let plan = s
.call_tool(
"dlq_delete",
&json!({"env": "poly-prod-wk", "message_id": "m-1"}),
)
.await
.expect("m-1 is still present");
let token = plan
.split("\"confirm_token\":\"")
.nth(1)
.and_then(|r| r.split('"').next())
.expect("a token")
.to_string();
let err = s
.call_tool("confirm_action", &json!({"confirm_token": token}))
.await
.expect_err("the delete fails");
assert!(
!err.contains("RESENT BUT NOT REMOVED") && !err.contains("duplicate"),
"a delete sent nothing — claiming a duplicate is on the main queue \
would be a false statement about the fleet: {err}"
);
assert!(
err.contains("\"ok\":false") && err.contains("failed\":1"),
"it must still report the item as failed: {err}"
);
}
#[tokio::test]
async fn a_dlq_delete_refuses_when_the_planned_message_is_gone() {
use aws_sdk_elasticbeanstalk::operation::describe_environment_resources::DescribeEnvironmentResourcesOutput;
use aws_sdk_elasticbeanstalk::types::{EnvironmentResourceDescription, Queue};
use aws_sdk_sqs::operation::get_queue_attributes::GetQueueAttributesOutput;
use aws_sdk_sqs::operation::receive_message::ReceiveMessageOutput;
use aws_sdk_sqs::types::{Message, QueueAttributeName};
let resources = aws_smithy_mocks::mock!(EbClient::describe_environment_resources)
.then_output(|| {
DescribeEnvironmentResourcesOutput::builder()
.environment_resources(
EnvironmentResourceDescription::builder()
.queues(
Queue::builder()
.name("WorkerQueue")
.url("https://sqs/main")
.build(),
)
.queues(
Queue::builder()
.name("WorkerDeadLetterQueue")
.url("https://sqs/main-dlq")
.build(),
)
.build(),
)
.build()
});
let attrs =
aws_smithy_mocks::mock!(SqsClient::get_queue_attributes).then_output(|| {
GetQueueAttributesOutput::builder()
.attributes(QueueAttributeName::ApproximateNumberOfMessages, "2")
.build()
});
let seq = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0));
let seq2 = std::sync::Arc::clone(&seq);
let peek = aws_smithy_mocks::mock!(SqsClient::receive_message).then_output(move || {
let first = seq2.fetch_add(1, std::sync::atomic::Ordering::SeqCst) == 0;
let id = if first { "m-1" } else { "m-2" };
ReceiveMessageOutput::builder()
.messages(
Message::builder()
.message_id(id)
.receipt_handle(format!("rh-{id}"))
.body("payload")
.build(),
)
.build()
});
let events = aws_smithy_mocks::mock!(EbClient::describe_events).then_output(|| {
aws_sdk_elasticbeanstalk::operation::describe_events::DescribeEventsOutput::builder(
)
.build()
});
let eb = aws_smithy_mocks::mock_client!(
aws_sdk_elasticbeanstalk,
aws_smithy_mocks::RuleMode::MatchAny,
[&env_listing(), &resources, &events]
);
let sqs = aws_smithy_mocks::mock_client!(
aws_sdk_sqs,
aws_smithy_mocks::RuleMode::MatchAny,
[&attrs, &peek]
);
let s = Server::with_injected_client(
WriteScope::All,
crate::config::Config::default(),
client_with(eb, sqs),
);
let plan = s
.call_tool(
"dlq_delete",
&json!({"env": "poly-prod-wk", "message_id": "m-1"}),
)
.await
.expect("m-1 is present at plan time");
let token = plan
.split("\"confirm_token\":\"")
.nth(1)
.and_then(|r| r.split('"').next())
.expect("a token")
.to_string();
assert!(plan.contains("m-1"), "the plan names the message: {plan}");
let err = s
.call_tool("confirm_action", &json!({"confirm_token": token}))
.await
.expect_err("m-1 is gone by confirm time — this must refuse");
assert!(
err.contains("not among the messages returned when the queue was re-read"),
"it must say the planned message was not found, not delete m-2 \
quietly: {err}"
);
assert!(
!err.contains("dispatched\":true"),
"a batch where nothing succeeded must not report a dispatch: {err}"
);
assert!(
!err.contains("m-2"),
"and must not have touched the message that IS there: {err}"
);
}
}
#[test]
fn mcp_drift_discovery_starts_from_an_absolute_path() {
let src = std::fs::read_to_string("src/cli/mcp/tools.rs").expect("read source");
let prod = src.split("\n#[cfg(test)]\nmod ").next().unwrap_or_default();
assert!(
prod.contains("std::env::current_dir()"),
"drift discovery must start from the absolute cwd, or it \
cannot walk up at all"
);
assert!(
prod.contains("resolve_state_path("),
"the production slice is not finding the resolution"
);
}
#[test]
fn the_stale_notice_says_whose_job_the_reconnect_is() {
let n = stale_binary_notice("/usr/local/bin/ebman");
assert!(
n.contains("ASK YOUR OPERATOR"),
"an agent reading `reconnect` imperatively will try it and fail — the \
panel is a client surface it cannot reach: {n}"
);
assert!(
n.contains("cannot do it yourself"),
"and must be told why, or it will look for another way round: {n}"
);
assert!(
n.contains("no MCP message a server can send"),
"naming the missing primitive stops an agent hunting for a tool that \
does not exist: {n}"
);
assert!(n.contains(env!("CARGO_PKG_VERSION")), "{n}");
assert!(!n.contains(" "), "renders into one line: {n:?}");
}
#[test]
fn a_replaced_binary_is_stale_and_an_unreadable_one_is_not() {
assert!(
super::staleness(Some(100), Some(200)),
"a different inode is a different file — that is an upgrade"
);
assert!(
!super::staleness(Some(100), Some(100)),
"the same file is not stale, however often it is checked"
);
assert!(!super::staleness(None, Some(200)), "unknown start");
assert!(!super::staleness(Some(100), None), "unknown now");
assert!(!super::staleness(None, None), "unknown both");
}
#[test]
fn the_stale_notice_says_what_to_do_about_it() {
let n = super::stale_binary_notice("/opt/homebrew/bin/ebman");
assert!(
n.contains(env!("CARGO_PKG_VERSION")),
"it must name the version actually RUNNING — the cached \
instructions block cannot be refreshed mid-connection, so \
this is the only truthful version an agent sees: {n}"
);
assert!(n.contains("/opt/homebrew/bin/ebman"), "and where: {n}");
assert!(
n.contains("reconnect") || n.contains("Reconnect"),
"an agent cannot act on \"you are stale\" — it can act on \
\"reconnect\": {n}"
);
}
#[tokio::test]
async fn a_current_binary_adds_no_notice() {
let s = demo_server();
let resp = rpc(
&s,
json!({"jsonrpc":"2.0","id":1,"method":"tools/call",
"params":{"name":"list_environments","arguments":{}}}),
)
.await
.expect("answers");
let text = resp["result"]["content"][0]["text"].as_str().expect("text");
assert!(
!text.contains("is running, but a different build"),
"this binary has not been replaced, so there is nothing to \
report: {text}"
);
assert!(text.starts_with('['), "still JSON: {text}");
}
#[tokio::test]
async fn a_stale_server_prepends_the_notice_to_its_answer() {
let dir = std::env::temp_dir().join(format!("ebman-exe-{}", std::process::id()));
std::fs::create_dir_all(&dir).expect("temp dir");
let fake = dir.join("ebman");
std::fs::write(&fake, b"v1").expect("write");
let s = Server::with_scope(true, false, WriteScope::None).watching_exe(&fake);
let quiet = rpc(
&s,
json!({"jsonrpc":"2.0","id":1,"method":"tools/call",
"params":{"name":"list_environments","arguments":{}}}),
)
.await
.expect("answers");
assert!(
!quiet["result"]["content"][0]["text"]
.as_str()
.unwrap_or("")
.contains("a different build"),
"nothing has changed yet"
);
let replacement = dir.join("ebman.new");
std::fs::write(&replacement, b"v2").expect("write");
std::fs::rename(&replacement, &fake).expect("rename");
let loud = rpc(
&s,
json!({"jsonrpc":"2.0","id":2,"method":"tools/call",
"params":{"name":"list_environments","arguments":{}}}),
)
.await
.expect("answers");
let text = loud["result"]["content"][0]["text"].as_str().expect("text");
assert!(
text.contains("a different build is now installed"),
"the server must say the binary changed underneath it: {text}"
);
assert!(
text.contains(env!("CARGO_PKG_VERSION")),
"naming the version actually running, which is the only \
truthful one an agent sees once instructions are cached: {text}"
);
assert!(
text.contains('['),
"and the payload must survive the prepend: {text}"
);
let _ = std::fs::remove_dir_all(&dir);
}
#[tokio::test]
async fn a_lint_result_names_the_rules_it_could_not_check() {
let resp = rpc(
&demo_server(),
json!({"jsonrpc":"2.0","id":1,"method":"tools/call",
"params":{"name":"lint","arguments":{}}}),
)
.await
.expect("lint answers");
let body = resp["result"]["content"][0]["text"].as_str().expect("text");
let v: Value = serde_json::from_str(body).expect("valid JSON");
let not_checked = v["rules_not_checked"].as_array().unwrap_or_else(|| {
panic!("every lint result must say what it could not check: {body}")
});
let joined = not_checked
.iter()
.filter_map(|x| x.as_str())
.collect::<Vec<_>>()
.join(" ");
assert!(
joined.contains("EBL011"),
"the worker-DLQ rule never fires here and the result must say \
so, not only the description: {body}"
);
assert!(
joined.contains("worker_queues"),
"and must point at the tool that DOES see queues — a caveat \
that points nowhere leaves the reader with \"lint says it is \
fine\": {body}"
);
assert!(v["issues"].is_array(), "{body}");
}
#[test]
fn every_result_that_can_be_partial_says_so() {
let prod = |path: &str| -> String {
std::fs::read_to_string(path)
.unwrap_or_else(|e| panic!("{path}: {e}"))
.split("\n#[cfg(test)]\nmod ")
.next()
.unwrap_or_default()
.to_string()
};
let src = format!(
"{}{}",
prod("src/cli/mcp/tools.rs"),
prod("src/cli/mcp/mod.rs")
);
assert!(
src.contains("pub(super) fn append_cannot_fire"),
"the production slice is not finding tools.rs"
);
for (field, what_it_bounds) in [
("rules_not_checked", "lint rules that cannot fire over MCP"),
("skipped_envs", "environments whose input fetch failed"),
("peeked", "whether a dead-letter queue was actually read"),
("complete", "whether a log window was fully consumed"),
("errors", "which sections of a `why` bundle failed"),
] {
assert!(
src.contains(field),
"`{field}` is gone — it bounded {what_it_bounds}, and \
without it that absence reads as an answer. See rule 6 \
in ARCHITECTURE.md before removing it."
);
}
}
#[tokio::test]
async fn a_scoped_grant_advertises_and_dispatches_only_its_verbs() {
let scope = WriteScope::Only(vec!["dlq_delete".into(), "dlq_resend".into()]);
let s = Server::with_config(true, false, scope, crate::config::Config::default());
let resp = rpc(&s, json!({"jsonrpc":"2.0","id":1,"method":"tools/list"}))
.await
.expect("tools/list");
let names: Vec<String> = resp["result"]["tools"]
.as_array()
.expect("array")
.iter()
.filter_map(|t| t["name"].as_str().map(str::to_string))
.collect();
assert!(names.iter().any(|n| n == "dlq_delete"), "{names:?}");
assert!(names.iter().any(|n| n == "dlq_resend"), "{names:?}");
for ungranted in [
"terminate",
"deploy",
"restart",
"rebuild",
"set_option",
"dlq_purge",
] {
assert!(
!names.iter().any(|n| n == ungranted),
"`{ungranted}` was not granted and must not be advertised — a \
client that cannot see a tool does not plan around it: {names:?}"
);
}
assert!(names.iter().any(|n| n == "list_environments"), "{names:?}");
assert!(
names.iter().any(|n| n == "confirm_action"),
"a narrow grant must still be able to confirm what it planned: {names:?}"
);
let init = rpc(
&s,
json!({"jsonrpc":"2.0","id":0,"method":"initialize",
"params":{"protocolVersion":"2025-06-18","capabilities":{},
"clientInfo":{"name":"t","version":"0"}}}),
)
.await
.expect("initialize");
let instructions = init["result"]["instructions"]
.as_str()
.expect("instructions are the only version/scope signal an agent is guaranteed");
assert!(
instructions.contains("dlq_delete") && instructions.contains("dlq_resend"),
"the instructions must name what WAS granted: {instructions}"
);
assert!(
instructions.contains("NOT GRANTED"),
"and say that the rest is withheld rather than missing: {instructions}"
);
let call = rpc(
&s,
json!({"jsonrpc":"2.0","id":9,"method":"tools/call",
"params":{"name":"terminate","arguments":{"env":"demo-prod"}}}),
)
.await
.expect("tools/call");
assert!(
call.get("error").is_none(),
"an ungranted verb is a tool refusal, not a protocol error: {call}"
);
let text = call["result"]["content"][0]["text"]
.as_str()
.expect("text content");
assert!(
text.contains("not in this server's write scope"),
"the refusal must name the scope: {text}"
);
assert!(
!text.contains("unknown tool"),
"and must not claim the tool does not exist: {text}"
);
assert_eq!(call["result"]["isError"], json!(true));
let bogus = rpc(
&s,
json!({"jsonrpc":"2.0","id":10,"method":"tools/call",
"params":{"name":"no_such_tool","arguments":{}}}),
)
.await
.expect("tools/call");
assert_eq!(bogus["error"]["code"], json!(-32602), "{bogus}");
let err = s
.tool_write_plan_for_tests(writes::WriteVerb::Terminate, &json!({"env": "x"}))
.await
.expect_err("terminate is outside the scope");
assert!(
err.contains("not in this server's write scope"),
"the refusal must name the scope, not read as a missing tool: {err}"
);
assert!(
err.contains("--allow-writes=terminate"),
"and say how to grant it: {err}"
);
let granted = s
.tool_write_plan_for_tests(writes::WriteVerb::DlqDelete, &json!({}))
.await;
if let Err(e) = &granted {
assert!(
!e.contains("not in this server's write scope"),
"`dlq_delete` was granted and must clear the scope gate \
(any later complaint about arguments is fine): {e}"
);
}
}
#[test]
fn a_mistyped_write_verb_is_refused_at_startup() {
let known = ["dlq_delete", "terminate"];
let err = super::parse_write_scope(Some("dlq_delte"), &known)
.expect_err("a typo must not be silently ignored");
assert!(err.contains("dlq_delte"), "name the typo: {err}");
assert!(err.contains("dlq_delete"), "and list what IS known: {err}");
assert_eq!(
super::parse_write_scope(None, &known).expect("bare is valid"),
WriteScope::All
);
assert!(super::parse_write_scope(Some(""), &known).is_err());
assert!(super::parse_write_scope(Some(" , ,"), &known).is_err());
assert_eq!(
super::parse_write_scope(Some("terminate, dlq_delete ,terminate"), &known)
.expect("valid"),
WriteScope::Only(vec!["terminate".into(), "dlq_delete".into()]),
"whitespace tolerated, duplicates collapsed, order kept"
);
}
#[test]
fn every_write_verb_round_trips_through_the_tool_table() {
let nameable = writes::write_verb_names();
assert_eq!(
nameable.len(),
writes::WriteVerb::ALL.len(),
"a write verb exists in one table and not the other: nameable={nameable:?}"
);
let advertised: Vec<String> = tool_table(&WriteScope::All, true)
.as_array()
.expect("array")
.iter()
.filter_map(|t| t["name"].as_str().map(str::to_string))
.collect();
for verb in writes::WriteVerb::ALL {
let name = verb.tool_name();
assert!(
nameable.iter().any(|n| n == name),
"`{name}` is what the dispatch gate checks but not a name \
--allow-writes accepts: {nameable:?}"
);
assert!(
advertised.contains(&name.to_string()),
"`{name}` is checkable but never advertised: {advertised:?}"
);
let only = WriteScope::Only(vec![name.to_string()]);
assert!(only.allows(name), "a grant of `{name}` must allow it");
for other in writes::WriteVerb::ALL {
if other.tool_name() != name {
assert!(
!only.allows(other.tool_name()),
"a grant of `{name}` must not allow `{}`",
other.tool_name()
);
}
}
}
}
#[test]
fn a_repeated_write_flag_cannot_silently_widen_the_grant() {
let args = |v: &[&str]| -> Vec<String> {
std::iter::once("mcp")
.chain(std::iter::once("serve"))
.chain(v.iter().copied())
.map(str::to_string)
.collect()
};
let err = parse_mcp_args(&args(&["--allow-writes=dlq_delete", "--allow-writes"]))
.expect_err("a second flag must not widen the first");
assert!(
err.contains("more than once"),
"the error must name the duplication: {err}"
);
assert!(
err.contains("--allow-writes=a,b"),
"and say what to do instead: {err}"
);
assert!(
!err.contains(" "),
"a wrapped literal left a gap in the rendered message: {err:?}"
);
assert!(parse_mcp_args(&args(&["--allow-writes", "--allow-writes=dlq_delete"])).is_err());
let ok = parse_mcp_args(&args(&["--allow-writes=dlq_delete"])).expect("one flag is valid");
assert_eq!(ok.write_scope, WriteScope::Only(vec!["dlq_delete".into()]));
}
#[test]
fn an_empty_grant_is_not_a_grant() {
let empty = WriteScope::Only(Vec::new());
assert!(
!empty.any(),
"an empty grant must not read as write-capable"
);
let names: Vec<String> = tool_table(&empty, true)
.as_array()
.expect("array")
.iter()
.filter_map(|t| t["name"].as_str().map(str::to_string))
.collect();
assert!(
!names.iter().any(|n| n == "confirm_action"),
"nothing to confirm, so nothing should offer to confirm it: {names:?}"
);
}
#[test]
fn only_a_live_write_capable_server_reads_the_audit_config() {
let narrow = WriteScope::Only(vec!["dlq_delete".into()]);
assert!(should_init_audit(&WriteScope::All, false));
assert!(
should_init_audit(&narrow, false),
"a narrow grant still dispatches writes, so it still audits"
);
assert!(
!should_init_audit(&WriteScope::None, false),
"a reads-only server must not touch the config disk"
);
assert!(
!should_init_audit(&WriteScope::All, true),
"demo must not acquire a webhook — `||` here would give it one"
);
assert!(
!should_init_audit(&WriteScope::None, true),
"and neither half alone is enough — dropping the `!` inverts this"
);
}
#[test]
fn only_the_mcp_daemon_opens_a_log_file() {
let a = |v: &[&str]| -> Vec<String> { v.iter().map(|s| s.to_string()).collect() };
assert!(wants_file_logging(&a(&["mcp", "serve"])));
assert!(wants_file_logging(&a(&[
"mcp",
"serve",
"--demo",
"--allow-writes"
])));
assert!(
!wants_file_logging(&a(&["mcp", "setup"])),
"setup is a pure printer that promises it writes no files"
);
assert!(!wants_file_logging(&a(&["mcp", "setup", "--allow-writes"])));
assert!(!wants_file_logging(&a(&["mcp"])), "a bare usage error");
}
#[test]
fn the_instructions_say_a_grant_is_not_the_agents_to_make() {
let read_only = WriteScope::None.agent_summary(None, false, false);
assert!(
read_only.contains("Do not edit the MCP config yourself"),
"a read-only server must say whose job the grant is: {read_only}"
);
assert!(
read_only.contains("self-modification"),
"and warn that trying costs a denial: {read_only}"
);
let narrow = WriteScope::Only(vec!["dlq_delete".into()]).agent_summary(None, false, false);
assert!(
narrow.contains("restart the client"),
"a client that reconnects without re-reading its config shows the \
verb as absent, which reads as a broken feature: {narrow}"
);
assert!(narrow.contains("Asking is your part"), "{narrow}");
}
#[tokio::test]
async fn a_standing_refusal_outranks_the_grant_in_the_instructions() {
let cfg = crate::config::Config {
safety_read_only: true,
..crate::config::Config::default()
};
let s = Server::with_config(true, false, WriteScope::All, cfg);
let init = rpc(
&s,
json!({"jsonrpc":"2.0","id":1,"method":"initialize",
"params":{"protocolVersion":"2025-06-18","capabilities":{},
"clientInfo":{"name":"t","version":"0"}}}),
)
.await
.expect("initialize");
let ins = init["result"]["instructions"]
.as_str()
.expect("instructions");
assert!(ins.contains("Writes are REFUSED"), "{ins}");
assert!(
ins.contains("safety.read_only"),
"and name the control: {ins}"
);
assert!(
!ins.contains("Writes are ENABLED"),
"the grant must not be described on a server that refuses every write: {ins}"
);
let broken = crate::config::Config {
safety_parse_errors: vec!["safety.envs.prod = true is missing .read_only".into()],
..crate::config::Config::default()
};
let b = Server::with_config(true, false, WriteScope::All, broken);
assert!(
b.write_scope
.agent_summary(Some("the safety config could not be parsed."), false, false)
.contains("REFUSED"),
"a fail-closed parse refuses every write and the block must say so"
);
let open = Server::with_config(
true,
false,
WriteScope::All,
crate::config::Config::default(),
);
assert!(
open.write_scope
.agent_summary(None, false, false)
.contains("ENABLED"),
"without a standing refusal the grant is the right thing to describe"
);
}
#[test]
fn only_the_call_that_asks_a_human_gets_a_humans_budget() {
assert_eq!(
call_timeout_secs(writes::CONFIRM_TOOL, true),
ASK_TIMEOUT_SECS
);
assert_eq!(
call_timeout_secs(writes::CONFIRM_TOOL, false),
TOOL_TIMEOUT_SECS
);
for t in [
"list_environments",
"worker_queues",
"deploy",
"terminate",
"doctor",
] {
assert_eq!(
call_timeout_secs(t, true),
TOOL_TIMEOUT_SECS,
"`{t}` cannot block on a person and must keep the AWS bound"
);
}
}
#[test]
fn the_frame_loop_times_calls_by_the_computed_budget() {
let src = include_str!("mod.rs");
let prod = crate::app::tests::scan::production_half(src);
let call = prod
.split("let outcome = tokio::time::timeout(")
.nth(1)
.and_then(|r| r.split(".await").next())
.expect("the frame loop times the tool call here");
assert!(
call.contains("from_secs(budget)"),
"the tool call must be bounded by the COMPUTED budget: a person deciding \
whether to delete production data gets the AWS bound otherwise, and the \
gate times out under ordinary use:\n{call}"
);
assert!(
!call.contains("TOOL_TIMEOUT_SECS"),
"and not by the raw constant, which is what it was before:\n{call}"
);
assert!(
prod.contains("let budget = call_timeout_secs("),
"the budget is no longer computed in this file — this guard has lost \
its subject rather than being satisfied"
);
}
#[test]
fn only_an_explicit_accept_approves() {
let accept = json!({"jsonrpc":"2.0","id":1,"result":{"action":"accept"}});
assert_eq!(ask_outcome_from(&accept), AskOutcome::Approved);
for reply in [
json!({"jsonrpc":"2.0","id":1,"result":{"action":"decline"}}),
json!({"jsonrpc":"2.0","id":1,"result":{"action":"cancel"}}),
json!({"jsonrpc":"2.0","id":1,"result":{"action":"ACCEPT"}}),
json!({"jsonrpc":"2.0","id":1,"result":{"action":true}}),
json!({"jsonrpc":"2.0","id":1,"result":{}}),
json!({"jsonrpc":"2.0","id":1,"result":"accept"}),
json!({"jsonrpc":"2.0","id":1}),
] {
assert_eq!(
ask_outcome_from(&reply),
AskOutcome::Declined,
"not an explicit accept, so not an approval: {reply}"
);
}
let errored = json!({"jsonrpc":"2.0","id":1,"error":{"code":-32601,"message":"no"}});
assert_eq!(
ask_outcome_from(&errored),
AskOutcome::Unsupported,
"a client that could not present the question has not declined it"
);
assert!(
ask_outcome_from(&errored).refuses(),
"and it must still deny"
);
}
#[test]
fn not_asked_is_not_the_same_as_unanswered() {
assert!(
AskOutcome::Unanswered.refuses(),
"an ask nobody answered denies"
);
assert!(AskOutcome::Declined.refuses());
assert!(
!AskOutcome::NotAsked.refuses(),
"no question was put, so there is no answer to respect — conflating \
these denied every write on every client that cannot elicit, including \
ones the operator had granted with the flag"
);
assert_ne!(
AskOutcome::NotAsked,
AskOutcome::Approved,
"nor is it an approval"
);
}
#[tokio::test]
async fn a_client_without_elicitation_is_not_asked() {
let s = Server::with_scope(true, false, WriteScope::All);
assert_eq!(
s.ask_operator("delete something", None).await,
AskOutcome::NotAsked
);
}
#[tokio::test(start_paused = true)]
async fn an_unanswered_ask_denies_when_the_budget_expires() {
let s = Server::with_scope(true, false, WriteScope::All);
s.client_supports_elicitation
.store(true, std::sync::atomic::Ordering::Relaxed);
let (tx, mut rx) = tokio::sync::mpsc::channel::<String>(4);
if let Ok(mut slot) = s.outbound.lock() {
*slot = Some(tx);
}
let asked = tokio::spawn(async move { s.ask_operator("delete something", None).await });
let frame: Value = serde_json::from_str(&rx.recv().await.expect("a frame")).expect("json");
assert_eq!(frame["method"], json!("elicitation/create"), "{frame}");
assert!(
frame["params"]["message"]
.as_str()
.is_some_and(|m| m.contains("delete something")),
"the question must carry what is being asked: {frame}"
);
tokio::time::advance(std::time::Duration::from_secs(ASK_TIMEOUT_SECS + 1)).await;
let outcome = asked.await.expect("join");
assert_eq!(
outcome,
AskOutcome::Unanswered,
"an operator who walked away must produce a deny, not a call that \
never returns"
);
}
#[tokio::test]
async fn an_answer_is_routed_back_to_the_ask_that_is_waiting() {
let s = std::sync::Arc::new(Server::with_scope(true, false, WriteScope::All));
s.client_supports_elicitation
.store(true, std::sync::atomic::Ordering::Relaxed);
let (tx, mut rx) = tokio::sync::mpsc::channel::<String>(4);
if let Ok(mut slot) = s.outbound.lock() {
*slot = Some(tx);
}
let asking = {
let s = std::sync::Arc::clone(&s);
tokio::spawn(async move { s.ask_operator("terminate prod", None).await })
};
let frame: Value = serde_json::from_str(&rx.recv().await.expect("frame")).expect("json");
let id = frame["id"].clone();
let reply = json!({"jsonrpc": "2.0", "id": id, "result": {"action": "accept"}});
assert!(
s.take_ask_reply(&reply),
"a frame carrying an id we issued, with no method, is ours"
);
assert_eq!(asking.await.expect("join"), AskOutcome::Approved);
assert!(!s.take_ask_reply(&json!({"jsonrpc":"2.0","id":7,"result":{}})));
assert!(!s.take_ask_reply(&json!({"jsonrpc":"2.0","id":1,"method":"ping"})));
}
fn demo_answering(
action: &'static str,
) -> (
std::sync::Arc<Server>,
std::sync::Arc<std::sync::Mutex<Vec<String>>>,
) {
let s = std::sync::Arc::new(demo_writes_server());
s.client_supports_elicitation
.store(true, std::sync::atomic::Ordering::Relaxed);
let (tx, mut rx) = tokio::sync::mpsc::channel::<String>(8);
if let Ok(mut slot) = s.outbound.lock() {
*slot = Some(tx);
}
let asked = std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
let seen = std::sync::Arc::clone(&asked);
let srv = std::sync::Arc::clone(&s);
tokio::spawn(async move {
while let Some(line) = rx.recv().await {
let frame: Value = serde_json::from_str(&line).expect("json");
if let Some(m) = frame["params"]["message"].as_str() {
seen.lock().expect("lock").push(m.to_string());
}
let reply = json!({
"jsonrpc": "2.0",
"id": frame["id"].clone(),
"result": {"action": action},
});
assert!(srv.take_ask_reply(&reply), "the loop must route it back");
}
});
(s, asked)
}
#[tokio::test]
async fn a_declined_ask_stops_the_write() {
let (s, asked) = demo_answering("decline");
let env = demo_fixture::envs()[0].name.clone();
let (err, plan) = call(&s, "restart", json!({"env": &env})).await;
assert!(!err, "planning is not gated: {plan}");
let token = plan["confirm_token"].as_str().expect("token").to_string();
let (err, out) = call(&s, "confirm_action", json!({"confirm_token": token})).await;
assert!(err, "a declined ask must refuse the write: {out}");
let text = out.to_string();
assert!(
text.contains("declined"),
"the refusal must say the operator declined, not blame a missing \
flag or an expired token: {text}"
);
assert!(
!text.contains("--allow-writes"),
"and must not send the agent off to widen permissions it already \
has — the answer was no: {text}"
);
let asked = asked.lock().expect("lock");
assert_eq!(asked.len(), 1, "exactly one ask: {asked:?}");
assert!(
asked[0].contains(&env) && asked[0].to_lowercase().contains("restart"),
"the operator must be told which verb on which environment, or the \
question is unanswerable: {:?}",
asked[0]
);
}
#[tokio::test]
async fn an_approved_ask_lets_the_write_through() {
let (s, asked) = demo_answering("accept");
let env = demo_fixture::envs()[0].name.clone();
let (_, plan) = call(&s, "restart", json!({"env": env})).await;
let token = plan["confirm_token"].as_str().expect("token").to_string();
let (err, out) = call(&s, "confirm_action", json!({"confirm_token": token})).await;
assert!(!err, "an approved write must dispatch: {out}");
assert_eq!(asked.lock().expect("lock").len(), 1, "and must still ask");
}
#[tokio::test]
async fn a_spent_token_does_not_ask_again() {
let (s, asked) = demo_answering("accept");
let env = demo_fixture::envs()[0].name.clone();
let (_, plan) = call(&s, "restart", json!({"env": env})).await;
let token = plan["confirm_token"].as_str().expect("token").to_string();
assert!(
!call(&s, "confirm_action", json!({"confirm_token": &token}))
.await
.0
);
assert!(
call(&s, "confirm_action", json!({"confirm_token": &token}))
.await
.0,
"the token is single-use"
);
assert_eq!(
asked.lock().expect("lock").len(),
1,
"the replay must be rejected before anyone is asked"
);
}
#[tokio::test]
async fn an_unclaimed_reply_would_be_answered_as_a_bad_method() {
let s = demo_writes_server();
let reply = json!({"jsonrpc": "2.0", "id": 1_000_000, "result": {"action": "accept"}});
assert!(
invalid_request_response(&reply).is_none(),
"the validity check passes it through — it only rejects non-objects"
);
let resp = s.handle_request(&reply).await.expect("a response");
assert_eq!(
resp["error"]["code"], -32601,
"so an unclaimed reply gets method-not-found, and the approval is \
lost: {resp}"
);
}
#[test]
fn take_ask_reply_precedes_the_dispatch() {
let src = include_str!("mod.rs");
let body = crate::app::tests::scan::production_half(src);
let claim = body
.find("if server.take_ask_reply(&req)")
.expect("the frame loop must route ask replies");
let dispatch = body
.find("server.handle_request(&req).await")
.expect("the frame loop must dispatch requests");
assert!(
claim < dispatch,
"take_ask_reply must come first: a reply has an id and no method, \
so the dispatch below answers it -32601 and the ask denies a write \
the operator had approved"
);
}
#[test]
fn the_summary_says_whether_confirmations_are_put_to_a_person() {
for scope in [
WriteScope::All,
WriteScope::Only(vec!["restart".into(), "dlq_delete".into()]),
] {
let asked = scope.agent_summary(None, true, false);
let silent = scope.agent_summary(None, false, false);
assert!(
asked.contains("put to the operator") || asked.contains("put to the"),
"a granted scope on an ask-capable client must say the confirmation \
reaches a person: {asked}"
);
assert!(
!asked.contains("a final answer from a human"),
"and must NOT pre-load the agent with an attribution ebman cannot \
make. That sentence sat in the instructions block — read at connect, \
before any refusal text — and taught the exact false record the \
decline wording was rewritten to stop: {asked}"
);
assert!(
asked.contains("decline"),
"and must say what a decline means, or it reads as an error: {asked}"
);
assert!(
!silent.contains("put to the"),
"but must NOT promise an ask that cannot happen — on a client that \
can't elicit, nobody is reachable and the agent would wait for a \
human who is never shown anything: {silent}"
);
}
}
#[test]
fn a_standing_refusal_outranks_the_ask_note() {
let t = WriteScope::All.agent_summary(Some("safety.read_only is set."), true, true);
assert!(t.contains("REFUSED"), "{t}");
assert!(
!t.contains("OPERATOR"),
"nothing will be put to anyone on a server that refuses every write: {t}"
);
}
#[tokio::test]
async fn an_ask_capable_client_gets_writes_without_a_flag() {
let s = Server::with_scope(true, false, WriteScope::None);
assert_eq!(
s.effective_scope(),
WriteScope::None,
"no flag and no ask is still read-only"
);
s.client_supports_elicitation
.store(true, std::sync::atomic::Ordering::Relaxed);
assert_eq!(
s.effective_scope(),
WriteScope::All,
"but a client that can be asked gets parity with the TUI"
);
}
#[tokio::test]
async fn elicitation_does_not_widen_a_narrowed_grant() {
let narrow = WriteScope::Only(vec!["dlq_delete".into()]);
let s = Server::with_scope(true, false, narrow.clone());
s.client_supports_elicitation
.store(true, std::sync::atomic::Ordering::Relaxed);
assert_eq!(
s.effective_scope(),
narrow,
"the operator said only dlq_delete, and a dialog capability is not \
their permission to widen it"
);
assert!(!s.effective_scope().allows("terminate"));
}
#[tokio::test]
async fn the_advertised_tools_follow_the_connection() {
let s = demo_server(); let names = |s: &Server| -> Vec<String> {
tool_table(&s.effective_scope(), true)
.as_array()
.expect("array")
.iter()
.filter_map(|t| t["name"].as_str().map(str::to_string))
.collect()
};
let before = names(&s);
assert!(
!before.iter().any(|n| n == "confirm_action"),
"read-only: {before:?}"
);
s.client_supports_elicitation
.store(true, std::sync::atomic::Ordering::Relaxed);
let after = names(&s);
assert!(
after.iter().any(|n| n == "confirm_action"),
"an ask-capable client must be able to SEE the write surface, not \
just be permitted it: {after:?}"
);
assert!(
after.len() > before.len(),
"and the read tools must not have been swapped out for them"
);
}
#[tokio::test]
async fn doctor_reports_the_effective_surface() {
let s = demo_server();
async fn doctor(s: &Server) -> String {
call(s, "doctor", json!({})).await.1.to_string()
}
assert!(doctor(&s).await.contains("read-only"));
s.client_supports_elicitation
.store(true, std::sync::atomic::Ordering::Relaxed);
let d = doctor(&s).await;
assert!(
!d.contains("read-only"),
"doctor must not report a restriction this connection does not have: {d}"
);
assert!(d.contains("every verb"), "{d}");
}
#[tokio::test]
async fn no_flag_one_answer_and_the_write_dispatches() {
let s = std::sync::Arc::new(demo_server());
s.client_supports_elicitation
.store(true, std::sync::atomic::Ordering::Relaxed);
let (tx, mut rx) = tokio::sync::mpsc::channel::<String>(8);
if let Ok(mut slot) = s.outbound.lock() {
*slot = Some(tx);
}
let srv = std::sync::Arc::clone(&s);
tokio::spawn(async move {
while let Some(line) = rx.recv().await {
let f: Value = serde_json::from_str(&line).expect("json");
let reply = json!({"jsonrpc":"2.0","id":f["id"].clone(),
"result":{"action":"accept"}});
assert!(srv.take_ask_reply(&reply));
}
});
let env = demo_fixture::envs()[0].name.clone();
let (err, plan) = call(&s, "restart", json!({"env": env})).await;
assert!(
!err,
"a write tool must be callable with no flag when the operator can be \
asked — this is the restart-your-client problem the design exists to \
remove: {plan}"
);
let token = plan["confirm_token"].as_str().expect("a token").to_string();
let (err, out) = call(&s, "confirm_action", json!({"confirm_token": token})).await;
assert!(!err, "and it must dispatch once approved: {out}");
}
#[test]
fn the_ask_resolves_inside_the_call_that_carries_it() {
assert!(
ASK_WAIT_SECS < call_timeout_secs(writes::CONFIRM_TOOL, true),
"an ask given its container's whole budget never returns its own \
answer — the outer timeout wins and nothing is audited"
);
}
#[tokio::test]
async fn a_batch_of_two_asks_once_and_reports_per_message() {
let (s, asked) = demo_answering("accept");
let ids: Vec<String> = crate::demo_fixture::dlq_messages_for_env("poly-batch")
.into_iter()
.map(|m| m.id)
.collect();
assert_eq!(ids.len(), 2, "the fixture must hold a real batch");
let (err, plan) = call(
&s,
"dlq_delete",
json!({"env": "poly-batch", "message_ids": ids.clone()}),
)
.await;
assert!(!err, "a batch plan must be accepted: {plan}");
let token = plan["confirm_token"].as_str().expect("token").to_string();
let plan_text = plan.to_string();
for id in &ids {
assert!(
plan_text.contains(id.as_str()),
"the plan must name every message it covers: {plan_text}"
);
}
let (err, out) = call(&s, "confirm_action", json!({"confirm_token": token})).await;
assert!(!err, "the batch must dispatch: {out}");
let asked = asked.lock().expect("lock");
assert_eq!(
asked.len(),
1,
"ONE dialog for the whole batch — asking per message is the failure \
this feature exists to remove: {asked:?}"
);
for id in &ids {
assert!(
asked[0].contains(id.as_str()),
"and that one dialog must enumerate what it covers, or the operator \
approves a set they were not shown: {:?}",
asked[0]
);
}
let text = out.to_string();
assert!(
text.contains("\"succeeded\":2"),
"the result must account for both: {text}"
);
assert!(
text.contains("\"failed\":0"),
"including the failures it did NOT have — an absent count reads as \
unknown: {text}"
);
}
#[tokio::test]
async fn an_oversized_batch_never_reaches_the_operator() {
let (s, asked) = demo_answering("accept");
let too_many: Vec<String> = (0..=super::writes::DLQ_BATCH_CAP)
.map(|i| format!("id-{i}"))
.collect();
let (err, out) = call(
&s,
"dlq_delete",
json!({"env": "poly-batch", "message_ids": too_many}),
)
.await;
assert!(err, "over the cap must refuse: {out}");
assert_eq!(
asked.lock().expect("lock").len(),
0,
"and must refuse at PLAN time — an unreadable list must never reach a \
dialog, because the cap exists to keep the dialog readable"
);
}
#[tokio::test]
async fn a_dead_ask_channel_denies_rather_than_falling_through() {
let s = Server::with_scope(true, false, WriteScope::None);
s.client_supports_elicitation
.store(true, std::sync::atomic::Ordering::Relaxed);
assert_eq!(
s.ask_operator("terminate poly-prod", None).await,
AskOutcome::Undeliverable,
"a client that can be asked but cannot be reached must DENY — \
`NotAsked` would fall back to a flag that was never given"
);
let (tx, rx) = tokio::sync::mpsc::channel::<String>(1);
drop(rx);
if let Ok(mut slot) = s.outbound.lock() {
*slot = Some(tx);
}
assert_eq!(
s.ask_operator("terminate poly-prod", None).await,
AskOutcome::Undeliverable,
"a send that cannot be delivered is an unanswered ask, not an absent one"
);
let flagged = Server::with_scope(true, false, WriteScope::All);
assert_eq!(
flagged.ask_operator("x", None).await,
AskOutcome::NotAsked,
"no capability is still NotAsked, or every flag-granted client is denied"
);
}
#[tokio::test]
async fn a_write_that_only_the_ask_gated_cannot_dispatch_unasked() {
let s = demo_server(); s.client_supports_elicitation
.store(true, std::sync::atomic::Ordering::Relaxed);
let env = demo_fixture::envs()[0].name.clone();
let (err, plan) = call(&s, "restart", json!({"env": env})).await;
assert!(!err, "planning is open: {plan}");
let token = plan["confirm_token"].as_str().expect("token").to_string();
let (err, out) = call(&s, "confirm_action", json!({"confirm_token": token})).await;
assert!(
err,
"with no flag and no reachable operator, nothing may dispatch: {out}"
);
}
#[tokio::test]
async fn read_only_outranks_the_elicitation_default() {
let s = Server::with_scope(true, false, WriteScope::None);
s.mcp_read_only
.store(true, std::sync::atomic::Ordering::Relaxed);
s.client_supports_elicitation
.store(true, std::sync::atomic::Ordering::Relaxed);
assert_eq!(
s.effective_scope(),
WriteScope::None,
"the operator said no to writes on this server; a client that supports \
dialogs is not their permission to change that"
);
assert!(
!tool_table(&s.effective_scope(), true)
.as_array()
.expect("array")
.iter()
.any(|t| t["name"] == "confirm_action"),
"and the write surface must not be advertised"
);
}
#[test]
fn read_only_is_parsed_and_cannot_contradict_a_grant() {
let args = |v: &[&str]| -> Vec<String> {
std::iter::once("mcp".to_string())
.chain(std::iter::once("serve".to_string()))
.chain(v.iter().map(|s| (*s).to_string()))
.collect()
};
let ok = parse_mcp_args(&args(&["--read-only"])).expect("valid");
assert_eq!(ok.write_scope, WriteScope::None);
let err = parse_mcp_args(&args(&["--read-only", "--allow-writes"]))
.expect_err("contradiction must be refused");
assert!(
err.contains("contradict"),
"picking a winner silently hands the operator a posture they did not \
choose, either way: {err}"
);
assert!(parse_mcp_args(&args(&["--allow-writes", "--read-only"])).is_err());
assert!(parse_mcp_args(&args(&["--read-only", "--read-only"])).is_err());
}
#[test]
fn a_timeout_and_a_decline_tell_the_agent_different_things() {
let declined = AskOutcome::Declined.guidance();
let silent = AskOutcome::Unanswered.guidance();
assert_ne!(declined, silent, "the two must not share wording");
assert!(
declined.contains("unless the operator asks for it"),
"a decline keeps the flat prohibition with one named exception — a \
rationale-shaped guard does not catch an agent under \
task-completion pressure, which is a different pull from \
permission-seeking: {declined}"
);
assert!(
silent.contains("NOT a refusal"),
"a silence must not be reported as a refusal — nobody refused: {silent}"
);
assert!(
silent.contains("Tell them"),
"and must name a legitimate next act, or the agent's only options are \
invent one or go quiet: {silent}"
);
assert!(
!silent.contains("unless the operator asks for it"),
"naming the absent party as the only unblock leaves no move at all: {silent}"
);
assert!(
silent.contains("plan it fresh"),
"a legitimate retry must be granted outright, or the agent reasons its \
way to one from an adverb: {silent}"
);
assert!(
!silent.contains("quietly"),
"the adverb is what made the permission implicit: {silent}"
);
assert!(AskOutcome::Approved.guidance().is_empty());
assert!(AskOutcome::NotAsked.guidance().is_empty());
}
#[tokio::test]
async fn doctor_says_why_writes_are_available_not_just_how_wide() {
async fn doctor(s: &Server) -> String {
call(s, "doctor", json!({})).await.1.to_string()
}
let flagged = Server::with_scope(true, false, WriteScope::All);
let d = doctor(&flagged).await;
assert!(d.contains("--allow-writes"), "{d}");
assert!(
!d.contains("client-elicitation"),
"a flag-granted surface must not claim every write is put to a person — \
on a client that cannot be asked, nobody is: {d}"
);
let asked = demo_server();
asked
.client_supports_elicitation
.store(true, std::sync::atomic::Ordering::Relaxed);
let d = doctor(&asked).await;
assert!(
d.contains("client-elicitation") && d.contains("may decline"),
"an elicitation-gated surface must say so, or the agent tells its user \
it can act when it can only propose: {d}"
);
let both = Server::with_scope(true, false, WriteScope::All);
both.client_supports_elicitation
.store(true, std::sync::atomic::Ordering::Relaxed);
let d = doctor(&both).await;
assert!(
d.contains("--allow-writes"),
"the flag is still where the scope came from: {d}"
);
assert!(
d.contains("still put to them") && d.contains("may decline"),
"and the ask fires on capability regardless of the flag, so doctor must \
not report this as a bare standing grant: {d}"
);
let ro = demo_server();
let d = doctor(&ro).await;
assert!(d.contains("no writes are available"), "{d}");
}
#[tokio::test]
async fn the_plan_says_a_person_will_be_asked() {
let s = demo_server();
s.client_supports_elicitation
.store(true, std::sync::atomic::Ordering::Relaxed);
let env = demo_fixture::envs()[0].name.clone();
let (_, plan) = call(&s, "restart", json!({"env": &env})).await;
let next = plan["next"].as_str().expect("a next hint");
assert!(
next.contains("OPERATOR") && next.contains("decline"),
"the plan must say the confirm is a request put to a person: {next}"
);
let flagged = Server::with_scope(true, false, WriteScope::All);
let (_, plan) = call(&flagged, "restart", json!({"env": env})).await;
let next = plan["next"].as_str().expect("a next hint");
assert!(
!next.contains("OPERATOR"),
"on a client that cannot be asked, promising an operator dialog is a \
lie the agent would relay to its user: {next}"
);
}
#[tokio::test(start_paused = true)]
async fn a_timed_out_ask_tells_the_client_to_withdraw_it() {
let s = std::sync::Arc::new(Server::with_scope(true, false, WriteScope::None));
s.client_supports_elicitation
.store(true, std::sync::atomic::Ordering::Relaxed);
let (tx, mut rx) = tokio::sync::mpsc::channel::<String>(8);
if let Ok(mut slot) = s.outbound.lock() {
*slot = Some(tx);
}
let srv = std::sync::Arc::clone(&s);
let asking =
tokio::spawn(async move { srv.ask_operator("terminate poly-prod", None).await });
let ask: Value = serde_json::from_str(&rx.recv().await.expect("the ask")).expect("json");
let asked_id = ask["id"].clone();
assert_eq!(ask["method"], json!("elicitation/create"));
tokio::time::advance(std::time::Duration::from_secs(ASK_TIMEOUT_SECS + 1)).await;
assert_eq!(asking.await.expect("join"), AskOutcome::Unanswered);
let cancel: Value =
serde_json::from_str(&rx.recv().await.expect("a cancellation")).expect("json");
assert_eq!(
cancel["method"],
json!("notifications/cancelled"),
"the client must be told to take the dialog down: {cancel}"
);
assert_eq!(
cancel["params"]["requestId"], asked_id,
"and it must name the request it withdraws, or it cancels someone else's \
dialog: {cancel}"
);
assert!(
cancel.get("id").is_none(),
"a notification carries no id — an id makes it a request the client must \
answer: {cancel}"
);
}
#[test]
fn a_reply_to_a_forgotten_ask_is_dropped_not_answered() {
let src = include_str!("mod.rs");
let body = crate::app::tests::scan::production_half(src);
let claim = body
.find("if server.take_ask_reply(&req)")
.expect("the loop routes ask replies");
let drop = body
.find("if req.get(\"method\").is_none()")
.expect("the loop must drop unclaimed responses");
let dispatch = body
.find("server.handle_request(&req).await")
.expect("the loop dispatches requests");
assert!(
claim < drop && drop < dispatch,
"the order must be claim, then drop, then dispatch: claiming after \
dropping loses every real answer, and dispatching before dropping \
answers a response"
);
assert!(
body[drop..dispatch].contains("result") && body[drop..dispatch].contains("error"),
"the drop must require result or error, or it silently swallows a \
malformed request as well as a response"
);
}
#[test]
fn an_ask_opened_surface_tells_the_agent_to_warn_the_operator() {
let opened = WriteScope::All.agent_summary(None, true, true);
assert!(
opened.contains("YOUR CLIENT") && opened.contains("--read-only"),
"the agent must be told to explain WHY writes exist and name the way \
back: {opened}"
);
assert!(
opened.contains("before you plan a write"),
"and to say it before acting, not after: {opened}"
);
assert!(
opened.contains("terminate") && opened.contains("--allow-writes=verb,verb"),
"it must name what else came with it, and the way to narrow it: {opened}"
);
assert!(
opened.contains("not a reason to avoid")
|| opened.contains("Do not treat this as a reason to avoid"),
"the note must say plainly that this is not a reason to stop proposing \
work, or it reads as a discouragement: {opened}"
);
let flagged = WriteScope::All.agent_summary(None, true, false);
assert!(
!flagged.contains("YOUR CLIENT"),
"an operator who passed --allow-writes already knows: {flagged}"
);
let refused = WriteScope::All.agent_summary(Some("read_only is set."), true, true);
assert!(!refused.contains("YOUR CLIENT"), "{refused}");
}
#[test]
fn the_ask_audit_vocabulary_is_stable_and_excludes_the_unasked() {
assert_eq!(AskOutcome::Approved.answer_label(), "approved");
assert_eq!(AskOutcome::Declined.answer_label(), "declined");
assert_eq!(AskOutcome::Unanswered.answer_label(), "unanswered");
let all = [
AskOutcome::Approved.answer_label(),
AskOutcome::Declined.answer_label(),
AskOutcome::Unanswered.answer_label(),
];
assert_eq!(
all.iter().collect::<std::collections::HashSet<_>>().len(),
3,
"every outcome must be distinguishable in the log"
);
for o in [
AskOutcome::Approved,
AskOutcome::Declined,
AskOutcome::Unanswered,
] {
assert!(
!o.answer_label().contains(' '),
"a log token must not contain spaces — `escape_value` does not quote \
them, so a spaced token can forge a field: {:?}",
o.answer_label()
);
}
}
#[test]
fn a_connection_that_was_never_asked_writes_no_ask_line() {
let src = include_str!("writes.rs");
let body = crate::app::tests::scan::production_half(src);
let call = body
.find("append_action_asked")
.expect("the confirm path must audit the ask");
let guard = body[..call]
.rfind("if was_actually_asked")
.expect("and must exclude the case where no question was put");
assert!(
call - guard < 400,
"the guard must be the condition on THIS call — a line saying a question \
was asked when none was is the false record this stage exists to prevent"
);
let binding = body[..call]
.rfind("let was_actually_asked")
.expect("the exclusion must be a named binding");
let cond = &body[binding..call];
for never_asked in ["AskOutcome::NotAsked", "AskOutcome::Undeliverable"] {
assert!(
cond.contains(never_asked),
"{never_asked} means no question reached anybody, so it must not \
produce a `stage=asked` line: {cond}"
);
}
}
#[tokio::test(start_paused = true)]
async fn terminate_and_purge_require_the_operator_to_type_the_name() {
async fn ask_with(reply_content: Option<Value>, expect: Option<&str>) -> AskOutcome {
let s = std::sync::Arc::new(Server::with_scope(true, false, WriteScope::All));
s.client_supports_elicitation
.store(true, std::sync::atomic::Ordering::Relaxed);
let (tx, mut rx) = tokio::sync::mpsc::channel::<String>(4);
if let Ok(mut slot) = s.outbound.lock() {
*slot = Some(tx);
}
let want = expect.map(str::to_string);
let srv = std::sync::Arc::clone(&s);
let asking = tokio::spawn(async move {
srv.ask_operator("terminate poly-prod", want.as_deref())
.await
});
let frame: Value = serde_json::from_str(&rx.recv().await.expect("ask")).expect("json");
let required = frame["params"]["requestedSchema"]["required"].clone();
if expect.is_some() {
assert_eq!(
required,
json!(["confirm"]),
"a typed confirm must be a REQUIRED property, or a client may \
render nothing and the operator types nothing: {frame}"
);
} else {
assert!(
required.is_null(),
"ordinary confirms demand nothing: {frame}"
);
}
let mut result = json!({"action": "accept"});
if let Some(c) = reply_content {
result["content"] = c;
}
let reply = json!({"jsonrpc": "2.0", "id": frame["id"].clone(), "result": result});
assert!(s.take_ask_reply(&reply));
asking.await.expect("join")
}
assert_eq!(
ask_with(Some(json!({"confirm": "poly-prod"})), Some("poly-prod")).await,
AskOutcome::Approved
);
assert_eq!(
ask_with(Some(json!({"confirm": "poly-prd"})), Some("poly-prod")).await,
AskOutcome::Unconfirmed,
"a mistyped name is a failed confirmation, and logging it as a decline \
records a refusal that did not happen"
);
assert_eq!(
ask_with(None, Some("poly-prod")).await,
AskOutcome::Unconfirmed,
"a client that cannot show a text field must not be able to one-click a \
terminate"
);
for near in ["Poly-Prod", " poly-prod", "poly-prod "] {
assert_eq!(
ask_with(Some(json!({"confirm": near})), Some("poly-prod")).await,
AskOutcome::Unconfirmed,
"{near:?} must not pass — the only thing this step buys is that \
somebody read the name and reproduced it"
);
}
assert_eq!(ask_with(None, None).await, AskOutcome::Approved);
}
#[test]
fn the_typed_confirm_is_scoped_to_terminate_and_purge() {
let src = include_str!("writes.rs");
let body = crate::app::tests::scan::production_half(src);
let arm = body
.find("WriteVerb::Terminate | WriteVerb::DlqPurge => Some(pending.env.as_str())")
.expect("terminate and purge demand a typed name");
let rest = &body[arm..arm + 200];
assert!(
rest.contains("_ => None"),
"every other verb must demand nothing — a typed confirm on a restart is \
friction that teaches operators to type past the ones that matter"
);
}
#[tokio::test]
async fn doctor_says_a_declared_capability_is_not_proof_of_a_human() {
async fn doctor(s: &Server) -> String {
call(s, "doctor", json!({})).await.1.to_string()
}
let s = demo_server();
s.client_supports_elicitation
.store(true, std::sync::atomic::Ordering::Relaxed);
let d = doctor(&s).await;
let body: Value = serde_json::from_str(&d).unwrap_or_else(|e| panic!("{d}: {e}"));
let note = body["notes"]
.as_array()
.and_then(|n| n.iter().find_map(Value::as_str))
.unwrap_or_else(|| panic!("no notes in {body}"));
assert!(
!note.starts_with('"') && !note.ends_with('"'),
"a note must not carry its own quotes — that is a double encode: {note:?}"
);
assert!(
d.contains("not proof a person saw"),
"the limit must be stated where an agent reads it: {d}"
);
assert!(
d.contains("not that a human approved it"),
"and must name the specific over-claim to avoid, not just gesture at \
uncertainty: {d}"
);
let quiet = demo_server();
assert!(
!doctor(&quiet).await.contains("not proof a person saw"),
"a connection with no elicitation has no ask to qualify"
);
}
#[test]
fn a_decline_does_not_assert_that_a_person_made_it() {
let reason = AskOutcome::Declined.reason();
assert!(
!reason.contains("operator") && !reason.contains("human"),
"ebman cannot see who answered — asserting a person did is a false \
record the agent repeats to its user: {reason}"
);
assert!(
reason.contains("declined"),
"while still saying plainly what happened: {reason}"
);
let guidance = AskOutcome::Declined.guidance();
assert!(
guidance.contains("do not re-plan the same action unless the operator"),
"the flat prohibition must survive the hedging — it is what stops a \
retry loop: {guidance}"
);
assert!(
guidance.contains("do NOT tell your user a person refused"),
"and the agent must be told not to attribute it: {guidance}"
);
assert!(
guidance.contains("-p") || guidance.contains("CI harness"),
"naming the concrete case beats gesturing at uncertainty — an agent that \
knows a `-p` run declines by itself can say something useful: {guidance}"
);
}
}