From de81f610e01bf873f29146d1e0a5b763b2f7bc35 Mon Sep 17 00:00:00 2001 From: Phelipe Lorran <112786693+httpsphl@users.noreply.github.com> Date: Sat, 10 Oct 2026 15:23:21 -0300 Subject: [PATCH] feat(agents): Codex asks the owner before it acts (A3b of spec 30) approvalPolicy on-request (never in plan mode): a command or a file change the sandbox does not allow reaches the chat as the usual approval card, with the bot's 'allow always' rules and its mode (bypass, accept edits). A request that names a fenced folder is refused without asking. The decoder moves to codex_decoder.rs and gets the shared daemon to wait for the owner in a task. Checked with the real codex: allowed, the file was made; denied, it was not. --- README.md | 6 +- app/src/features/bots/agentPicker.test.tsx | 4 +- app/src/i18n/en/bots.ts | 2 +- app/src/i18n/es/bots.ts | 2 +- app/src/i18n/pt-BR/bots.ts | 2 +- crates/botloftd/src/agent/agy.rs | 3 +- crates/botloftd/src/agent/claude.rs | 3 +- crates/botloftd/src/agent/codex.rs | 253 +------------ crates/botloftd/src/agent/codex_decoder.rs | 400 +++++++++++++++++++++ crates/botloftd/src/agent/codex_wire.rs | 8 +- crates/botloftd/src/agent/mod.rs | 9 +- crates/botloftd/src/approvals/mod.rs | 27 ++ crates/botloftd/src/chat/mod.rs | 6 +- crates/botloftd/src/supervisor/spawn.rs | 2 + crates/botloftd/tests/codex_bot.rs | 227 ++++++++++-- docs/spec.md | 7 +- 16 files changed, 674 insertions(+), 287 deletions(-) create mode 100644 crates/botloftd/src/agent/codex_decoder.rs diff --git a/README.md b/README.md index 5a2e1fab..3dd1b7af 100644 --- a/README.md +++ b/README.md @@ -90,9 +90,9 @@ itself. Claude Code is the full experience. Antigravity and Codex are off until you list them in Botloft's `config.toml` (`experimental_agents = ["agy"]`, `["codex"]` or both); then a new bot can be made on -them, and Settings has "Agent for new bots". A Codex bot reads files, uses the crew's tools and -answers, with the exact size of its conversation; it cannot change files or run commands yet, and -asking you before it acts is the next step. See [spec section 30](docs/spec.md) for what was +them, and Settings has "Agent for new bots". A Codex bot asks you before it changes a file or runs a +command, like Claude Code, and shows the exact size of its conversation; it can still read any file, +and it has no model picker or images yet. See [spec section 30](docs/spec.md) for what was checked. ## Get started diff --git a/app/src/features/bots/agentPicker.test.tsx b/app/src/features/bots/agentPicker.test.tsx index 5bb6d3b1..c15d00c9 100644 --- a/app/src/features/bots/agentPicker.test.tsx +++ b/app/src/features/bots/agentPicker.test.tsx @@ -42,7 +42,9 @@ describe("Codex in the new-bot dialog", () => { fireEvent.click(button as HTMLElement); const dialog = within(await screen.findByRole("dialog", { name: "New bot" })); fireEvent.change(dialog.getByLabelText("Agent"), { target: { value: "codex" } }); - expect(dialog.getByRole("note").textContent).toMatch(/cannot change files or run commands yet/); + expect(dialog.getByRole("note").textContent).toMatch( + /asks you before it changes a file or runs a command/, + ); expect(dialog.queryByLabelText("Model")).toBeNull(); }); }); diff --git a/app/src/i18n/en/bots.ts b/app/src/i18n/en/bots.ts index 33d6142c..49928aaf 100644 --- a/app/src/i18n/en/bots.ts +++ b/app/src/i18n/en/bots.ts @@ -117,7 +117,7 @@ export const bots = { experimental: "Experimental. This bot cannot ask you before it acts. It can work with files in its own folders and use the crew's tools, but it cannot run commands, and it has no usage meter or model choice.", experimentalCodex: - "Experimental. This bot can read files, use the crew's tools and answer, but it cannot change files or run commands yet. Asking you before it acts is coming next.", + "Experimental. This bot asks you before it changes a file or runs a command, like Claude Code. It can still read any file, and it has no model or image choice yet.", }, color: "Color", swatch: (color: string) => `Color ${color}`, diff --git a/app/src/i18n/es/bots.ts b/app/src/i18n/es/bots.ts index 355e198e..660d7a42 100644 --- a/app/src/i18n/es/bots.ts +++ b/app/src/i18n/es/bots.ts @@ -115,7 +115,7 @@ export const bots: Messages["bots"] = { experimental: "Experimental. Este bot no puede preguntarte antes de actuar. Trabaja con archivos de sus propias carpetas y usa las herramientas del equipo, pero no ejecuta comandos, y no tiene medidor de uso ni elección de modelo.", experimentalCodex: - "Experimental. Este bot lee archivos, usa las herramientas del equipo y responde, pero todavía no cambia archivos ni ejecuta comandos. Preguntarte antes de actuar viene a continuación.", + "Experimental. Este bot te pregunta antes de cambiar un archivo o ejecutar un comando, como Claude Code. Todavía puede leer cualquier archivo, y no tiene elección de modelo ni de imagen aún.", }, color: "Color", swatch: (color: string) => `Color ${color}`, diff --git a/app/src/i18n/pt-BR/bots.ts b/app/src/i18n/pt-BR/bots.ts index 3f7ec2fa..3377ba8b 100644 --- a/app/src/i18n/pt-BR/bots.ts +++ b/app/src/i18n/pt-BR/bots.ts @@ -114,7 +114,7 @@ export const bots: Messages["bots"] = { experimental: "Experimental. Este bot não consegue perguntar a você antes de agir. Ele trabalha com arquivos das próprias pastas e usa as ferramentas da equipe, mas não roda comandos, e não tem medidor de uso nem escolha de modelo.", experimentalCodex: - "Experimental. Este bot lê arquivos, usa as ferramentas da equipe e responde, mas ainda não muda arquivos nem roda comandos. Perguntar a você antes de agir vem a seguir.", + "Experimental. Este bot pergunta a você antes de mudar um arquivo ou rodar um comando, como o Claude Code. Ele ainda pode ler qualquer arquivo, e não tem escolha de modelo nem de imagem ainda.", }, color: "Cor", swatch: (color: string) => `Cor ${color}`, diff --git a/crates/botloftd/src/agent/agy.rs b/crates/botloftd/src/agent/agy.rs index 876b0cff..51e90871 100644 --- a/crates/botloftd/src/agent/agy.rs +++ b/crates/botloftd/src/agent/agy.rs @@ -11,6 +11,7 @@ use std::ffi::OsString; use std::io; use std::path::{Path, PathBuf}; +use std::sync::Arc; use botloft_core::ids::BotId; use botloft_core::protocol::{AgentKind, TokenUsage}; @@ -199,7 +200,7 @@ impl OutputDecoder for AgyDecoder { Some(text) } - fn handle(&mut self, daemon: &Daemon, bot: &BotId, generation: u64, event: &Value) { + fn handle(&mut self, daemon: &Arc, bot: &BotId, generation: u64, event: &Value) { if !daemon.supervisor.is_current(bot, generation) { return; } diff --git a/crates/botloftd/src/agent/claude.rs b/crates/botloftd/src/agent/claude.rs index 28e252a4..a59d2b57 100644 --- a/crates/botloftd/src/agent/claude.rs +++ b/crates/botloftd/src/agent/claude.rs @@ -4,6 +4,7 @@ use std::ffi::OsString; use std::io; use std::path::{Path, PathBuf}; +use std::sync::Arc; use botloft_core::ids::BotId; use botloft_core::protocol::AgentKind; @@ -143,7 +144,7 @@ impl OutputDecoder for ClaudeDecoder { claude::live_text(event) } - fn handle(&mut self, daemon: &Daemon, bot: &BotId, generation: u64, event: &Value) { + fn handle(&mut self, daemon: &Arc, bot: &BotId, generation: u64, event: &Value) { claude::apply(daemon, bot, generation, event); } } diff --git a/crates/botloftd/src/agent/codex.rs b/crates/botloftd/src/agent/codex.rs index 34cc13ec..15322873 100644 --- a/crates/botloftd/src/agent/codex.rs +++ b/crates/botloftd/src/agent/codex.rs @@ -1,7 +1,7 @@ //! OpenAI's Codex as an agent (spec 30), through `codex app-server`: one -//! process per bot, JSON-RPC on stdin and stdout. Experimental. The first -//! slice runs the thread in a read-only sandbox that asks for nothing, so the -//! bot reads and answers; approvals come next (spec 30.1). +//! process per bot, JSON-RPC on stdin and stdout. Experimental. The thread +//! runs in a read-only sandbox; whatever needs more is asked of the owner in +//! the chat (`codex_decoder.rs`). use std::ffi::OsString; use std::io; @@ -9,13 +9,13 @@ use std::path::{Path, PathBuf}; use std::sync::Arc; use botloft_core::ids::BotId; -use botloft_core::protocol::{AgentKind, TokenUsage}; +use botloft_core::protocol::AgentKind; use bytes::Bytes; -use serde_json::{Value, json}; +use serde_json::Value; -use super::codex_wire::{Answer, CodexControl, Shared}; +use super::codex_decoder::CodexDecoder; +use super::codex_wire::{CodexControl, Shared}; use super::{Agent, AttachInput, LaunchFiles, LaunchPlan, OutputDecoder, Turn}; -use crate::chat::sink; use crate::runtime::ProcessControl; use crate::state::Daemon; @@ -114,14 +114,9 @@ impl Agent for CodexAgent { control: Box, input: AttachInput, ) -> (Box, Box) { + let fenced = input.fenced.clone(); let shared = Shared::begin(control, input); - let decoder = CodexDecoder { - shared: Arc::clone(&shared), - thread: None, - window: 0, - base: (0, 0, 0, 0), - usage: (0, 0, 0, 0), - }; + let decoder = CodexDecoder::new(Arc::clone(&shared), fenced); (Box::new(CodexControl { shared }), Box::new(decoder)) } } @@ -133,7 +128,7 @@ impl OutputDecoder for Silent { None } - fn handle(&mut self, _daemon: &Daemon, _bot: &BotId, _generation: u64, _event: &Value) {} + fn handle(&mut self, _daemon: &Arc, _bot: &BotId, _generation: u64, _event: &Value) {} } /// The names of the MCP servers in the owner's `config.toml`, to switch off. @@ -164,231 +159,3 @@ fn owner_mcp_servers() -> Vec { }) .unwrap_or_default() } - -/// Reads what the server prints and tells the daemon. -struct CodexDecoder { - shared: Arc, - thread: Option, - /// The model's context window, from `thread/tokenUsage/updated`. - window: u64, - /// What the thread has used (input, cached, output, reasoning), and what - /// it had used when this turn began. - usage: (u64, u64, u64, u64), - base: (u64, u64, u64, u64), -} - -impl OutputDecoder for CodexDecoder { - fn live_text<'a>(&mut self, event: &'a Value) -> Option<&'a str> { - if event["method"] != "item/agentMessage/delta" { - return None; - } - event["params"]["delta"] - .as_str() - .filter(|text| !text.is_empty()) - } - - fn handle(&mut self, daemon: &Daemon, bot: &BotId, generation: u64, event: &Value) { - if !daemon.supervisor.is_current(bot, generation) { - return; - } - let method = event["method"].as_str(); - match (method, event.get("id")) { - (None, Some(_)) => self.response(daemon, bot, generation, event), - (Some(method), Some(id)) => self.server_request(method, id, &event["params"]), - (Some(method), None) => { - self.notification(daemon, bot, generation, method, &event["params"]) - } - (None, None) => {} - } - } -} - -impl CodexDecoder { - fn response(&mut self, daemon: &Daemon, bot: &BotId, generation: u64, event: &Value) { - match self.shared.answered(event) { - Answer::Thread { thread, model } => { - self.thread = Some(thread); - if let Some(model) = model { - crate::service::models::reported(daemon, bot, &model); - } - } - Answer::TurnRefused(message) => { - // No turn began, so the message is read and the turn failed. - if let Some(uuid) = daemon.supervisor.oldest_message_began(bot, generation) { - sink::message_read(daemon, bot, &uuid); - } - let error = message.unwrap_or_else(|| "the turn was refused".to_owned()); - sink::turn_finished(daemon, bot, 0, None, Some(error)); - sink::finish_turn(daemon, bot, generation, true); - } - Answer::Failed(message) => { - tracing::warn!(bot = %bot, "codex could not start a thread: {message:?}"); - } - Answer::Lost | Answer::None => {} - } - } - - /// Requests the daemon does not take yet (A3a) are declined, except the - /// call of Botloft's own tools, which the bot needs to work in the crew. - fn server_request(&self, method: &str, id: &Value, params: &Value) { - let result = match method { - "mcpServer/elicitation/request" if params["serverName"] == "botloft" => { - json!({ "action": "accept", "content": null }) - } - "mcpServer/elicitation/request" => json!({ "action": "decline", "content": null }), - "item/commandExecution/requestApproval" | "item/fileChange/requestApproval" => { - json!({ "decision": "decline" }) - } - "applyPatchApproval" | "execCommandApproval" => json!({ "decision": "denied" }), - _ => { - self.shared.refuse(id, "not supported by Botloft yet"); - return; - } - }; - self.shared.respond(id, result); - } - - fn notification( - &mut self, - daemon: &Daemon, - bot: &BotId, - generation: u64, - method: &str, - params: &Value, - ) { - match method { - "turn/started" => { - self.base = self.usage; - daemon.supervisor.turn_began(bot, generation); - } - "item/started" => self.item_started(daemon, bot, generation, params), - "item/completed" => self.item_completed(daemon, bot, ¶ms["item"]), - "thread/tokenUsage/updated" => self.token_usage(daemon, bot, ¶ms["tokenUsage"]), - "turn/completed" => self.turn_completed(daemon, bot, generation, ¶ms["turn"]), - _ => {} - } - } - - fn item_started(&mut self, daemon: &Daemon, bot: &BotId, generation: u64, params: &Value) { - let item = ¶ms["item"]; - let id = item["id"].as_str().unwrap_or_default(); - match item["type"].as_str() { - // The message was taken up: the oldest one written. - Some("userMessage") => { - if let Some(thread) = params["threadId"].as_str().or(self.thread.as_deref()) { - sink::session_started(daemon, bot, thread); - } - if let Some(uuid) = daemon.supervisor.oldest_message_began(bot, generation) { - sink::message_read(daemon, bot, &uuid); - } - } - Some("commandExecution") => { - let command = item["commandActions"][0]["command"] - .as_str() - .or_else(|| item["command"].as_str()) - .unwrap_or_default(); - sink::tool_started(daemon, bot, id, "Bash", &json!({ "command": command })); - } - Some("fileChange") => { - let change = &item["changes"][0]; - let name = if change["kind"]["type"] == "add" { - "Write" - } else { - "Edit" - }; - sink::tool_started( - daemon, - bot, - id, - name, - &json!({ "file_path": change["path"] }), - ); - } - Some("mcpToolCall") => { - let name = format!( - "mcp__{}__{}", - item["server"].as_str().unwrap_or("mcp"), - item["tool"].as_str().unwrap_or("tool") - ); - sink::tool_started(daemon, bot, id, &name, &item["arguments"]); - } - _ => {} - } - } - - fn item_completed(&mut self, daemon: &Daemon, bot: &BotId, item: &Value) { - let id = item["id"].as_str().unwrap_or_default(); - let ok = item["status"].as_str() == Some("completed"); - match item["type"].as_str() { - Some("agentMessage") => { - sink::reply(daemon, bot, item["text"].as_str().unwrap_or_default()); - } - Some("commandExecution") => { - let failed = !ok || item["exitCode"].as_i64().is_some_and(|code| code != 0); - let output = item["aggregatedOutput"].as_str().unwrap_or_default(); - sink::tool_finished(daemon, bot, id, failed, output); - } - Some("fileChange") => sink::tool_finished(daemon, bot, id, !ok, ""), - Some("mcpToolCall") => { - let output = item["result"]["content"] - .as_array() - .map(|parts| { - parts - .iter() - .filter_map(|part| part["text"].as_str()) - .collect::>() - .join("\n") - }) - .or_else(|| item["error"]["message"].as_str().map(str::to_owned)) - .unwrap_or_default(); - sink::tool_finished(daemon, bot, id, !ok, &output); - } - _ => {} - } - } - - fn token_usage(&mut self, daemon: &Daemon, bot: &BotId, usage: &Value) { - let count = |value: &Value, key: &str| value[key].as_u64().unwrap_or_default(); - self.window = count(usage, "modelContextWindow"); - let last = &usage["last"]; - self.usage = ( - count(&usage["total"], "inputTokens"), - count(&usage["total"], "cachedInputTokens"), - count(&usage["total"], "outputTokens"), - count(&usage["total"], "reasoningOutputTokens"), - ); - // The last request held the conversation up to that point. - let held = count(last, "inputTokens") + count(last, "outputTokens"); - if held > 0 && self.window > 0 { - crate::context::report(daemon, bot, held, self.window); - } - } - - fn turn_completed(&mut self, daemon: &Daemon, bot: &BotId, generation: u64, turn: &Value) { - let failed = turn["status"].as_str() != Some("completed"); - let error = failed.then(|| { - turn["error"]["message"] - .as_str() - .or_else(|| turn["status"].as_str()) - .unwrap_or("error") - .to_owned() - }); - let (input, cached, output, reasoning) = ( - self.usage.0.saturating_sub(self.base.0), - self.usage.1.saturating_sub(self.base.1), - self.usage.2.saturating_sub(self.base.2), - self.usage.3.saturating_sub(self.base.3), - ); - let tokens = (self.usage != self.base).then(|| TokenUsage { - input: input.saturating_sub(cached), - cache_write: 0, - reloaded: 0, - cache_read: cached, - output: output + reasoning, - }); - let duration = turn["durationMs"].as_u64().unwrap_or_default(); - sink::turn_finished(daemon, bot, duration, tokens, error); - self.shared.turn_done(); - sink::finish_turn(daemon, bot, generation, failed); - } -} diff --git a/crates/botloftd/src/agent/codex_decoder.rs b/crates/botloftd/src/agent/codex_decoder.rs new file mode 100644 index 00000000..a6ee4027 --- /dev/null +++ b/crates/botloftd/src/agent/codex_decoder.rs @@ -0,0 +1,400 @@ +//! What `codex app-server` prints, as the chat, the bot's state and the +//! owner's questions (spec 30). Its requests for approval are answered here: +//! a folder Botloft fences is refused at once, a rule of the bot or the bot's +//! mode may allow, and otherwise the owner is asked in the chat as for +//! Claude Code. + +use std::collections::HashMap; +use std::path::PathBuf; +use std::sync::Arc; + +use botloft_core::ids::BotId; +use botloft_core::protocol::{PermissionMode, TokenUsage}; +use serde_json::{Value, json}; + +use super::OutputDecoder; +use super::codex_wire::{Answer, Shared}; +use crate::approvals::{self, Verdict}; +use crate::chat::sink; +use crate::state::Daemon; + +/// Reads what the server prints and tells the daemon. +pub(super) struct CodexDecoder { + shared: Arc, + fenced: Vec, + thread: Option, + /// The model's context window, from `thread/tokenUsage/updated`. + window: u64, + /// What the thread has used (input, cached, output, reasoning), and what + /// it had used when this turn began. + usage: (u64, u64, u64, u64), + base: (u64, u64, u64, u64), + /// What each running item is about, to answer a request for it: a + /// command, or the files a change touches. + commands: HashMap, + changes: HashMap>, +} + +impl CodexDecoder { + pub(super) fn new(shared: Arc, fenced: Vec) -> Self { + Self { + shared, + fenced, + thread: None, + window: 0, + usage: (0, 0, 0, 0), + base: (0, 0, 0, 0), + commands: HashMap::new(), + changes: HashMap::new(), + } + } +} + +impl OutputDecoder for CodexDecoder { + fn live_text<'a>(&mut self, event: &'a Value) -> Option<&'a str> { + if event["method"] != "item/agentMessage/delta" { + return None; + } + event["params"]["delta"] + .as_str() + .filter(|text| !text.is_empty()) + } + + fn handle(&mut self, daemon: &Arc, bot: &BotId, generation: u64, event: &Value) { + if !daemon.supervisor.is_current(bot, generation) { + return; + } + let method = event["method"].as_str(); + match (method, event.get("id")) { + (None, Some(_)) => self.response(daemon, bot, generation, event), + (Some(method), Some(id)) => { + self.server_request(daemon, bot, generation, method, id, &event["params"]); + } + (Some(method), None) => { + self.notification(daemon, bot, generation, method, &event["params"]); + } + (None, None) => {} + } + } +} + +/// Whether `text` names a folder of `fenced` (slashes and case aside). +fn touches(fenced: &[PathBuf], text: &str) -> bool { + let flat = |value: &str| value.replace('\\', "/").to_lowercase(); + let text = flat(text); + fenced + .iter() + .map(|folder| flat(&folder.to_string_lossy())) + .any(|folder| !folder.is_empty() && text.contains(&folder)) +} + +impl CodexDecoder { + fn response(&mut self, daemon: &Daemon, bot: &BotId, generation: u64, event: &Value) { + match self.shared.answered(event) { + Answer::Thread { thread, model } => { + self.thread = Some(thread); + if let Some(model) = model { + crate::service::models::reported(daemon, bot, &model); + } + } + Answer::TurnRefused(message) => { + // No turn began, so the message is read and the turn failed. + if let Some(uuid) = daemon.supervisor.oldest_message_began(bot, generation) { + sink::message_read(daemon, bot, &uuid); + } + let error = message.unwrap_or_else(|| "the turn was refused".to_owned()); + sink::turn_finished(daemon, bot, 0, None, Some(error)); + sink::finish_turn(daemon, bot, generation, true); + } + Answer::Failed(message) => { + tracing::warn!(bot = %bot, "codex could not start a thread: {message:?}"); + } + Answer::Lost | Answer::None => {} + } + } + + /// A request of the server. Botloft's own tools go through; for the + /// rest, the owner decides (spec 30.3.2). + fn server_request( + &self, + daemon: &Arc, + bot: &BotId, + generation: u64, + method: &str, + id: &Value, + params: &Value, + ) { + let item_id = params["itemId"].as_str().unwrap_or_default().to_owned(); + let (tool, input, reply): (String, Value, fn(bool) -> Value) = match method { + "mcpServer/elicitation/request" if params["serverName"] == "botloft" => { + self.shared + .respond(id, json!({ "action": "accept", "content": null })); + return; + } + "mcpServer/elicitation/request" => { + let server = params["serverName"].as_str().unwrap_or("mcp"); + let tool = params["_meta"]["tool_name"].as_str().unwrap_or("tool"); + ( + format!("mcp__{server}__{tool}"), + params["_meta"]["tool_params"].clone(), + |allow| json!({ "action": if allow { "accept" } else { "decline" }, "content": null }), + ) + } + "item/commandExecution/requestApproval" => { + let command = self + .commands + .get(&item_id) + .cloned() + .or_else(|| params["command"].as_str().map(str::to_owned)) + .unwrap_or_default(); + ( + "Bash".to_owned(), + json!({ "command": command }), + |allow| json!({ "decision": if allow { "accept" } else { "decline" } }), + ) + } + "item/fileChange/requestApproval" => { + let files = self.changes.get(&item_id).cloned().unwrap_or_default(); + let (first, added) = files.first().cloned().unwrap_or_default(); + if files.iter().any(|(path, _)| touches(&self.fenced, path)) { + // Another crew's folder, or Botloft's own: not for the + // owner to be asked about (spec 7.5). + self.shared.respond(id, json!({ "decision": "decline" })); + return; + } + ( + if added { "Write" } else { "Edit" }.to_owned(), + json!({ "file_path": first }), + |allow| json!({ "decision": if allow { "accept" } else { "decline" } }), + ) + } + _ => { + self.shared.refuse(id, "not supported by Botloft yet"); + return; + } + }; + if tool == "Bash" && touches(&self.fenced, input["command"].as_str().unwrap_or_default()) { + self.shared.respond(id, reply(false)); + return; + } + let (daemon, shared, bot, id) = ( + Arc::clone(daemon), + Arc::clone(&self.shared), + bot.clone(), + id.clone(), + ); + // The owner may take a while: the output goes on in the meantime. + tokio::spawn(async move { + let allow = decide(&daemon, &bot, generation, &tool, &input, &item_id).await; + shared.respond(&id, reply(allow)); + }); + } + + fn notification( + &mut self, + daemon: &Daemon, + bot: &BotId, + generation: u64, + method: &str, + params: &Value, + ) { + match method { + "turn/started" => { + self.base = self.usage; + daemon.supervisor.turn_began(bot, generation); + } + "item/started" => self.item_started(daemon, bot, generation, params), + "item/completed" => self.item_completed(daemon, bot, ¶ms["item"]), + "thread/tokenUsage/updated" => self.token_usage(daemon, bot, ¶ms["tokenUsage"]), + "turn/completed" => self.turn_completed(daemon, bot, generation, ¶ms["turn"]), + _ => {} + } + } + + fn item_started(&mut self, daemon: &Daemon, bot: &BotId, generation: u64, params: &Value) { + let item = ¶ms["item"]; + let id = item["id"].as_str().unwrap_or_default(); + match item["type"].as_str() { + // The message was taken up: the oldest one written. + Some("userMessage") => { + if let Some(thread) = params["threadId"].as_str().or(self.thread.as_deref()) { + sink::session_started(daemon, bot, thread); + } + if let Some(uuid) = daemon.supervisor.oldest_message_began(bot, generation) { + sink::message_read(daemon, bot, &uuid); + } + } + Some("commandExecution") => { + let command = item["commandActions"][0]["command"] + .as_str() + .or_else(|| item["command"].as_str()) + .unwrap_or_default(); + self.commands.insert(id.to_owned(), command.to_owned()); + sink::tool_started(daemon, bot, id, "Bash", &json!({ "command": command })); + } + Some("fileChange") => { + let changes = item["changes"].as_array().cloned().unwrap_or_default(); + self.changes.insert( + id.to_owned(), + changes + .iter() + .map(|change| { + ( + change["path"].as_str().unwrap_or_default().to_owned(), + change["kind"]["type"] == "add", + ) + }) + .collect(), + ); + let change = changes.first().cloned().unwrap_or_default(); + let name = if change["kind"]["type"] == "add" { + "Write" + } else { + "Edit" + }; + sink::tool_started( + daemon, + bot, + id, + name, + &json!({ "file_path": change["path"] }), + ); + } + Some("mcpToolCall") => { + let name = format!( + "mcp__{}__{}", + item["server"].as_str().unwrap_or("mcp"), + item["tool"].as_str().unwrap_or("tool") + ); + sink::tool_started(daemon, bot, id, &name, &item["arguments"]); + } + _ => {} + } + } + + fn item_completed(&mut self, daemon: &Daemon, bot: &BotId, item: &Value) { + let id = item["id"].as_str().unwrap_or_default(); + let ok = item["status"].as_str() == Some("completed"); + match item["type"].as_str() { + Some("agentMessage") => { + sink::reply(daemon, bot, item["text"].as_str().unwrap_or_default()); + } + Some("commandExecution") => { + self.commands.remove(id); + let failed = !ok || item["exitCode"].as_i64().is_some_and(|code| code != 0); + let output = item["aggregatedOutput"].as_str().unwrap_or_default(); + sink::tool_finished(daemon, bot, id, failed, output); + } + Some("fileChange") => { + self.changes.remove(id); + sink::tool_finished(daemon, bot, id, !ok, ""); + } + Some("mcpToolCall") => { + let output = item["result"]["content"] + .as_array() + .map(|parts| { + parts + .iter() + .filter_map(|part| part["text"].as_str()) + .collect::>() + .join("\n") + }) + .or_else(|| item["error"]["message"].as_str().map(str::to_owned)) + .unwrap_or_default(); + sink::tool_finished(daemon, bot, id, !ok, &output); + } + _ => {} + } + } + + fn token_usage(&mut self, daemon: &Daemon, bot: &BotId, usage: &Value) { + let count = |value: &Value, key: &str| value[key].as_u64().unwrap_or_default(); + self.window = count(usage, "modelContextWindow"); + self.usage = ( + count(&usage["total"], "inputTokens"), + count(&usage["total"], "cachedInputTokens"), + count(&usage["total"], "outputTokens"), + count(&usage["total"], "reasoningOutputTokens"), + ); + // The last request held the conversation up to that point. + let last = &usage["last"]; + let held = count(last, "inputTokens") + count(last, "outputTokens"); + if held > 0 && self.window > 0 { + crate::context::report(daemon, bot, held, self.window); + } + } + + fn turn_completed(&mut self, daemon: &Daemon, bot: &BotId, generation: u64, turn: &Value) { + let failed = turn["status"].as_str() != Some("completed"); + let error = failed.then(|| { + turn["error"]["message"] + .as_str() + .or_else(|| turn["status"].as_str()) + .unwrap_or("error") + .to_owned() + }); + let used = |now: u64, before: u64| now.saturating_sub(before); + let (input, cached) = ( + used(self.usage.0, self.base.0), + used(self.usage.1, self.base.1), + ); + let output = used(self.usage.2, self.base.2) + used(self.usage.3, self.base.3); + let tokens = (self.usage != self.base).then(|| TokenUsage { + input: input.saturating_sub(cached), + cache_write: 0, + reloaded: 0, + cache_read: cached, + output, + }); + let duration = turn["durationMs"].as_u64().unwrap_or_default(); + sink::turn_finished(daemon, bot, duration, tokens, error); + self.shared.turn_done(); + sink::finish_turn(daemon, bot, generation, failed); + } +} + +/// Whether the request goes ahead: the bot's mode, a rule of the bot, or the +/// owner (spec 10.1). +async fn decide( + daemon: &Daemon, + bot: &BotId, + generation: u64, + tool: &str, + input: &Value, + item_id: &str, +) -> bool { + let mode = daemon + .store() + .bot(bot) + .ok() + .flatten() + .map_or(PermissionMode::Default, |record| record.permission_mode); + let edit = matches!(tool, "Write" | "Edit"); + if mode == PermissionMode::BypassPermissions || (mode == PermissionMode::AcceptEdits && edit) { + return true; + } + matches!( + approvals::agent_request(daemon, bot, generation, tool, input, item_id).await, + Verdict::Allow + ) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn a_text_that_names_a_fenced_folder_is_caught_whatever_the_slashes_and_case() { + let fenced = vec![PathBuf::from(r"C:\Users\Ana\Botloft\Other")]; + assert!(touches( + &fenced, + "type C:/users/ana/botloft/other/notes.txt" + )); + assert!(touches( + &fenced, + r"Get-Content 'C:\Users\Ana\Botloft\Other\a'" + )); + assert!(!touches(&fenced, r"C:\Users\Ana\Botloft\Mine\a.txt")); + assert!(!touches(&[], "anything")); + } +} diff --git a/crates/botloftd/src/agent/codex_wire.rs b/crates/botloftd/src/agent/codex_wire.rs index e8f357c2..3df05291 100644 --- a/crates/botloftd/src/agent/codex_wire.rs +++ b/crates/botloftd/src/agent/codex_wire.rs @@ -101,7 +101,9 @@ impl Shared { fn thread_params(&self) -> Value { let mut params = json!({ "cwd": self.input.workspace, - "approvalPolicy": "never", + // A plan only reads; otherwise whatever needs more than reading + // is asked, and Botloft asks the owner (spec 30). + "approvalPolicy": if self.input.may_ask { "on-request" } else { "never" }, "sandbox": "read-only", "config": { "mcp_servers": { @@ -294,6 +296,8 @@ mod tests { resume: resume.map(str::to_owned), model: Some("gpt-5.5".into()), effort: None, + fenced: Vec::new(), + may_ask: true, } } @@ -326,7 +330,7 @@ mod tests { shared.answered(&json!({ "id": 1, "result": {} })); assert_eq!(methods(), ["initialize", "initialized", "thread/start"]); let start = written.lock().expect("lock")[2].clone(); - assert_eq!(start["params"]["approvalPolicy"], "never"); + assert_eq!(start["params"]["approvalPolicy"], "on-request"); assert_eq!(start["params"]["sandbox"], "read-only"); assert_eq!(start["params"]["model"], "gpt-5.5"); assert_eq!( diff --git a/crates/botloftd/src/agent/mod.rs b/crates/botloftd/src/agent/mod.rs index 3c300296..86f96bc7 100644 --- a/crates/botloftd/src/agent/mod.rs +++ b/crates/botloftd/src/agent/mod.rs @@ -6,11 +6,13 @@ mod agy; mod claude; mod codex; +mod codex_decoder; mod codex_wire; use std::ffi::OsString; use std::io; use std::path::{Path, PathBuf}; +use std::sync::Arc; use botloft_core::ids::BotId; use botloft_core::protocol::{AgentKind, BotEffort, BotModel, PermissionMode}; @@ -81,6 +83,11 @@ pub struct AttachInput { pub resume: Option, pub model: Option, pub effort: Option, + /// Folders its tools must not touch (spec 7.5): a request that does is + /// refused without asking the owner. + pub fenced: Vec, + /// Whether it may ask the owner at all; a plan only reads. + pub may_ask: bool, } /// Reads one process's output, line by line (already JSON). One per process, @@ -94,7 +101,7 @@ pub trait OutputDecoder: Send { /// and tells the supervisor, through `chat::sink` for what every agent has /// and its own code for the rest. Lines from a process that was replaced /// say nothing about the new one. - fn handle(&mut self, daemon: &Daemon, bot: &BotId, generation: u64, event: &Value); + fn handle(&mut self, daemon: &Arc, bot: &BotId, generation: u64, event: &Value); } pub trait Agent: Send + Sync + 'static { diff --git a/crates/botloftd/src/approvals/mod.rs b/crates/botloftd/src/approvals/mod.rs index f1523893..eb297eb5 100644 --- a/crates/botloftd/src/approvals/mod.rs +++ b/crates/botloftd/src/approvals/mod.rs @@ -72,6 +72,33 @@ impl Approvals { } } +/// How a request of an agent that asks in its own protocol ended (spec 30). +pub(crate) enum Verdict { + Allow, + /// Denied, or never answered; the agent is told no. + Deny, +} + +/// A request of an agent for the owner: a rule of the bot may already allow +/// it, and otherwise the owner is asked in the chat like for Claude Code. +/// `tool_use_id` is the id of the item the agent shows for it. +pub(crate) async fn agent_request( + daemon: &Daemon, + bot: &BotId, + generation: u64, + tool_name: &str, + input: &Value, + tool_use_id: &str, +) -> Verdict { + if always::allowed(daemon, bot, tool_name, input) { + return Verdict::Allow; + } + match ask(daemon, bot, generation, tool_name, input, tool_use_id).await { + Some(Answer::Allowed { .. }) => Verdict::Allow, + _ => Verdict::Deny, + } +} + /// Puts a request in the bot's chat and waits for the owner. `None` if it /// could not be saved. pub(crate) async fn ask( diff --git a/crates/botloftd/src/chat/mod.rs b/crates/botloftd/src/chat/mod.rs index 2389993d..4e209451 100644 --- a/crates/botloftd/src/chat/mod.rs +++ b/crates/botloftd/src/chat/mod.rs @@ -9,6 +9,8 @@ pub(crate) mod items; mod session; pub(crate) mod sink; +use std::sync::Arc; + use botloft_core::ids::BotId; use botloft_core::protocol::ChatDelta; use tracing::debug; @@ -75,7 +77,7 @@ impl StreamReader { } } - pub fn feed(&mut self, daemon: &Daemon, data: &[u8]) { + pub fn feed(&mut self, daemon: &Arc, data: &[u8]) { let mut rest = data; while let Some(end) = rest.iter().position(|byte| *byte == b'\n') { if self.skipping { @@ -98,7 +100,7 @@ impl StreamReader { } } - fn line(&mut self, daemon: &Daemon) { + fn line(&mut self, daemon: &Arc) { let line = self.buffer.strip_suffix(b"\r").unwrap_or(&self.buffer); if line.iter().all(u8::is_ascii_whitespace) { return; diff --git a/crates/botloftd/src/supervisor/spawn.rs b/crates/botloftd/src/supervisor/spawn.rs index e7770b12..6cc94c3d 100644 --- a/crates/botloftd/src/supervisor/spawn.rs +++ b/crates/botloftd/src/supervisor/spawn.rs @@ -184,6 +184,8 @@ pub(super) fn launch_spec( resume: resumed.then(|| session.clone()), model: bot.agent_model.clone(), effort: bot.effort.cli_value().map(str::to_owned), + fenced: fenced.clone(), + may_ask: bot.permission_mode != botloft_core::protocol::PermissionMode::Plan, }, token_hash: TokenHash::of(&token).to_hex(), resumed, diff --git a/crates/botloftd/tests/codex_bot.rs b/crates/botloftd/tests/codex_bot.rs index 3d127ab5..639c2d42 100644 --- a/crates/botloftd/tests/codex_bot.rs +++ b/crates/botloftd/tests/codex_bot.rs @@ -163,7 +163,7 @@ async fn a_codex_bot_starts_its_server_with_the_owners_things_off_and_its_own_mc handshake(&process, "th-1").await; let start = sent(&process, "thread/start").await; - assert_eq!(start["params"]["approvalPolicy"], "never"); + assert_eq!(start["params"]["approvalPolicy"], "on-request"); assert_eq!(start["params"]["sandbox"], "read-only"); let server = &start["params"]["config"]["mcp_servers"]["botloft"]; assert_eq!(server["url"], "http://127.0.0.1:45710/mcp"); @@ -326,48 +326,221 @@ async fn what_codex_prints_becomes_the_chat() { assert_eq!(bot.model_in_use.as_deref(), Some("gpt-5.5")); } +/// The approval card waiting in the chat, once it is there. +async fn card(codex: &Codex) -> botloft_core::protocol::ApprovalItem { + for _ in 0..600 { + let found = codex.items().into_iter().find_map(|body| match body { + ChatBody::Approval(item) + if item.status == botloft_core::protocol::ApprovalStatus::Pending => + { + Some(item) + } + _ => None, + }); + if let Some(item) = found { + return item; + } + tokio::time::sleep(Duration::from_millis(10)).await; + } + panic!("no approval card"); +} + +fn answer_of(process: &FakeProcess, id: u64) -> Option { + process + .input_lines() + .into_iter() + .find(|line| line["id"] == id) +} + +async fn answer_for(process: &FakeProcess, id: u64) -> Value { + for _ in 0..600 { + if let Some(line) = answer_of(process, id) { + return line; + } + tokio::time::sleep(Duration::from_millis(10)).await; + } + panic!("no answer to {id}"); +} + +fn file_started( + process: &FakeProcess, + id: &str, + path: &str, +) -> impl std::future::Future { + process.emit(notify( + "item/started", + json!({ "threadId": "th-1", "item": { "type": "fileChange", "id": id, + "changes": [{ "path": path, "kind": { "type": "add" }, "diff": "x\n" }], "status": "inProgress" } }), + )) +} + +fn owner(codex: &Codex, approval: &botloft_core::protocol::ApprovalItem, allow: bool) { + botloftd::approvals::answer( + &codex.daemon, + botloft_core::protocol::ApprovalsAnswerParams { + approval_id: approval.approval_id.clone(), + allow, + note: None, + input: None, + always: None, + }, + ) + .expect("answered"); +} + #[tokio::test(start_paused = true)] -async fn requests_for_approval_are_declined_for_now_and_the_crew_tools_are_allowed() { +async fn a_change_waits_for_the_owner_who_allows_or_denies_it() { let codex = codex_bot().await; let process = codex.runtime.process(1).await; handshake(&process, "th-1").await; codex.until(BotState::Idle).await; + file_started(&process, "f1", r"C:\work\a.txt").await; process .emit(json!({ "id": 90, "method": "item/fileChange/requestApproval", "params": { "itemId": "f1" } })) .await; + let shown = card(&codex).await; + assert_eq!(shown.tool_name, "Write"); + assert!(shown.input.contains("a.txt")); + // Nothing is answered while the owner has not. + assert!(answer_of(&process, 90).is_none()); + owner(&codex, &shown, true); + assert_eq!( + answer_for(&process, 90).await["result"]["decision"], + "accept" + ); + + file_started(&process, "f2", r"C:\work\b.txt").await; + process + .emit(json!({ "id": 91, "method": "item/fileChange/requestApproval", "params": { "itemId": "f2" } })) + .await; + let second = loop { + let shown = card(&codex).await; + if shown.input.contains("b.txt") { + break shown; + } + tokio::time::sleep(Duration::from_millis(10)).await; + }; + owner(&codex, &second, false); + assert_eq!( + answer_for(&process, 91).await["result"]["decision"], + "decline" + ); +} + +#[tokio::test(start_paused = true)] +async fn a_command_asks_and_the_crew_tools_do_not() { + let codex = codex_bot().await; + let process = codex.runtime.process(1).await; + handshake(&process, "th-1").await; + codex.until(BotState::Idle).await; + process - .emit(json!({ "id": 91, "method": "mcpServer/elicitation/request", "params": { "serverName": "botloft", "threadId": "th-1" } })) + .emit(notify("item/started", json!({ "threadId": "th-1", "item": { + "type": "commandExecution", "id": "c1", "command": "powershell -Command 'rm x'", + "commandActions": [{ "type": "unknown", "command": "rm x" }], "status": "inProgress" } }))) .await; process - .emit(json!({ "id": 92, "method": "mcpServer/elicitation/request", "params": { "serverName": "other", "threadId": "th-1" } })) + .emit(json!({ "id": 80, "method": "item/commandExecution/requestApproval", "params": { "itemId": "c1", "command": "rm x" } })) .await; + let shown = card(&codex).await; + assert_eq!(shown.tool_name, "Bash"); + assert!(shown.input.contains("rm x")); + owner(&codex, &shown, true); + assert_eq!( + answer_for(&process, 80).await["result"]["decision"], + "accept" + ); + + // Botloft's own tools go through; another server's tool asks. process - .emit(json!({ "id": 93, "method": "item/tool/call", "params": {} })) + .emit(json!({ "id": 81, "method": "mcpServer/elicitation/request", "params": { "serverName": "botloft", "threadId": "th-1" } })) .await; - for _ in 0..200 { - if process - .input_lines() + assert_eq!(answer_for(&process, 81).await["result"]["action"], "accept"); + // What the server asks that Botloft does not take gets an error. + process + .emit(json!({ "id": 82, "method": "item/tool/call", "params": {} })) + .await; + assert!(answer_for(&process, 82).await["error"].is_object()); +} + +#[tokio::test(start_paused = true)] +async fn a_folder_botloft_fences_is_refused_without_asking() { + let codex = codex_bot().await; + // Another crew's folder. + let other = crews::create( + &codex.daemon, + CrewsCreateParams { + name: "Other".into(), + work_folder: None, + lead: None, + }, + ) + .expect("crew"); + let theirs = codex.daemon.paths.crew_dir(&other.slug).join("notes.txt"); + let process = codex.runtime.process(1).await; + handshake(&process, "th-1").await; + codex.until(BotState::Idle).await; + + file_started(&process, "f9", &theirs.to_string_lossy()).await; + process + .emit(json!({ "id": 70, "method": "item/fileChange/requestApproval", "params": { "itemId": "f9" } })) + .await; + assert_eq!( + answer_for(&process, 70).await["result"]["decision"], + "decline" + ); + + // A command that names it too. + process + .emit( + json!({ "id": 71, "method": "item/commandExecution/requestApproval", "params": { + "itemId": "c9", "command": format!("type {}", theirs.to_string_lossy()) } }), + ) + .await; + assert_eq!( + answer_for(&process, 71).await["result"]["decision"], + "decline" + ); + // No card was made for either. + assert!( + !codex + .items() .iter() - .filter(|l| l["id"].as_u64() >= Some(90)) - .count() - == 4 - { - break; - } - tokio::time::sleep(Duration::from_millis(10)).await; - } - let answer = |id: u64| -> Value { - process - .input_lines() - .into_iter() - .find(|line| line["id"] == id) - .unwrap_or_else(|| panic!("no answer to {id}")) - }; - assert_eq!(answer(90)["result"]["decision"], "decline"); - assert_eq!(answer(91)["result"]["action"], "accept"); - assert_eq!(answer(92)["result"]["action"], "decline"); - assert!(answer(93)["error"].is_object()); + .any(|body| matches!(body, ChatBody::Approval(_))) + ); +} + +#[tokio::test(start_paused = true)] +async fn the_bots_mode_decides_what_is_not_asked() { + use botloft_core::protocol::{BotsSetPermissionModeParams, PermissionMode}; + let codex = codex_bot().await; + botloftd::service::modes::set_permission_mode( + &codex.daemon, + BotsSetPermissionModeParams { + bot_id: codex.bot.clone(), + mode: PermissionMode::AcceptEdits, + }, + ) + .expect("mode"); + let process = codex.runtime.process(1).await; + handshake(&process, "th-1").await; + codex.until(BotState::Idle).await; + + file_started(&process, "f1", r"C:\work\a.txt").await; + process + .emit(json!({ "id": 60, "method": "item/fileChange/requestApproval", "params": { "itemId": "f1" } })) + .await; + assert_eq!( + answer_for(&process, 60).await["result"]["decision"], + "accept" + ); + // A command still asks. + process + .emit(json!({ "id": 61, "method": "item/commandExecution/requestApproval", "params": { "itemId": "c1", "command": "rm x" } })) + .await; + let _ = card(&codex).await; + assert!(answer_of(&process, 61).is_none()); } #[tokio::test(start_paused = true)] diff --git a/docs/spec.md b/docs/spec.md index ba5819fd..f19d1668 100644 --- a/docs/spec.md +++ b/docs/spec.md @@ -2651,7 +2651,7 @@ Decisão e contexto: `docs/adr/0003-agentes.md`. Um bot tem um **agente**, `Bot. | **A1** | A interface `Agent` no daemon (`crates/botloftd/src/agent`), com o Claude por trás e os testes de hoje sem mudança. Em fatias: **A1a** argumentos do processo, ambiente extra e codificação do turno no stdin (feito); **A1b** leitura da saída: cada agente decodifica o próprio fluxo (`Agent::handle_output`, `Agent::live_text`) e grava no chat por `chat::sink` (resposta, ferramenta que começa e termina, fim de turno), o que todo agente tem; o que é só do Claude (contexto, compactação, parte do plano, rascunho de telas) fica no decodificador dele, `chat/claude.rs` (feito); **A1c** descoberta e login, arquivos da pasta do bot e aprovações | em andamento | | **A2** | AGY, **experimental**: só existe quando `experimental_agents = ["agy"]` no `config.toml`. Lançamento, entrada e saída, MCP do Botloft, isolamento por regras `deny`, retomada de conversa, no `agy` real. Sem aprovações do dono e sem comandos de shell (30.3); sem modelo e esforço escolhidos, contexto, compactação nem ferramentas conectadas do dono | feito (sem seletor no app) | | **A3a** | Codex, **experimental** (`experimental_agents = ["codex"]`): um `codex app-server` por bot, a conversa por JSON-RPC, texto ao vivo, ferramentas, MCP do Botloft por conversa, contexto exato, retomada. Sandbox `read-only` e `approvalPolicy: never`: o bot lê e responde, não muda arquivos nem roda comandos que escrevam | feito | -| **A3b** | Codex: os pedidos de aprovação viram os cartões do Botloft (com "sempre permitir"); alterações em pastas protegidas são recusadas sozinhas | a fazer | +| **A3b** | Codex: `approvalPolicy: on-request` (`never` num bot em modo plano). Os pedidos de aprovação de comando e de alteração de arquivo viram os cartões do Botloft, com "sempre permitir" e as regras do bot, como no Claude; o modo do bot vale (`bypass` e `accept_edits` para edições); um pedido que nomeia uma pasta cercada é recusado sem perguntar | feito | | **A3c** | Codex: lista de modelos e esforço, uso do plano, imagens | a fazer | | **A5** | **Lacunas do AGY**: comandos liberados por bot, bot AGY sobe sem o Claude Code, retomada conferida com o `agy` real (30.3, 30.4) | feito | | **A4** | Seletor de agente no app (**feito**: na criação do bot, aparece quando há mais de um agente em `enabledAgents`, com o aviso de experimental e sem a escolha de modelo; o medidor de contexto, o esforço e o modelo do campo de mensagem só aparecem para bots Claude). **Falta:** conta e login de cada agente, paridade de rotinas, navegador, desktop e perguntas | em andamento | @@ -2683,9 +2683,10 @@ Descoberta e saúde (binário, versão mínima, login, comando de entrar); lanç - **Processo:** `codex app-server -c features.apps=false -c features.hooks=false -c features.plugins=false -c notify=[] -c mcp_servers..enabled=false` (um para cada MCP do `config.toml` do dono), na `CODEX_HOME` do dono: o login vale sem copiar o token. O invólucro `CodexControl` (`agent/codex_wire.rs`) recebe a linha neutra do Botloft e a vira `turn/start` quando a thread existe e nenhum turno roda; as mensagens esperam em fila. - **Aperto de mão:** `initialize`, `initialized`, `thread/start` (ou `thread/resume` com o id guardado; se não existir, `thread/start`). A thread leva `cwd` (a pasta do bot), `approvalPolicy: "never"`, `sandbox: "read-only"`, `model` (`Bot.agentModel`) e o servidor MCP `botloft` com o token deste processo em `http_headers`. - **Eventos:** `item/started` `userMessage` é o recibo de leitura da mensagem mais antiga escrita e grava a thread a retomar; `item/agentMessage/delta` é o texto ao vivo e `item/completed` `agentMessage` fecha a resposta; `commandExecution`, `fileChange` e `mcpToolCall` aparecem como `Bash`, `Write`/`Edit` e `mcp____`; `thread/tokenUsage/updated` dá o contexto (a última requisição e `modelContextWindow`) e os tokens do turno (o total da thread menos o que era no começo); `turn/completed` fecha o turno com `durationMs` e o erro. -- **Pedidos do servidor:** `mcpServer/elicitation/request` do servidor `botloft` é aceito (o bot precisa das ferramentas da equipe), de outro servidor é recusado; pedidos de aprovação de comando e de arquivo são recusados (A3b os leva ao dono); o resto recebe um erro. +- **Pedidos do servidor:** `mcpServer/elicitation/request` do servidor `botloft` é aceito (o bot precisa das ferramentas da equipe). `item/commandExecution/requestApproval` e `item/fileChange/requestApproval` viram um cartão com o vocabulário do Claude (`Bash` com o comando, `Write` ou `Edit` com o caminho que o `item/started` do mesmo `itemId` trouxe), passam pelas regras "sempre permitir" do bot e pelo modo dele, e a resposta é `accept` ou `decline`. A tool de outro servidor MCP pede do mesmo jeito. Um pedido cujo caminho ou comando contém uma pasta cercada (a lista da seção 7.5; barras e caixa não importam) é recusado sem cartão. O resto recebe um erro. - **Verificado com o Codex real** (0.148.0-alpha.9, daemon de desenvolvimento): o bot soube o nome e a equipe pelo `AGENTS.md`, listou as ferramentas do `botloft`, chamou `crew_roster`, e a tentativa de criar um arquivo falhou como `Bash` falho. -- **Limites desta fatia:** não escreve arquivos nem roda comandos que escrevam; a leitura não é limitada (`read-only` lê tudo); o `AGENTS.md` global e as skills da pasta pessoal do dono continuam visíveis para ele; sem seletor de modelo (usa o padrão do Codex) e sem imagens (a mensagem lista o caminho); o aviso do app diz o que ele ainda não faz. +- **Verificado com o Codex real:** um comando que escreve um arquivo chegou como cartão `Bash` (estado `needs_approval`); permitido, o arquivo foi criado; negado, o bot disse que a permissão foi negada e nada foi criado. +- **Limites:** a leitura não é limitada (`read-only` lê tudo, e um comando só de leitura roda sem perguntar, então uma pasta cercada não é protegida contra leitura por comando); o `AGENTS.md` global e as skills da pasta pessoal do dono continuam visíveis; sem seletor de modelo (usa o padrão do Codex) e sem imagens (a mensagem lista o caminho); o aviso do app diz isso. ### 30.3.1 Isolamento dos agentes