diff --git a/.github/workflows/codex-lab-release.yml b/.github/workflows/codex-lab-release.yml new file mode 100644 index 0000000000..2cf1c8362f --- /dev/null +++ b/.github/workflows/codex-lab-release.yml @@ -0,0 +1,173 @@ +name: Buzz Codex Lab release + +on: + workflow_dispatch: + inputs: + version: + description: Semantic version for the Codex Lab build + required: true + type: string + +concurrency: + group: buzz-codex-lab-release + cancel-in-progress: false + +permissions: + contents: write + +jobs: + release-windows: + name: Build and publish Windows updater + if: github.repository == 'chemyibinjiang/buzz' + runs-on: windows-latest + timeout-minutes: 120 + env: + TARGET: x86_64-pc-windows-msvc + BUZZ_UPDATER_ENDPOINT: https://github.com/chemyibinjiang/buzz/releases/download/buzz-codex-lab-latest/latest.json + steps: + - name: Require integration branch + shell: bash + run: | + if [[ "$GITHUB_REF" != "refs/heads/Lin/develop" ]]; then + echo "::error::Codex Lab releases must run from Lin/develop; got $GITHUB_REF" + exit 1 + fi + + - name: Validate version + shell: bash + env: + VERSION: ${{ inputs.version }} + run: | + if ! [[ "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?(\+[0-9A-Za-z.-]+)?$ ]]; then + echo "::error::Version is not semantic: $VERSION" + exit 1 + fi + + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + with: + fetch-depth: 0 + + - uses: dtolnay/rust-toolchain@e081816240890017053eacbb1bdf337761dc5582 # 1.95.0 + with: + targets: ${{ env.TARGET }} + + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 + with: + node-version: 24.14.1 + package-manager-cache: false + + - uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4.3.0 + with: + version: 11.4.0 + + - name: Install dependencies + shell: bash + run: pnpm install --frozen-lockfile + + - name: Build signed Codex Lab installer + shell: pwsh + env: + BUZZ_UPDATER_PUBLIC_KEY: ${{ secrets.CODEX_LAB_UPDATER_PUBLIC_KEY }} + TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.CODEX_LAB_UPDATER_PRIVATE_KEY }} + TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.CODEX_LAB_UPDATER_PRIVATE_KEY_PASSWORD }} + run: | + .\scripts\build-codex-lab-windows.ps1 ` + -EnableUpdater ` + -VersionOverride '${{ inputs.version }}' ` + -SkipDependencyInstall + + - name: Resolve release artifacts + id: artifacts + shell: pwsh + env: + VERSION: ${{ inputs.version }} + run: | + $info = Get-Content -LiteralPath 'dist\codex-lab-windows\BUILD-INFO.json' -Raw -Encoding UTF8 | + ConvertFrom-Json + if ($info.version -ne $env:VERSION -or -not $info.updater.enabled) { + throw 'Build metadata does not describe the requested updater release.' + } + $installer = Join-Path 'dist\codex-lab-windows' $info.installer.name + $signature = "$installer.sig" + if (-not (Test-Path -LiteralPath $installer -PathType Leaf) -or + -not (Test-Path -LiteralPath $signature -PathType Leaf)) { + throw 'Signed updater artifacts are missing.' + } + "installer=$installer" >> $env:GITHUB_OUTPUT + "installer_name=$($info.installer.name)" >> $env:GITHUB_OUTPUT + "signature=$signature" >> $env:GITHUB_OUTPUT + + - name: Verify updater signature + shell: pwsh + env: + BUZZ_UPDATER_PUBLIC_KEY: ${{ secrets.CODEX_LAB_UPDATER_PUBLIC_KEY }} + run: | + .\scripts\verify-codex-lab-updater.ps1 ` + -InstallerPath '${{ steps.artifacts.outputs.installer }}' ` + -SignaturePath '${{ steps.artifacts.outputs.signature }}' ` + -PublicKey $env:BUZZ_UPDATER_PUBLIC_KEY + + - name: Generate latest.json + shell: bash + env: + VERSION: ${{ inputs.version }} + INSTALLER_NAME: ${{ steps.artifacts.outputs.installer_name }} + SIGNATURE: ${{ steps.artifacts.outputs.signature }} + run: | + VERSION_TAG="buzz-codex-lab-v${VERSION}" + ASSET_URL="https://github.com/chemyibinjiang/buzz/releases/download/${VERSION_TAG}/${INSTALLER_NAME}" + node scripts/generate-codex-lab-latest.mjs \ + --version "$VERSION" \ + --signature-file "$SIGNATURE" \ + --url "$ASSET_URL" \ + --output dist/codex-lab-windows/latest.json + + - name: Publish immutable version release + shell: bash + env: + GH_TOKEN: ${{ github.token }} + VERSION: ${{ inputs.version }} + INSTALLER: ${{ steps.artifacts.outputs.installer }} + SIGNATURE: ${{ steps.artifacts.outputs.signature }} + run: | + VERSION_TAG="buzz-codex-lab-v${VERSION}" + if gh release view "$VERSION_TAG" >/dev/null 2>&1; then + echo "::error::Release already exists: $VERSION_TAG" + exit 1 + fi + gh release create "$VERSION_TAG" \ + "$INSTALLER" \ + "$SIGNATURE" \ + --target "$GITHUB_SHA" \ + --title "Buzz Codex Lab v${VERSION}" \ + --notes "Signed Buzz Codex Lab Windows updater built from ${GITHUB_SHA}." \ + --prerelease + + - name: Publish stable updater manifest + shell: bash + env: + GH_TOKEN: ${{ github.token }} + run: | + if ! gh release view buzz-codex-lab-latest >/dev/null 2>&1; then + gh release create buzz-codex-lab-latest \ + --target "$GITHUB_SHA" \ + --title "Buzz Codex Lab updater channel" \ + --notes "Mutable manifest consumed by Buzz Codex Lab clients." \ + --prerelease + fi + gh release upload buzz-codex-lab-latest \ + dist/codex-lab-windows/latest.json \ + --clobber + + - name: Upload workflow artifact + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + with: + name: buzz-codex-lab-${{ inputs.version }} + path: | + ${{ steps.artifacts.outputs.installer }} + ${{ steps.artifacts.outputs.signature }} + dist/codex-lab-windows/latest.json + dist/codex-lab-windows/BUILD-INFO.json + dist/codex-lab-windows/SHA256SUMS.txt + if-no-files-found: error + retention-days: 30 diff --git a/Cargo.lock b/Cargo.lock index 6c46beedf2..88d0d0cac4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -835,7 +835,9 @@ dependencies = [ "buzz-sdk", "chrono", "clap", + "dirs", "evalexpr", + "fs2", "futures-util", "hex", "httparse", @@ -846,6 +848,7 @@ dependencies = [ "serde", "serde_json", "sha2 0.11.0", + "tempfile", "thiserror 2.0.18", "tokio", "tokio-tungstenite 0.29.0", @@ -984,6 +987,8 @@ dependencies = [ "diffy", "dirs", "hex", + "image", + "imagesize", "infer", "nostr 0.44.7", "rand 0.10.1", @@ -3032,6 +3037,16 @@ dependencies = [ "percent-encoding", ] +[[package]] +name = "fs2" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9564fc758e15025b46aa6643b1b77d047d1a56a1aea6e01002ac0c7026876213" +dependencies = [ + "libc", + "winapi", +] + [[package]] name = "fs_extra" version = "1.3.0" diff --git a/crates/buzz-acp/Cargo.toml b/crates/buzz-acp/Cargo.toml index d047849806..e2c093fb21 100644 --- a/crates/buzz-acp/Cargo.toml +++ b/crates/buzz-acp/Cargo.toml @@ -25,7 +25,7 @@ buzz-persona = { path = "../buzz-persona" } nostr = { workspace = true } # Async runtime -tokio = { workspace = true } +tokio = { workspace = true, features = ["io-std"] } # WebSocket tokio-tungstenite = { workspace = true } @@ -36,7 +36,7 @@ rustls = { version = "0.23", default-features = false, features = ["ring", "std" futures-util = { workspace = true } # HTTP (channel discovery REST API) -reqwest = { workspace = true } +reqwest = { workspace = true, features = ["stream"] } # Serialization serde = { workspace = true } @@ -68,6 +68,11 @@ clap = { version = "4", features = ["derive", "env"] } # Config file toml = "1.0" +# Durable ACP session binding store location +dirs = "6" +# Cross-process flock for shared session bindings +fs2 = "0.4" + # Filter expressions evalexpr = { workspace = true } @@ -78,4 +83,5 @@ nix = { version = "0.31", default-features = false, features = ["signal"] } [dev-dependencies] tokio = { workspace = true, features = ["test-util"] } +tempfile = "3" httparse = "1" diff --git a/crates/buzz-acp/src/acp.rs b/crates/buzz-acp/src/acp.rs index f04b8eeec0..21d6a07863 100644 --- a/crates/buzz-acp/src/acp.rs +++ b/crates/buzz-acp/src/acp.rs @@ -134,6 +134,19 @@ fn build_initialize_params() -> serde_json::Value { }) } +fn codex_final_answer_text(update: &serde_json::Value) -> Option<&str> { + (update + .pointer("/_meta/codex/phase") + .and_then(|v| v.as_str()) + == Some("final_answer")) + .then(|| update.pointer("/content/text").and_then(|v| v.as_str())) + .flatten() +} + +fn contains_buzz_standing_context(text: &str) -> bool { + text.contains("[Base]") && text.contains("buzz-acp harness routes channel events") +} + /// ACP client that owns an agent subprocess and communicates over its stdio. /// /// One `AcpClient` per agent process. Multiple sessions can be created on the @@ -200,6 +213,10 @@ pub struct AcpClient { /// a JSON-RPC *success*, not `-32601` — which the main loop would read as /// a delivered steer and drop the user's message from the queue. steering_supported: bool, + /// Whether the adapter advertised ACP `session/fork` at initialization. + fork_session_supported: bool, + /// Whether the adapter accepts ACP image content blocks in prompts. + image_prompt_supported: bool, /// Per-turn channel for receiving goose-native non-cancelling steer /// requests from the main loop. Installed by /// [`install_steer_rx`](Self::install_steer_rx) at dispatch and @@ -210,6 +227,18 @@ pub struct AcpClient { steer_rx: Option>, /// Usage tracker for goose/buzz-agent's cumulative notification format. goose_usage: UsageTracker, + /// Final-answer text emitted by codex-acp during the current prompt. + /// + /// Task-bound shared-runtime agents hand this text back to the harness for + /// signed Buzz delivery, so the shared Codex process never needs an + /// identity-specific private key. + turn_final_answer: String, + /// Visible assistant text accumulated during the current prompt turn. + turn_output_text: String, + /// True only while `session/load` history notifications are being drained. + session_load_in_progress: bool, + /// Set when that replay contains Buzz's standing-context marker. + session_load_saw_buzz_standing_context: bool, /// Per-turn prompt-response usage and Claude's optional cumulative cost. standard_usage: StandardUsageTracker, /// Known adapter identity for prompt-response usage mapping. @@ -559,8 +588,14 @@ impl AcpClient { observer_context: ObserverContext::default(), active_run_id: None, steering_supported: false, + fork_session_supported: false, + image_prompt_supported: false, steer_rx: None, goose_usage: UsageTracker::default(), + turn_final_answer: String::new(), + turn_output_text: String::new(), + session_load_in_progress: false, + session_load_saw_buzz_standing_context: false, standard_usage: StandardUsageTracker::default(), standard_adapter, }) @@ -617,6 +652,8 @@ impl AcpClient { .pointer("/_meta/steering/supported") .and_then(|v| v.as_bool()) .unwrap_or(false); + self.fork_session_supported = Self::agent_supports_fork_session(&result); + self.image_prompt_supported = Self::agent_supports_image_prompt(&result); tracing::debug!(target: "acp::init", "initialize response: {result}"); Ok(result) } @@ -682,6 +719,7 @@ impl AcpClient { Ok(SessionNewResponse { session_id, raw: result, + loaded_buzz_standing_context: false, }) } @@ -702,6 +740,113 @@ impl AcpClient { .session_id) } + /// Send `session/load` for an existing ACP session id. + /// + /// Used after harness restart when a durable channel→session binding is + /// known and the agent advertised `agentCapabilities.loadSession`. + /// History-replay `session/update` notifications are consumed by the + /// request loop without entering the observer feed, so relay observers do + /// not republish the loaded transcript. + pub async fn session_load_full( + &mut self, + cwd: &str, + session_id: &str, + mcp_servers: Vec, + ) -> Result { + let params = serde_json::json!({ + "cwd": cwd, + "sessionId": session_id, + "mcpServers": mcp_servers, + }); + self.session_load_in_progress = true; + self.session_load_saw_buzz_standing_context = false; + let result = self + .send_request_with_session_update_observer("session/load", params, false) + .await; + self.session_load_in_progress = false; + let result = result?; + let loaded_buzz_standing_context = self.session_load_saw_buzz_standing_context; + // Spec-compliant agents may omit sessionId on load (it is implied). + // Prefer the request id so callers always have a concrete binding. + let resolved_id = result + .get("sessionId") + .and_then(|v| v.as_str()) + .unwrap_or(session_id) + .to_owned(); + tracing::info!(target: "acp::session", "session loaded: {resolved_id}"); + Ok(SessionNewResponse { + session_id: resolved_id, + raw: result, + loaded_buzz_standing_context, + }) + } + + /// Send `session/fork` to branch a stored session into a new session ID. + pub async fn session_fork_full( + &mut self, + cwd: &str, + session_id: &str, + mcp_servers: Vec, + ) -> Result { + let params = serde_json::json!({ + "cwd": cwd, + "sessionId": session_id, + "mcpServers": mcp_servers, + }); + let result = self.send_request("session/fork", params).await?; + let forked_id = result["sessionId"] + .as_str() + .ok_or_else(|| AcpError::Protocol("session/fork response missing sessionId".into()))? + .to_owned(); + tracing::info!( + target: "acp::session", + source_session_id = session_id, + "session forked: {forked_id}" + ); + Ok(SessionNewResponse { + session_id: forked_id, + raw: result, + loaded_buzz_standing_context: false, + }) + } + + /// Returns true when an initialize result advertises `loadSession`. + pub fn agent_supports_load_session(init_result: &serde_json::Value) -> bool { + init_result + .get("agentCapabilities") + .and_then(|caps| caps.get("loadSession")) + .and_then(|v| v.as_bool()) + .unwrap_or(false) + } + + /// Returns true when an initialize result advertises `session/fork`. + pub fn agent_supports_fork_session(init_result: &serde_json::Value) -> bool { + init_result + .pointer("/agentCapabilities/sessionCapabilities/fork") + .is_some_and(|value| !value.is_null()) + } + + /// Returns true when an initialize result advertises image prompt support. + pub fn agent_supports_image_prompt(init_result: &serde_json::Value) -> bool { + init_result + .pointer("/agentCapabilities/promptCapabilities/image") + .and_then(serde_json::Value::as_bool) + .unwrap_or(false) + } + + pub fn fork_session_supported(&self) -> bool { + self.fork_session_supported + } + + pub fn image_prompt_supported(&self) -> bool { + self.image_prompt_supported + } + + pub fn take_turn_final_answer(&mut self) -> Option { + let answer = std::mem::take(&mut self.turn_final_answer); + (!answer.trim().is_empty()).then_some(answer) + } + /// Send Goose's custom system-prompt request after `session/new`. pub async fn session_set_goose_system_prompt( &mut self, @@ -781,7 +926,30 @@ impl AcpClient { idle_timeout: std::time::Duration, max_duration: std::time::Duration, ) -> Result { - let params = build_prompt_params(session_id, prompt_blocks); + let content = prompt_blocks + .iter() + .map(|text| serde_json::json!({ "type": "text", "text": text })) + .collect::>(); + self.session_prompt_content_with_idle_timeout( + session_id, + &content, + idle_timeout, + max_duration, + ) + .await + } + + /// Send already-typed ACP content blocks, including native image blocks. + pub async fn session_prompt_content_with_idle_timeout( + &mut self, + session_id: &str, + content: &[serde_json::Value], + idle_timeout: std::time::Duration, + max_duration: std::time::Duration, + ) -> Result { + self.turn_final_answer.clear(); + self.turn_output_text.clear(); + let params = build_prompt_content_params(session_id, content); let hard_deadline = tokio::time::Instant::now() + max_duration; self.current_hard_deadline = Some(hard_deadline); @@ -802,7 +970,11 @@ impl AcpClient { "params": params, }); - tracing::debug!(target: "acp::wire", "→ {}", &serde_json::to_string(&msg).unwrap_or_default()); + tracing::debug!( + target: "acp::wire", + "→ session/prompt id={id} blocks={}", + content.len() + ); if let Err(e) = self.write_ndjson(&msg).await { self.last_prompt_id = None; self.current_hard_deadline = None; @@ -1087,7 +1259,7 @@ impl AcpClient { /// Send a JSON-RPC request and wait for the matching response. /// /// Assigns the next available id, writes the NDJSON line to stdin, - /// then calls [`read_until_response`](Self::read_until_response). + /// then reads until the matching response arrives. /// /// The write phase is bounded by `WRITE_TIMEOUT` (30s) and the read phase /// by `REQUEST_TIMEOUT` (60s), so worst-case wall clock is ~90s. Non-prompt @@ -1097,6 +1269,16 @@ impl AcpClient { &mut self, method: &str, params: serde_json::Value, + ) -> Result { + self.send_request_with_session_update_observer(method, params, true) + .await + } + + async fn send_request_with_session_update_observer( + &mut self, + method: &str, + params: serde_json::Value, + observe_session_updates: bool, ) -> Result { let id = self.next_id; self.next_id += 1; @@ -1119,7 +1301,12 @@ impl AcpClient { Err(_) => return Err(AcpError::Timeout(timeout)), } - match tokio::time::timeout(timeout, self.read_until_response(id)).await { + match tokio::time::timeout( + timeout, + self.read_until_response_with_session_update_observer(id, observe_session_updates), + ) + .await + { Ok(result) => result, Err(_) => Err(AcpError::Timeout(timeout)), } @@ -1129,7 +1316,7 @@ impl AcpClient { /// /// After a [`AcpError::Timeout`] from [`send_request`], the agent may /// eventually send the late response. That stale message will sit in the - /// `BufReader` buffer and be silently skipped by the next `read_until_response` + /// `BufReader` buffer and be silently skipped by the next response-read /// call (ID mismatch). However, if the caller wants a clean slate — e.g. /// before retrying the same method — they can call this to consume any /// buffered data with a short deadline. @@ -1188,9 +1375,10 @@ impl AcpClient { /// /// Compares the incoming `id` field as a `serde_json::Value` against /// `json!(expected_id)` so that both numeric and string IDs work correctly. - async fn read_until_response( + async fn read_until_response_with_session_update_observer( &mut self, expected_id: u64, + observe_session_updates: bool, ) -> Result { loop { // LinesCodec::new_with_max_length enforces MAX_LINE_SIZE at the @@ -1234,7 +1422,11 @@ impl AcpClient { continue; } }; - self.observe("acp_read", msg.clone()); + let is_session_update = + msg.get("method").and_then(|v| v.as_str()) == Some("session/update"); + if observe_session_updates || !is_session_update { + self.observe("acp_read", msg.clone()); + } // Check if this is a response to our expected request (has matching id // AND no `method` field — a `method` field means it's an agent-initiated @@ -1281,7 +1473,7 @@ impl AcpClient { } } - /// Idle-aware message loop: like [`read_until_response`] but resets an idle + /// Idle-aware message loop: like the regular response-read path but resets an idle /// deadline on every stdout line. Fires [`AcpError::IdleTimeout`] on silence /// or [`AcpError::HardTimeout`] on absolute wall-clock cap. /// @@ -1756,6 +1948,20 @@ impl AcpClient { "agent_message_chunk" => { if let Some(text) = update["content"]["text"].as_str() { tracing::info!(target: "acp::stream", "{text}"); + self.turn_output_text.push_str(text); + } + if let Some(text) = codex_final_answer_text(update) { + self.turn_final_answer.push_str(text); + } + false + } + "user_message_chunk" => { + if self.session_load_in_progress + && update["content"]["text"] + .as_str() + .is_some_and(contains_buzz_standing_context) + { + self.session_load_saw_buzz_standing_context = true; } false } @@ -1853,6 +2059,12 @@ impl AcpClient { } } + /// Take the visible assistant text emitted during the most recent prompt. + pub fn take_turn_output_text(&mut self) -> Option { + let text = std::mem::take(&mut self.turn_output_text); + (!text.trim().is_empty()).then_some(text) + } + /// Record the standard ACP cumulative cost notification when emitted by /// Claude. Unlike Goose's payload, `used`/`size` are context occupancy and /// are intentionally not mapped to token accounting. @@ -2041,11 +2253,19 @@ impl AcpClient { } /// Build `session/prompt` params from one or more text content blocks. +#[cfg(test)] fn build_prompt_params(session_id: &str, prompt_blocks: &[&str]) -> serde_json::Value { let blocks: Vec = prompt_blocks .iter() .map(|text| serde_json::json!({ "type": "text", "text": text })) .collect(); + build_prompt_content_params(session_id, &blocks) +} + +fn build_prompt_content_params( + session_id: &str, + blocks: &[serde_json::Value], +) -> serde_json::Value { serde_json::json!({ "sessionId": session_id, "prompt": blocks, @@ -2127,6 +2347,9 @@ pub struct SessionNewResponse { pub session_id: String, /// The full `result` value from the JSON-RPC response. pub raw: serde_json::Value, + /// A `session/load` replay contained Buzz's standing-context marker. + /// False for newly created and forked sessions. + pub loaded_buzz_standing_context: bool, } /// How to deliver a system prompt on `session/new`. @@ -2330,6 +2553,90 @@ fn configure_no_window(cmd: &mut tokio::process::Command) { mod tests { use super::*; + #[test] + fn captures_only_codex_final_answer_messages() { + let final_update = serde_json::json!({ + "sessionUpdate": "agent_message_chunk", + "content": {"type": "text", "text": "published answer"}, + "_meta": {"codex": {"phase": "final_answer"}} + }); + let commentary_update = serde_json::json!({ + "sessionUpdate": "agent_message_chunk", + "content": {"type": "text", "text": "working on it"}, + "_meta": {"codex": {"phase": "commentary"}} + }); + + assert_eq!( + codex_final_answer_text(&final_update), + Some("published answer") + ); + assert_eq!(codex_final_answer_text(&commentary_update), None); + } + + #[test] + fn detects_buzz_standing_context_in_loaded_history() { + assert!(contains_buzz_standing_context( + "[Base]\nYou are operating inside Buzz. The buzz-acp harness routes channel events to your session." + )); + assert!(!contains_buzz_standing_context( + "[Task Handoff]\nContinue this local task." + )); + assert!(!contains_buzz_standing_context( + "The buzz-acp harness routes channel events to your session." + )); + } + + #[test] + fn fork_capability_requires_advertised_session_fork() { + let supported = serde_json::json!({ + "agentCapabilities": { + "sessionCapabilities": { + "fork": {} + } + } + }); + let absent = serde_json::json!({ + "agentCapabilities": { + "loadSession": true, + "sessionCapabilities": {} + } + }); + + assert!(AcpClient::agent_supports_fork_session(&supported)); + assert!(!AcpClient::agent_supports_fork_session(&absent)); + } + + #[test] + fn image_capability_requires_explicit_true() { + let supported = serde_json::json!({ + "agentCapabilities": { + "promptCapabilities": { "image": true } + } + }); + let absent = serde_json::json!({ "agentCapabilities": {} }); + + assert!(AcpClient::agent_supports_image_prompt(&supported)); + assert!(!AcpClient::agent_supports_image_prompt(&absent)); + } + + #[test] + fn session_prompt_preserves_native_image_block() { + let blocks = [ + serde_json::json!({ "type": "text", "text": "inspect" }), + serde_json::json!({ + "type": "image", + "data": "aGVsbG8=", + "mimeType": "image/png" + }), + ]; + let params = build_prompt_content_params("sess_abc123", &blocks); + + assert_eq!(params["prompt"][1]["type"], "image"); + assert_eq!(params["prompt"][1]["mimeType"], "image/png"); + assert_eq!(params["prompt"][1]["data"], "aGVsbG8="); + assert!(params["prompt"][1].get("uri").is_none()); + } + #[test] fn stop_reason_parses_all_known_values() { assert_eq!(StopReason::from_str("end_turn"), Some(StopReason::EndTurn)); @@ -3314,6 +3621,58 @@ mod tests { assert_eq!(result.unwrap()["worked"], serde_json::json!(true)); } + #[tokio::test] + async fn session_load_suppresses_replayed_updates_from_observer_only() { + let script = r#" + read -t 2 _load + echo '{"jsonrpc":"2.0","method":"session/update","params":{"marker":"replayed","update":{"sessionUpdate":"user_message_chunk","content":{"type":"text","text":"[Base] buzz-acp harness routes channel events to your session"}}}}' + echo '{"jsonrpc":"2.0","id":0,"result":{}}' + read -t 2 _next + echo '{"jsonrpc":"2.0","method":"session/update","params":{"marker":"live"}}' + echo '{"jsonrpc":"2.0","id":1,"result":{"worked":true}}' + sleep 1 + "#; + let mut client = spawn_script(script).await; + let observer = crate::observer::ObserverHandle::in_process(); + client.set_observer(Some(observer.clone()), 0); + + let loaded = client + .session_load_full("/", "sess-existing", Vec::new()) + .await + .expect("session/load should succeed"); + assert_eq!(loaded.session_id, "sess-existing"); + assert!(loaded.loaded_buzz_standing_context); + + let next = client + .send_request("test/echo", serde_json::json!({})) + .await + .expect("follow-up request should succeed"); + assert_eq!(next["worked"], serde_json::json!(true)); + + let observed_reads: Vec<_> = observer + .snapshot() + .into_iter() + .filter(|event| event.kind == "acp_read") + .map(|event| event.payload) + .collect(); + assert!( + !observed_reads + .iter() + .any(|payload| payload["params"]["marker"] == "replayed"), + "session/load replay updates must not enter the observer feed" + ); + assert!( + observed_reads + .iter() + .any(|payload| payload["params"]["marker"] == "live"), + "normal session updates must remain observable after load" + ); + assert!( + observed_reads.iter().any(|payload| payload["id"] == 0), + "the session/load response itself must remain observable" + ); + } + #[tokio::test] async fn keepalive_resets_idle_past_deadline() { // Keepalive session/update lines every 50ms against a 100ms idle deadline. diff --git a/crates/buzz-acp/src/base_prompt.md b/crates/buzz-acp/src/base_prompt.md index 1d85221f11..606c1d1f7d 100644 --- a/crates/buzz-acp/src/base_prompt.md +++ b/crates/buzz-acp/src/base_prompt.md @@ -6,13 +6,17 @@ You are one per-channel session of your agent identity — not the only copy. Ea When a human references work "you" are doing in another channel, that work belongs to a different session of you. Unless the human asks you to take it over or coordinate it from this channel, leave execution with the owning session — answer from what you can verify (core memory, workspace files, relay messages) and assume the owning session has it handled. +When a turn includes `[Task Handoff]`, this channel has explicitly taken ownership of a pre-existing local task. Treat the loaded task history, objective, unfinished work, and workspace as the current work. Room history remains collaboration input and does not replace the loaded task history. + ## Buzz CLI The `buzz` CLI is your primary interface. Auth env vars: `BUZZ_RELAY_URL`, `BUZZ_PRIVATE_KEY`, `BUZZ_AUTH_TAG`. Exit codes: 0 ok, 1 user error, 2 network, 3 auth, 4 other. Output is structured JSON. +Run every `buzz` CLI command through the `buzz-dev-mcp` MCP server's `shell` tool. Discover that MCP tool first when it is not already loaded. Never run `buzz` through Codex's native `exec_command` or native shell: a shared Codex app-server deliberately does not inherit an individual Buzz identity's credentials, while the per-session MCP server does. Do not search the workspace, user configuration, or process environment for `BUZZ_PRIVATE_KEY`; the harness supplies it only to `buzz-dev-mcp`. + | Group | Key commands | |-------|-------------| -| `buzz agents` | `draft-create`, `draft-update` | +| `buzz agents` | `draft-create`, `draft-update`, `handoff send/list/show` | | `buzz messages` | `send`, `get`, `thread`, `search` | | `buzz channels` | `list`, `get`, `create`, `join`, `members` | | `buzz canvas` | `get`, `set` | @@ -29,6 +33,10 @@ The `buzz` CLI is your primary interface. Auth env vars: `BUZZ_RELAY_URL`, `BUZZ Run `buzz --help` or `buzz --help` for full usage. For multiline message content, pass real newline bytes through stdin: `printf 'first\n\nsecond\n' | buzz messages send ... --content -`. Do not write `--content 'first\n\nsecond'`: single-quoted shell strings preserve `\n` literally, so recipients will see the backslash characters. `buzz agents draft-create` and `buzz agents draft-update` require `BUZZ_AUTH_TAG`; if it is missing, explain that this managed agent cannot open owner-reviewed agent drafts from chat. +To deliver a file to a channel, send it in the message itself: `buzz messages send --channel --content "attached" --file ./report.md`. Repeat `--file` for multiple attachments. Do not use `buzz upload file` and paste its bare URL as the delivery mechanism: that omits the message's filename metadata and can make text files appear as `.bin` on older relays. If you accidentally run `buzz upload file`, do not publish its `url`; retry the delivery with `buzz messages send --file`. + +When another agent sends an attachment, the event context includes an `Attachments` manifest with its filename, MIME type, size, SHA-256, and URL. Download it on demand with `buzz media get --output ` before reading or transforming it. Keep the output path inside your current workspace, verify the downloaded size and SHA-256 when those values are provided, and never assume another agent's local path is shared with you. To forward the file, download it first and send it again with `buzz messages send --file`; do not forward only the URL. + When opening a pull request in response to channel work, always pass `--channel ` using the UUID from `[Context]`. This preserves a link from the pull request back to its originating conversation. `buzz pr open`, `buzz issues create`, and `buzz repos create` return a `link` field (a `buzz://` deep link). When you announce that work in a channel message, include the `link` value verbatim — Buzz Desktop renders it as a rich preview card that opens the PR, issue, or repo in-app, the same way GitHub links render. Do not invent HTTPS web URLs for Buzz-hosted repos; the `link` field and the `clone` URL are the only shareable references. @@ -43,6 +51,16 @@ Use the channel UUID from `[Context]`. Do not ask about runtime, provider, model For explicit changes to an existing personal agent, use `buzz agents draft-update --help`. Draft updates also require owner review and save. +## Agent Handoffs + +When a human explicitly asks you to transfer work to another Agent, create a curated Markdown handoff and send it directly to that Agent: + +`buzz agents handoff send --to --title --summary --history-file ` + +Include the original requests, final outcomes, key decisions, files or links involved, verification performed, unresolved work, and the next concrete actions. Do not include hidden chain-of-thought, raw credentials, environment secrets, or unrelated conversation history. A handoff is a point-in-time encrypted snapshot, not permission to read future activity. + +Use `buzz agents handoff list` to discover handoffs addressed to you and `buzz agents handoff show ` to load the full history before continuing the work. + ## Communication Patterns ### Mentions @@ -73,7 +91,7 @@ All replies and delegations — including task assignments to other agents — g ### General - Respond promptly to @mentions. Be direct — no preamble. Name what you did, what you found, or what you need. -- **If your turn produced anything worth knowing, you MUST publish it.** Use `buzz messages send`. Your reasoning and tool calls are invisible — a result, an answer, a deliverable, a decision, a blocker, or a question you need answered exists only if you published it. Work or an answer that someone asked you for always counts. Ending that kind of turn without a message is a silent failure. +- **If your turn produced anything worth knowing, you MUST publish it.** Use `buzz messages send` unless the current prompt contains `[Buzz Delivery]`; in that case, return ordinary text as your final answer and let the harness publish it. For file deliverables in that mode, call `buzz messages send --file` yourself first, then return a short summary so the harness can publish the accompanying text. Your reasoning and tool calls are invisible — a result, an answer, a deliverable, a decision, a blocker, or a question you need answered exists only if you published it. Work or an answer that someone asked you for always counts. Ending that kind of turn without a message is a silent failure. - **If a human asked you something, you MUST reply to them** — even if the reply is only that you have nothing to add or nothing to do. Never leave a person waiting on you. - **Otherwise, publishing is optional and silence is usually correct.** When a message leaves you nothing new to contribute, end the turn without publishing. That is a success, not a failure. - **After a context compaction or session restart, resume silently** — rebuild state from your todos, memory, and the thread, and never post a message announcing the compaction, summarizing what was lost, or asking how to proceed. @@ -87,10 +105,11 @@ All replies and delegations — including task assignments to other agents — g ## Startup Recovery -1. `buzz feed get` — surface pending mentions and action items. Filter by type: `mentions`, `needs_action`, `activity`, `agent_activity`. -2. `buzz messages get --channel ` on assigned channels — catch up on recent history. -3. Check `AGENTS.md` in your working directory for team context. -4. Check `RESEARCH/`, `GUIDES/`, `PLANS/` before searching externally. Use `buzz messages search --query "..."` for cross-channel keyword lookups. +1. `buzz agents handoff list` — discover encrypted task histories explicitly assigned to this Agent. +2. `buzz feed get` — surface pending mentions and action items. Filter by type: `mentions`, `needs_action`, `activity`, `agent_activity`. +3. `buzz messages get --channel ` on assigned channels — catch up on recent history. +4. Check `AGENTS.md` in your working directory for team context. +5. Check `RESEARCH/`, `GUIDES/`, `PLANS/` before searching externally. Use `buzz messages search --query "..."` for cross-channel keyword lookups. ## Workspace Layout diff --git a/crates/buzz-acp/src/codex_app_server_proxy.rs b/crates/buzz-acp/src/codex_app_server_proxy.rs new file mode 100644 index 0000000000..6f2d912cc2 --- /dev/null +++ b/crates/buzz-acp/src/codex_app_server_proxy.rs @@ -0,0 +1,69 @@ +use anyhow::{anyhow, Context, Result}; +use futures_util::{SinkExt, StreamExt}; +use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader, BufWriter}; +use tokio_tungstenite::{connect_async, tungstenite::Message}; + +const SHARED_APP_SERVER_URL_ENV: &str = "CODEX_SHARED_APP_SERVER_URL"; + +/// Bridge the Codex app-server's WebSocket transport to the stdio transport +/// expected by codex-acp. This keeps the adapter as the ACP/app-server protocol +/// translator while allowing several clients to share one long-lived server. +pub async fn run() -> Result<()> { + let url = std::env::var(SHARED_APP_SERVER_URL_ENV) + .with_context(|| format!("{SHARED_APP_SERVER_URL_ENV} is required"))?; + let (socket, _) = connect_async(&url) + .await + .with_context(|| format!("failed to connect to shared Codex app-server at {url}"))?; + let (mut socket_writer, mut socket_reader) = socket.split(); + + let mut stdin_lines = BufReader::new(tokio::io::stdin()).lines(); + let mut stdout = BufWriter::new(tokio::io::stdout()); + + loop { + tokio::select! { + line = stdin_lines.next_line() => { + match line.context("failed to read app-server request from stdin")? { + Some(line) => socket_writer + .send(Message::Text(line.into())) + .await + .context("failed to write app-server request to WebSocket")?, + None => { + let _ = socket_writer.close().await; + break; + } + } + } + message = socket_reader.next() => { + match message { + Some(Ok(Message::Text(text))) => { + stdout + .write_all(text.as_bytes()) + .await + .context("failed to write app-server response to stdout")?; + stdout.write_all(b"\n").await?; + stdout.flush().await?; + } + Some(Ok(Message::Binary(bytes))) => { + stdout.write_all(&bytes).await?; + stdout.write_all(b"\n").await?; + stdout.flush().await?; + } + Some(Ok(Message::Ping(payload))) => { + socket_writer.send(Message::Pong(payload)).await?; + } + Some(Ok(Message::Pong(_))) => {} + Some(Ok(Message::Close(frame))) => { + return Err(anyhow!("shared app-server WebSocket closed unexpectedly: {frame:?}")); + } + None => { + return Err(anyhow!("shared app-server WebSocket ended unexpectedly")); + } + Some(Ok(Message::Frame(_))) => {} + Some(Err(error)) => return Err(anyhow!(error).context("shared app-server WebSocket failed")), + } + } + } + } + + Ok(()) +} diff --git a/crates/buzz-acp/src/config.rs b/crates/buzz-acp/src/config.rs index f9e7bf1ed8..04c5103e20 100644 --- a/crates/buzz-acp/src/config.rs +++ b/crates/buzz-acp/src/config.rs @@ -429,6 +429,22 @@ pub struct CliArgs { #[arg(long, env = "BUZZ_ACP_SESSION_TITLE")] pub session_title: Option, + /// Existing Codex task exclusively owned by this Buzz agent identity. + #[arg( + long, + env = "BUZZ_ACP_CODEX_TASK_ID", + requires = "codex_task_workspace" + )] + pub codex_task_id: Option, + + /// Workspace recorded by Codex for --codex-task-id. + #[arg( + long, + env = "BUZZ_ACP_CODEX_TASK_WORKSPACE", + requires = "codex_task_id" + )] + pub codex_task_workspace: Option, + /// Permission mode for agents that support `session/set_config_option` /// with `configId: "mode"` (e.g. `claude-agent-acp`). /// @@ -458,6 +474,10 @@ pub struct CliArgs { #[arg(long, env = "BUZZ_ACP_RESPOND_TO_ALLOWLIST", value_delimiter = ',')] pub respond_to_allowlist: Option>, + /// Allow non-owner authors through the inbound gate in direct messages. + #[arg(long, env = "BUZZ_ACP_ALLOW_NON_OWNER_DM", default_value_t = false)] + pub allow_non_owner_dm: bool, + /// Comma-separated list of allowed `--respond-to` modes. /// When set, the harness rejects startup if `--respond-to` is not in this list. /// Modes: owner-only, allowlist, anyone, nobody. @@ -500,6 +520,12 @@ pub struct ChannelFilter { pub require_mention: bool, } +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct CodexTaskBinding { + pub task_id: String, + pub workspace: String, +} + #[derive(Debug)] pub struct Config { pub keys: Keys, @@ -543,12 +569,15 @@ pub struct Config { /// Sanitized session title, sent as `_meta.sessionTitle` on `session/new`. /// `None` when unset or when the configured value sanitized to empty. pub session_title: Option, + /// Identity-scoped Codex task. All joined Rooms route into this one task. + pub codex_task_binding: Option, /// Permission mode to apply after session creation. `Default` = skip. pub permission_mode: PermissionMode, /// Inbound author gate mode. pub respond_to: RespondTo, /// Validated allowlist of pubkey hex strings (used when respond_to == Allowlist). pub respond_to_allowlist: HashSet, + pub allow_non_owner_dm: bool, /// Allowed `respond_to` modes. Empty = all modes allowed. pub allowed_respond_to: Vec, /// Per-persona env vars to inject at agent spawn time (e.g., GOOSE_PROVIDER, GOOSE_MODEL, BUZZ_AGENT_MODEL). @@ -925,6 +954,47 @@ impl Config { let agent_args = normalize_agent_args(&agent_command, args.agent_args); + let codex_task_binding = match (args.codex_task_id, args.codex_task_workspace) { + (Some(task_id), Some(workspace)) => { + if args.agents != 1 { + return Err(ConfigError::ConfigFile( + "Codex task-bound agents require exactly one ACP worker".into(), + )); + } + let task_id = Uuid::parse_str(task_id.trim()) + .map_err(|_| ConfigError::ConfigFile("Codex task ID must be a UUID".into()))? + .to_string(); + let workspace = workspace.canonicalize().map_err(|error| { + ConfigError::ConfigFile(format!( + "Codex task workspace {} is unavailable: {error}", + workspace.display() + )) + })?; + if !workspace.is_dir() { + return Err(ConfigError::ConfigFile(format!( + "Codex task workspace is not a directory: {}", + workspace.display() + ))); + } + Some(CodexTaskBinding { + task_id, + workspace: workspace.to_string_lossy().to_string(), + }) + } + (None, None) => None, + _ => { + return Err(ConfigError::ConfigFile( + "Codex task ID and workspace must be configured together".into(), + )); + } + }; + let lazy_pool = args.lazy_pool && codex_task_binding.is_none(); + if args.lazy_pool && codex_task_binding.is_some() { + tracing::info!( + "lazy pool disabled because a task-bound identity must load its Codex task before going online" + ); + } + if let Some(ref channels) = args.channels { for ch in channels { if ch.parse::().is_err() { @@ -1109,15 +1179,17 @@ impl Config { .session_title .as_deref() .and_then(sanitize_session_title), + codex_task_binding, permission_mode: args.permission_mode, respond_to: args.respond_to, respond_to_allowlist, + allow_non_owner_dm: args.allow_non_owner_dm, allowed_respond_to, persona_env_vars, has_generated_codex_config, relay_observer: args.relay_observer, exit_after_inactivity_secs: args.exit_after_inactivity, - lazy_pool: args.lazy_pool, + lazy_pool, idle_pool_sleep_secs: args.idle_pool_sleep, agent_owner: args.agent_owner.map(|s| s.trim().to_ascii_lowercase()), no_base_prompt: args.no_base_prompt, @@ -1280,7 +1352,12 @@ pub fn resolve_channel_filters( KIND_STREAM_REMINDER, ] }); - let require_mention = !config.no_mention_filter; + // Managed Desktop agents carry their visible name in + // `session_title`. Subscribe to the channel stream for those + // agents so older clients whose visible `@Name` mention omitted + // the Nostr `p` tag can still be matched locally. Agents without + // a name retain the efficient relay-side `#p` filter. + let require_mention = !config.no_mention_filter && config.session_title.is_none(); for ch in &target_channels { result.insert( *ch, @@ -1305,7 +1382,7 @@ pub fn resolve_channel_filters( SubscribeMode::Config => { for ch in discovered_channels { let mut merged_kinds: Option> = Some(vec![]); - let mut require_mention = true; + let mut require_mention = config.session_title.is_none(); let mut has_rule = false; for rule in rules { @@ -1385,7 +1462,7 @@ pub fn resolve_dynamic_channel_filter( KIND_STREAM_REMINDER, ] })), - require_mention: !config.no_mention_filter, + require_mention: !config.no_mention_filter && config.session_title.is_none(), }), SubscribeMode::All => Some(ChannelFilter { kinds: config.kinds_override.clone(), @@ -1396,7 +1473,7 @@ pub fn resolve_dynamic_channel_filter( // evaluate ALL rules against this specific channel (including // channel-specific rules, not just ChannelScope::All). let mut merged_kinds: Option> = Some(vec![]); - let mut require_mention = true; + let mut require_mention = config.session_title.is_none(); let mut has_rule = false; for rule in rules { @@ -1481,9 +1558,11 @@ mod tests { memory_enabled: true, model: None, session_title: None, + codex_task_binding: None, permission_mode: PermissionMode::BypassPermissions, respond_to: RespondTo::Anyone, respond_to_allowlist: HashSet::new(), + allow_non_owner_dm: false, allowed_respond_to: Vec::new(), persona_env_vars: vec![], has_generated_codex_config: false, @@ -1534,6 +1613,22 @@ mod tests { } } + #[test] + fn test_managed_agent_name_disables_relay_pubkey_prefilter() { + let mut config = test_config(SubscribeMode::Mentions); + config.session_title = Some("Debug".into()); + let channel = Uuid::new_v4(); + + let filters = resolve_channel_filters(&config, &[channel], &[]); + assert!( + !filters.get(&channel).unwrap().require_mention, + "managed Agent must receive legacy visible-name mentions for local matching" + ); + + let dynamic = resolve_dynamic_channel_filter(&config, channel, &[]).unwrap(); + assert!(!dynamic.require_mention); + } + #[test] fn test_mentions_mode_custom_kinds() { let mut config = test_config(SubscribeMode::Mentions); @@ -2771,6 +2866,70 @@ channels = "ALL" const TEST_PRIVATE_KEY: &str = "0000000000000000000000000000000000000000000000000000000000000001"; + #[test] + fn codex_task_binding_requires_single_worker_and_preserves_workspace() { + let workspace = tempfile::tempdir().unwrap(); + let workspace_string = workspace.path().to_string_lossy().to_string(); + let task_id = "019eca9a-beb9-7902-8ce6-527b2ba56020"; + let args = CliArgs::try_parse_from(vec![ + "buzz-acp".to_string(), + "--private-key".to_string(), + TEST_PRIVATE_KEY.to_string(), + "--codex-task-id".to_string(), + task_id.to_string(), + "--codex-task-workspace".to_string(), + workspace_string, + ]) + .expect("clap should parse task binding"); + + let config = Config::from_args(args).expect("task binding should be valid"); + let binding = config.codex_task_binding.expect("binding should be set"); + assert_eq!(binding.task_id, task_id); + assert_eq!( + PathBuf::from(binding.workspace), + workspace.path().canonicalize().unwrap() + ); + } + + #[test] + fn codex_task_binding_rejects_multiple_workers() { + let workspace = tempfile::tempdir().unwrap(); + let args = CliArgs::try_parse_from(vec![ + "buzz-acp".to_string(), + "--private-key".to_string(), + TEST_PRIVATE_KEY.to_string(), + "--agents".to_string(), + "2".to_string(), + "--codex-task-id".to_string(), + "019eca9a-beb9-7902-8ce6-527b2ba56020".to_string(), + "--codex-task-workspace".to_string(), + workspace.path().to_string_lossy().to_string(), + ]) + .expect("clap should parse task binding"); + + let error = Config::from_args(args).unwrap_err().to_string(); + assert!(error.contains("exactly one ACP worker")); + } + + #[test] + fn codex_task_binding_disables_lazy_pool() { + let workspace = tempfile::tempdir().unwrap(); + let args = CliArgs::try_parse_from(vec![ + "buzz-acp".to_string(), + "--private-key".to_string(), + TEST_PRIVATE_KEY.to_string(), + "--lazy-pool".to_string(), + "--codex-task-id".to_string(), + "019eca9a-beb9-7902-8ce6-527b2ba56020".to_string(), + "--codex-task-workspace".to_string(), + workspace.path().to_string_lossy().to_string(), + ]) + .expect("clap should parse task binding"); + + let config = Config::from_args(args).expect("task binding should be valid"); + assert!(!config.lazy_pool); + } + #[test] fn allowed_respond_to_full_path_rejects_disallowed_mode() { // --allowed-respond-to=owner-only,allowlist + --respond-to=anyone → ConfigError diff --git a/crates/buzz-acp/src/filter.rs b/crates/buzz-acp/src/filter.rs index 43edd969dd..fb41170e60 100644 --- a/crates/buzz-acp/src/filter.rs +++ b/crates/buzz-acp/src/filter.rs @@ -365,11 +365,24 @@ const MAX_CONSECUTIVE_TIMEOUTS: u32 = 5; /// After [`MAX_CONSECUTIVE_TIMEOUTS`] consecutive timeouts on a single rule, /// that rule is logged at ERROR and the call returns `None` immediately to /// avoid blocking the event loop indefinitely. +#[cfg(test)] pub async fn match_event( event: &nostr::Event, channel_id: uuid::Uuid, rules: &[SubscriptionRule], agent_pubkey_hex: &str, +) -> Option { + match_event_with_display_name(event, channel_id, rules, agent_pubkey_hex, None).await +} + +/// Match an event while allowing legacy visible-name mentions for a managed +/// Agent. See [`event_mentions_agent`] for the compatibility rules. +pub async fn match_event_with_display_name( + event: &nostr::Event, + channel_id: uuid::Uuid, + rules: &[SubscriptionRule], + agent_pubkey_hex: &str, + agent_display_name: Option<&str>, ) -> Option { let filter_ctx = FilterContext::from_event(event, channel_id); @@ -384,15 +397,12 @@ pub async fn match_event( continue; } - // 3. Mention check — look for a `p` tag whose first element equals - // agent_pubkey_hex. Uses tag.as_slice() for stable, library-independent - // access — avoids relying on the Display impl of tag kind. + // 3. Mention check. A matching `p` tag is authoritative. For managed + // agents with a display name, also accept a boundary-delimited + // textual `@Name` mention. This is compatibility for older Buzz + // clients that rendered mention text but omitted the Nostr tag. if rule.require_mention { - let mentioned = event.tags.iter().any(|tag| { - let s = tag.as_slice(); - s.first().map(|k| k.as_str()) == Some("p") - && s.get(1).map(|v| v.as_str()) == Some(agent_pubkey_hex) - }); + let mentioned = event_mentions_agent(event, agent_pubkey_hex, agent_display_name); if !mentioned { continue; } @@ -459,6 +469,54 @@ pub async fn match_event( None } +/// Return whether an event explicitly mentions this agent. +/// +/// Modern clients encode mentions as Nostr `p` tags. Older Buzz clients could +/// publish only the visible `@Display Name` text, so managed agents also accept +/// a case-insensitive textual mention with identifier boundaries. The leading +/// boundary prevents email-like text (`user@Agent`) from waking an agent; the +/// trailing boundary prevents `@Debugging` from matching `Debug`. +pub(crate) fn event_mentions_agent( + event: &nostr::Event, + agent_pubkey_hex: &str, + agent_display_name: Option<&str>, +) -> bool { + for tag in event.tags.iter() { + let s = tag.as_slice(); + if s.first().map(|k| k.as_str()) != Some("p") { + continue; + } + if s.get(1).map(|v| v.as_str()) == Some(agent_pubkey_hex) { + return true; + } + } + + let Some(name) = agent_display_name + .map(str::trim) + .filter(|name| !name.is_empty()) + else { + return false; + }; + text_mentions_name(&event.content, name) +} + +fn text_mentions_name(content: &str, display_name: &str) -> bool { + let folded_content = content.to_lowercase(); + let needle = format!("@{}", display_name.to_lowercase()); + + folded_content + .match_indices(&needle) + .any(|(start, matched)| { + let before = folded_content[..start].chars().next_back(); + let after = folded_content[start + matched.len()..].chars().next(); + before.is_none_or(is_mention_boundary) && after.is_none_or(is_mention_boundary) + }) +} + +fn is_mention_boundary(ch: char) -> bool { + !ch.is_alphanumeric() && ch != '_' +} + #[cfg(test)] mod tests { use super::*; @@ -663,6 +721,88 @@ mod tests { assert_eq!(matched.prompt_tag, "mentioned"); } + #[tokio::test] + async fn test_match_event_accepts_legacy_text_mention() { + let event = make_event(9, "@Debug hi"); + let channel_id = any_channel(); + let rules = vec![make_rule( + "mention-only", + ChannelScope::All("all".into()), + vec![9], + true, + None, + Some("mentioned"), + )]; + + let matched = match_event_with_display_name( + &event, + channel_id, + &rules, + "agent-pubkey", + Some("Debug"), + ) + .await + .expect("legacy visible mention should match"); + assert_eq!(matched.prompt_tag, "mentioned"); + } + + #[tokio::test] + async fn test_legacy_text_mention_requires_boundaries() { + let channel_id = any_channel(); + let rules = vec![make_rule( + "mention-only", + ChannelScope::All("all".into()), + vec![9], + true, + None, + None, + )]; + + for content in ["mail@Debug hi", "@Debugging hi", "@Debug_agent hi"] { + let event = make_event(9, content); + assert!( + match_event_with_display_name( + &event, + channel_id, + &rules, + "agent-pubkey", + Some("Debug"), + ) + .await + .is_none(), + "must not match {content:?}" + ); + } + } + + #[tokio::test] + async fn test_legacy_text_recovers_when_old_client_tagged_duplicate_identity() { + let other_pubkey = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; + let event = make_event_with_p_tag(9, "@Debug hi", other_pubkey); + let channel_id = any_channel(); + let rules = vec![make_rule( + "mention-only", + ChannelScope::All("all".into()), + vec![9], + true, + None, + None, + )]; + + assert!( + match_event_with_display_name( + &event, + channel_id, + &rules, + "agent-pubkey", + Some("Debug"), + ) + .await + .is_some(), + "the working same-name Agent must recover an old client's stale identity tag" + ); + } + #[tokio::test] async fn test_match_event_no_match() { let event = make_event(1, "hello"); diff --git a/crates/buzz-acp/src/lib.rs b/crates/buzz-acp/src/lib.rs index 27b9000b7b..8555b2968a 100644 --- a/crates/buzz-acp/src/lib.rs +++ b/crates/buzz-acp/src/lib.rs @@ -1,6 +1,7 @@ #![deny(unsafe_code)] mod acp; +mod codex_app_server_proxy; mod config; mod engram_fetch; mod filter; @@ -9,6 +10,7 @@ mod pool; mod pool_lifecycle; mod queue; mod relay; +mod session_store; mod setup_mode; mod usage; @@ -232,15 +234,19 @@ async fn is_owner_or_sibling( /// siblings may fire a turn — the explicit allowlist and `anyone` mode do /// NOT apply inside DMs. `Nobody` still drops everything. Callers must /// resolve `is_dm` fail-closed: unknown channel type ⇒ treat as DM. -async fn author_allowed( +async fn author_allowed_with_dm( respond_to: &RespondTo, allowlist: &HashSet, author: &str, is_dm: bool, + allow_non_owner_dm: bool, owner_cache: &OwnerCache, rest_client: &relay::RestClient, ) -> bool { if is_dm { + if allow_non_owner_dm && !matches!(respond_to, RespondTo::Nobody) { + return true; + } return match respond_to { RespondTo::Nobody => false, _ => is_owner_or_sibling(author, owner_cache, rest_client).await, @@ -257,6 +263,26 @@ async fn author_allowed( } } +async fn author_allowed( + respond_to: &RespondTo, + allowlist: &HashSet, + author: &str, + is_dm: bool, + owner_cache: &OwnerCache, + rest_client: &relay::RestClient, +) -> bool { + author_allowed_with_dm( + respond_to, + allowlist, + author, + is_dm, + false, + owner_cache, + rest_client, + ) + .await +} + /// Resolve whether `channel_id` is a DM, for the inbound author gate. /// /// Resolution order: @@ -594,6 +620,12 @@ fn batch_envelope(events: &[observer::ObserverEvent]) -> observer::ObserverEvent session_id: last.session_id.clone(), turn_id: last.turn_id.clone(), started_at: last.started_at.clone(), + viewer_pubkeys: events + .iter() + .flat_map(|event| event.viewer_pubkeys.iter().cloned()) + .collect::>() + .into_iter() + .collect(), payload: serde_json::json!({ "events": serde_json::to_value(events).unwrap_or_default(), }), @@ -1031,46 +1063,94 @@ async fn publish_relay_observer_event( // Trim oversized frames to fit the plaintext cap rather than letting // encrypt_observer_payload reject and drop them whole (silent telemetry loss). fit_observer_event_to_budget(&mut event); - let encrypted = match encrypt_observer_payload(keys, owner_pubkey, &event) { - Ok(encrypted) => encrypted, - Err(error) => { - tracing::warn!("failed to encrypt relay observer event: {error}"); - return; + let mut recipients = vec![(owner_pubkey_hex.to_string(), *owner_pubkey)]; + let mut seen = HashSet::from([owner_pubkey_hex.to_ascii_lowercase()]); + for viewer in &event.viewer_pubkeys { + let normalized = viewer.to_ascii_lowercase(); + if !seen.insert(normalized.clone()) { + continue; } - }; - let builder = match buzz_sdk::build_agent_observer_frame( - owner_pubkey_hex, - agent_pubkey_hex, - OBSERVER_FRAME_TELEMETRY, - &encrypted, - ) { - Ok(builder) => builder, - Err(error) => { - tracing::warn!("failed to build relay observer event: {error}"); - return; + match PublicKey::from_hex(&normalized) { + Ok(pubkey) => recipients.push((normalized, pubkey)), + Err(error) => tracing::warn!(viewer, %error, "invalid observer viewer pubkey"), } - }; - let signed = match builder.sign_with_keys(keys) { - Ok(event) => event, - Err(error) => { - tracing::warn!("failed to sign relay observer event: {error}"); - return; + } + + for (recipient_hex, recipient) in recipients { + let encrypted = match encrypt_observer_payload(keys, &recipient, &event) { + Ok(encrypted) => encrypted, + Err(error) => { + tracing::warn!(recipient = %recipient_hex, "failed to encrypt relay observer event: {error}"); + continue; + } + }; + let builder = match buzz_sdk::build_agent_observer_frame( + &recipient_hex, + agent_pubkey_hex, + OBSERVER_FRAME_TELEMETRY, + &encrypted, + ) { + Ok(builder) => builder, + Err(error) => { + tracing::warn!(recipient = %recipient_hex, "failed to build relay observer event: {error}"); + continue; + } + }; + let signed = match builder.sign_with_keys(keys) { + Ok(event) => event, + Err(error) => { + tracing::warn!(recipient = %recipient_hex, "failed to sign relay observer event: {error}"); + continue; + } + }; + if let Err(error) = publisher.publish_event(signed).await { + tracing::warn!(recipient = %recipient_hex, "relay observer event dropped: {error}"); } - }; - if let Err(error) = publisher.publish_event(signed).await { - tracing::warn!("relay observer event dropped: {error}"); } } /// Maximum age (seconds) for an observer control frame to be considered fresh. const OBSERVER_CONTROL_FRESHNESS_SECS: i64 = 300; -fn handle_relay_observer_control_event( +struct ObserverControlAuthorization<'a> { + owner_pubkey_hex: &'a str, + respond_to: &'a RespondTo, + respond_to_allowlist: &'a HashSet, + owner_cache: &'a OwnerCache, + rest_client: &'a relay::RestClient, +} + +async fn observer_control_author_allowed( + command_type: &str, + sender: &str, + is_dm: bool, + authorization: &ObserverControlAuthorization<'_>, +) -> bool { + match command_type { + "cancel_turn" => { + author_allowed( + authorization.respond_to, + authorization.respond_to_allowlist, + sender, + is_dm, + authorization.owner_cache, + authorization.rest_client, + ) + .await + } + "switch_model" => sender == authorization.owner_pubkey_hex, + "generate_handoff" => sender == authorization.owner_pubkey_hex, + _ => false, + } +} + +async fn handle_relay_observer_control_event( keys: &nostr::Keys, event: nostr::Event, pool: &mut AgentPool, observer: Option<&observer::ObserverHandle>, - owner_pubkey_hex: &str, + authorization: &ObserverControlAuthorization<'_>, + channel_info: &pool::ChannelInfoResolver, ) { // Defense-in-depth: verify signature even though the relay already checked. if let Err(e) = buzz_core::verify_event(&event) { @@ -1078,16 +1158,6 @@ fn handle_relay_observer_control_event( return; } - // Defense-in-depth: verify the sender is the resolved owner. - if event.pubkey.to_hex() != owner_pubkey_hex { - tracing::warn!( - sender = %event.pubkey, - expected = %owner_pubkey_hex, - "observer control frame from non-owner — dropping" - ); - return; - } - // Freshness: reject stale/replayed frames outside ±5 minute window. let now = chrono::Utc::now().timestamp(); let event_ts = event.created_at.as_secs() as i64; @@ -1111,17 +1181,150 @@ fn handle_relay_observer_control_event( let command_type = payload.get("type").and_then(|value| value.as_str()); match command_type { Some("cancel_turn") => { + let Some(channel_id) = payload + .get("channelId") + .and_then(|value| value.as_str()) + .and_then(|value| value.parse::().ok()) + else { + tracing::warn!("observer cancel_turn control frame missing valid channelId"); + return; + }; + let sender = event.pubkey.to_hex(); + let is_dm = is_dm_channel(channel_id, channel_info).await; + if !observer_control_author_allowed("cancel_turn", &sender, is_dm, authorization).await + { + tracing::warn!(sender, %channel_id, "observer cancel frame from disallowed author"); + return; + } handle_cancel_turn_control(&payload, pool, observer); } Some("switch_model") => { + let sender = event.pubkey.to_hex(); + if !observer_control_author_allowed("switch_model", &sender, true, authorization).await + { + tracing::warn!( + sender = %event.pubkey, + expected = %authorization.owner_pubkey_hex, + "observer model control frame from non-owner — dropping" + ); + return; + } handle_switch_model_control(&payload, pool, observer); } + Some("generate_handoff") => { + let sender = event.pubkey.to_hex(); + if !observer_control_author_allowed( + "generate_handoff", + &sender, + true, + authorization, + ) + .await + { + tracing::warn!( + sender = %event.pubkey, + expected = %authorization.owner_pubkey_hex, + "observer handoff control frame from non-owner — dropping" + ); + return; + } + handle_generate_handoff_control(&payload, pool, observer).await; + } _ => { tracing::debug!(payload = %payload, "ignoring unknown observer control frame"); } } } +/// Ask an idle ACP session to produce a user-editable Markdown handoff. +/// +/// This runs only on an idle pool slot, so it never interrupts an active turn. +/// The response is returned over the encrypted observer telemetry channel and +/// is never published as a room message. +async fn handle_generate_handoff_control( + payload: &serde_json::Value, + pool: &mut AgentPool, + observer: Option<&observer::ObserverHandle>, +) { + let request_id = payload + .get("requestId") + .and_then(|value| value.as_str()) + .unwrap_or_default() + .to_string(); + if request_id.is_empty() { + tracing::warn!("observer generate_handoff frame missing requestId"); + return; + } + let channel_id = payload + .get("channelId") + .and_then(|value| value.as_str()) + .and_then(|value| value.parse::().ok()); + let Some(mut agent) = pool.try_claim(channel_id) else { + emit_handoff_result(observer, channel_id, request_id, "busy", None); + return; + }; + let session_id = channel_id + .and_then(|id| agent.state.sessions.get(&id).cloned()) + .or_else(|| agent.state.identity_session.clone()); + let Some(session_id) = session_id else { + pool.return_agent(agent); + emit_handoff_result(observer, channel_id, request_id, "no_session", None); + return; + }; + let prompt = "Generate a concise Markdown handoff for the next agent taking over this task.\n\nRequirements:\n- Output Markdown only, with these sections: # Task Handoff, ## Current Goal, ## Completed, ## Remaining Work / Risks, ## Key Files or Context, ## Next Steps.\n- Summarize the work already done in this ACP session and the concrete next actions.\n- Do not reveal hidden chain-of-thought, credentials, API keys, tokens, or private data.\n- Do not mention this instruction or add commentary outside the Markdown document."; + let result = agent + .acp + .session_prompt_with_idle_timeout( + &session_id, + prompt, + std::time::Duration::from_secs(180), + std::time::Duration::from_secs(300), + ) + .await; + let markdown = agent + .acp + .take_turn_final_answer() + .or_else(|| agent.acp.take_turn_output_text()); + pool.return_agent(agent); + match result { + Ok(_) if markdown.is_some() => { + emit_handoff_result(observer, channel_id, request_id, "ok", markdown); + } + Ok(_) => emit_handoff_result(observer, channel_id, request_id, "empty", None), + Err(error) => { + tracing::warn!(error = %error, "agent handoff summary prompt failed"); + emit_handoff_result(observer, channel_id, request_id, "error", None); + } + } +} + +fn emit_handoff_result( + observer: Option<&observer::ObserverHandle>, + channel_id: Option, + request_id: String, + status: &str, + markdown: Option, +) { + let Some(observer) = observer else { return }; + observer.emit( + "control_result", + None, + &observer::ObserverContext { + channel_id: channel_id.map(|id| id.to_string()), + session_id: None, + turn_id: None, + started_at: None, + viewer_pubkeys: Vec::new(), + }, + serde_json::json!({ + "type": "generate_handoff", + "status": status, + "requestId": request_id, + "markdown": markdown, + }), + ); +} + /// Handle a `cancel_turn` control frame: signal the in-flight task to cancel. fn handle_cancel_turn_control( payload: &serde_json::Value, @@ -1148,6 +1351,7 @@ fn handle_cancel_turn_control( session_id: None, turn_id: None, started_at: None, + viewer_pubkeys: Vec::new(), }, serde_json::json!({ "type": "cancel_turn", @@ -1225,6 +1429,7 @@ fn handle_switch_model_control( session_id: None, turn_id: None, started_at: None, + viewer_pubkeys: Vec::new(), }, serde_json::json!({ "type": "switch_model", @@ -1371,8 +1576,9 @@ fn any_respawn_in_flight(crash_history: &[SlotCircuit]) -> bool { /// Result of a background respawn task. struct RespawnResult { index: usize, - /// Tuple: (initialized client, protocol version, agent name). - result: Result<(AcpClient, u32, String)>, + /// Tuple: (initialized client, protocol version, agent name, + /// supports session/load). + result: Result<(AcpClient, u32, String, bool)>, } /// Outcome of a non-cancelling steer attempt, forwarded from a per-attempt @@ -1416,7 +1622,7 @@ impl RespawnGuard { /// Send the result and disarm the guard. Uses `try_send` (sync) so there /// is no await boundary between marking `sent` and actually enqueueing — /// cancellation cannot slip between the two. - fn send(mut self, result: Result<(AcpClient, u32, String)>) { + fn send(mut self, result: Result<(AcpClient, u32, String, bool)>) { // Invariant: try_send succeeds because the channel capacity equals the // slot count, and respawn_in_flight guarantees at most one outstanding // result per slot. If this ever fails, the channel sizing or the @@ -1723,6 +1929,9 @@ async fn tokio_main() -> Result<()> { rustls::crypto::ring::default_provider() .install_default() .expect("failed to install rustls crypto provider"); + if is_subcommand("app-server") { + return codex_app_server_proxy::run().await; + } if is_subcommand("models") { // Strip the subcommand token so clap doesn't reject it as a positional. // Keeps argv[0] (binary name) and passes everything after the subcommand. @@ -1987,16 +2196,6 @@ async fn tokio_main() -> Result<()> { let mut queue = EventQueue::new(dedup_mode).with_in_flight_deadline(config.max_turn_duration_secs); - // Online means the harness can receive work, not merely that its socket is - // connected. Publishing after channel subscriptions gives desktop callers - // a durable readiness boundary before they send a startup mention. - if config.presence_enabled { - match publish_presence(&presence_publisher, &presence_keys, "online").await { - Ok(_) => tracing::info!("presence set to online"), - Err(e) => tracing::warn!("failed to set initial presence: {e}"), - } - } - if config.lazy_pool { emit_runtime_lifecycle( observer.as_ref(), @@ -2018,6 +2217,7 @@ async fn tokio_main() -> Result<()> { dedup_mode: config.dedup_mode, system_prompt: config.system_prompt.clone(), session_title: config.session_title.clone(), + codex_task_binding: config.codex_task_binding.clone(), team_instructions: config.team_instructions.clone(), base_prompt: if config.no_base_prompt { None @@ -2043,8 +2243,33 @@ async fn tokio_main() -> Result<()> { memory_enabled: config.memory_enabled, harness_name: crate::config::normalize_agent_command_identity(&config.agent_command), relay_url: config.relay_url.clone(), + agent_command: config.agent_command.clone(), + agent_args: config.agent_args.clone(), + session_store: std::sync::Arc::new(crate::session_store::SessionStore::open( + crate::session_store::SessionStore::default_path( + &config.agent_command, + &config.agent_args, + ), + )), }); + // A task-bound identity is only online after its exact Codex task has been + // loaded. This acquires the adapter's task writer lock before peers can send + // work and prevents Codex Desktop from owning the task at the same time. + pool.preload_identity_session(&ctx) + .await + .map_err(|error| anyhow::anyhow!("failed to load identity-bound Codex task: {error}"))?; + + // Online means the harness can receive work, not merely that its socket is + // connected. Publishing after channel subscriptions and task preloading gives + // desktop callers a durable readiness boundary before they send a mention. + if config.presence_enabled { + match publish_presence(&presence_publisher, &presence_keys, "online").await { + Ok(_) => tracing::info!("presence set to online"), + Err(e) => tracing::warn!("failed to set initial presence: {e}"), + } + } + if !config.memory_enabled { tracing::info!( target: "engram::core", @@ -2306,8 +2531,8 @@ async fn tokio_main() -> Result<()> { while let Ok(rr) = respawn_rx.try_recv() { crash_history[rr.index].respawn_in_flight = false; match rr.result { - Ok((acp, protocol_version, agent_name)) => { - let agent = OwnedAgent { + Ok((acp, protocol_version, agent_name, supports_load_session)) => { + let mut agent = OwnedAgent { index: rr.index, acp, state: SessionState::default(), @@ -2317,7 +2542,18 @@ async fn tokio_main() -> Result<()> { agent_name, goose_system_prompt_supported: None, protocol_version, + supports_load_session, }; + if let Err(error) = agent.preload_identity_session(&ctx).await { + crash_history[rr.index].mark_spawn_failed(); + tracing::warn!( + agent = rr.index, + error = %error, + "respawn could not reload identity-bound Codex task" + ); + agent.acp.shutdown().await; + continue; + } pool.return_agent(agent); tracing::info!(agent = rr.index, "respawn complete"); respawn_collected = true; @@ -2423,7 +2659,21 @@ async fn tokio_main() -> Result<()> { match control_event { Some(event) => { if let Some(ref owner_hex) = owner_cache.pubkey { - handle_relay_observer_control_event(&config.keys, event, &mut pool, observer.as_ref(), owner_hex); + let authorization = ObserverControlAuthorization { + owner_pubkey_hex: owner_hex, + respond_to: &config.respond_to, + respond_to_allowlist: &config.respond_to_allowlist, + owner_cache: &owner_cache, + rest_client: &ctx.rest_client, + }; + handle_relay_observer_control_event( + &config.keys, + event, + &mut pool, + observer.as_ref(), + &authorization, + &ctx.channel_info, + ).await; } else { tracing::warn!("observer control frame received but no owner resolved — dropping"); } @@ -2642,6 +2892,11 @@ async fn tokio_main() -> Result<()> { if is_rotate { if let Some(owner) = owner_cache.get() { if buzz_event.event.pubkey.to_hex() == *owner { + let durable_cleared = + pool::clear_durable_channel_binding( + &ctx, + &buzz_event.channel_id, + ); let fired = signal_in_flight_task( &mut pool, buzz_event.channel_id, @@ -2650,6 +2905,7 @@ async fn tokio_main() -> Result<()> { if fired { tracing::info!( channel_id = %buzz_event.channel_id, + durable_cleared, "!rotate received — cancelling in-flight turn and rotating session" ); } else { @@ -2657,6 +2913,7 @@ async fn tokio_main() -> Result<()> { tracing::info!( channel_id = %buzz_event.channel_id, invalidated, + durable_cleared, "!rotate received — invalidated idle channel session(s)" ); } @@ -2684,11 +2941,12 @@ async fn tokio_main() -> Result<()> { // exercised by non-owner authors inside DMs. let is_dm = is_dm_channel(buzz_event.channel_id, &ctx.channel_info).await; - let allowed = author_allowed( + let allowed = author_allowed_with_dm( &config.respond_to, &config.respond_to_allowlist, &author, is_dm, + config.allow_non_owner_dm, &owner_cache, &ctx.rest_client, ) @@ -2705,7 +2963,14 @@ async fn tokio_main() -> Result<()> { } } - let matched = filter::match_event(&buzz_event.event, buzz_event.channel_id, &rules, &pubkey_hex).await; + let matched = filter::match_event_with_display_name( + &buzz_event.event, + buzz_event.channel_id, + &rules, + &pubkey_hex, + config.session_title.as_deref(), + ) + .await; let prompt_tag = match matched { Some(m) => m.prompt_tag, None => { @@ -3309,7 +3574,7 @@ async fn tokio_main() -> Result<()> { // Drain any respawn results that completed before the abort. Explicitly // shut down returned agents instead of relying on AcpClient::Drop. while let Ok(rr) = respawn_rx.try_recv() { - if let Ok((mut acp, _, _)) = rr.result { + if let Ok((mut acp, _, _, _)) = rr.result { acp.shutdown().await; tracing::debug!(agent = rr.index, "reaped respawned agent on shutdown"); } @@ -4266,6 +4531,15 @@ mod agent_draft_prompt_tests { assert!(prompt.contains("Do not ask about runtime, provider, model, credentials")); } + #[test] + fn shared_base_prompt_teaches_encrypted_agent_handoffs() { + let prompt = include_str!("base_prompt.md"); + assert!(prompt.contains("buzz agents handoff send")); + assert!(prompt.contains("buzz agents handoff list")); + assert!(prompt.contains("hidden chain-of-thought")); + assert!(prompt.contains("point-in-time encrypted snapshot")); + } + #[test] fn shared_base_prompt_teaches_real_newlines_for_multiline_messages() { let prompt = include_str!("base_prompt.md"); @@ -4274,6 +4548,29 @@ mod agent_draft_prompt_tests { assert!(prompt.contains("buzz messages send ... --content -")); } + #[test] + fn shared_base_prompt_requires_filename_aware_file_delivery() { + let prompt = include_str!("base_prompt.md"); + assert!(prompt.contains("buzz messages send --channel ")); + assert!(prompt.contains("--file ./report.md")); + assert!(prompt.contains("do not publish its `url`")); + assert!(prompt.contains("retry the delivery with `buzz messages send --file`")); + assert!(prompt.contains("event context includes an `Attachments` manifest")); + assert!(prompt.contains("buzz media get --output ")); + assert!(prompt.contains("never assume another agent's local path is shared")); + } + + #[test] + fn shared_base_prompt_keeps_buzz_credentials_in_the_agent_mcp() { + let prompt = include_str!("base_prompt.md"); + assert!(prompt.contains("`buzz-dev-mcp` MCP server's `shell` tool")); + assert!(prompt.contains("Never run `buzz` through Codex's native `exec_command`")); + assert!(prompt.contains("per-session MCP server")); + assert!(prompt.contains("Do not search the workspace")); + assert!(prompt.contains("unless the current prompt contains `[Buzz Delivery]`")); + assert!(prompt.contains("let the harness publish it")); + } + #[test] fn shared_base_prompt_teaches_single_command_mentions_and_preflight() { let prompt = include_str!("base_prompt.md"); @@ -4478,6 +4775,8 @@ async fn initialize_agent_pool( }), ); let agent_name = normalized_agent_name(&init_result); + let supports_load_session = + AcpClient::agent_supports_load_session(&init_result); agent_slots.push(Some(OwnedAgent { index: i, acp, @@ -4488,6 +4787,7 @@ async fn initialize_agent_pool( agent_name, goose_system_prompt_supported: None, protocol_version, + supports_load_session, })); } Ok(Err(e)) => { @@ -4538,7 +4838,7 @@ async fn spawn_and_init( has_generated_codex_config: bool, agent_index: usize, observer: Option, -) -> Result<(AcpClient, u32, String)> { +) -> Result<(AcpClient, u32, String, bool)> { let mut acp = AcpClient::spawn(command, args, extra_env, has_generated_codex_config) .await .map_err(|e| anyhow::anyhow!("failed to spawn agent: {e}"))?; @@ -4548,6 +4848,7 @@ async fn spawn_and_init( Ok(init_result) => { tracing::info!("agent initialized: {init_result}"); let protocol_version = init_result["protocolVersion"].as_u64().unwrap_or(1) as u32; + let supports_load_session = AcpClient::agent_supports_load_session(&init_result); acp.observe( "agent_initialized", serde_json::json!({ @@ -4556,7 +4857,7 @@ async fn spawn_and_init( }), ); let agent_name = normalized_agent_name(&init_result); - Ok((acp, protocol_version, agent_name)) + Ok((acp, protocol_version, agent_name, supports_load_session)) } Err(e) => { // Explicitly shut down the spawned child to prevent zombie/leak. @@ -5101,6 +5402,56 @@ mod author_gate_tests { cache } + fn control_authorization<'a>( + owner_cache: &'a OwnerCache, + respond_to: &'a RespondTo, + respond_to_allowlist: &'a HashSet, + rest_client: &'a relay::RestClient, + ) -> ObserverControlAuthorization<'a> { + ObserverControlAuthorization { + owner_pubkey_hex: OWNER, + respond_to, + respond_to_allowlist, + owner_cache, + rest_client, + } + } + + #[tokio::test] + async fn shared_cancel_uses_instruction_author_policy() { + let cache = cache_with_sibling(); + let allowlist = HashSet::new(); + let rest_client = dummy_rest_client(); + let respond_to = RespondTo::Anyone; + let authorization = control_authorization(&cache, &respond_to, &allowlist, &rest_client); + assert!( + observer_control_author_allowed("cancel_turn", STRANGER, false, &authorization,).await, + "respond-to=anyone should let another channel user stop the turn" + ); + let respond_to = RespondTo::OwnerOnly; + let authorization = control_authorization(&cache, &respond_to, &allowlist, &rest_client); + assert!( + !observer_control_author_allowed("cancel_turn", STRANGER, false, &authorization,).await, + "owner-only must still reject an unrelated user's stop command" + ); + } + + #[tokio::test] + async fn model_switch_remains_owner_only_when_instructions_allow_anyone() { + let cache = cache_with_sibling(); + let allowlist = HashSet::new(); + let rest_client = dummy_rest_client(); + let respond_to = RespondTo::Anyone; + let authorization = control_authorization(&cache, &respond_to, &allowlist, &rest_client); + assert!( + !observer_control_author_allowed("switch_model", STRANGER, false, &authorization,) + .await + ); + assert!( + observer_control_author_allowed("switch_model", OWNER, false, &authorization,).await + ); + } + #[tokio::test] async fn test_allowlist_accepts_sibling_not_in_allowlist() { let cache = cache_with_sibling(); @@ -5255,6 +5606,24 @@ mod author_gate_tests { ); } + #[tokio::test] + async fn test_dm_allows_external_author_when_explicitly_enabled() { + let cache = cache_with_sibling(); + assert!( + author_allowed_with_dm( + &RespondTo::Anyone, + &HashSet::new(), + STRANGER, + true, + true, + &cache, + &dummy_rest_client() + ) + .await, + "the explicit DM access option must allow a non-owner author" + ); + } + #[tokio::test] async fn test_dm_admits_owner_and_sibling_in_every_responding_mode() { let cache = cache_with_sibling(); @@ -5460,6 +5829,52 @@ mod observer_snapshot_race_tests { ); } + #[tokio::test] + async fn relay_observer_event_is_encrypted_for_owner_and_turn_author() { + let agent_keys = Keys::generate(); + let owner_keys = Keys::generate(); + let author_keys = Keys::generate(); + let (publisher, mut published_rx) = RelayEventPublisher::test_pair(); + let mut frame = observer::ObserverEvent { + seq: 1, + timestamp: "2026-08-21T00:00:00Z".to_string(), + kind: "turn_started".to_string(), + agent_index: Some(0), + channel_id: Some(Uuid::new_v4().to_string()), + session_id: None, + turn_id: Some("turn-1".to_string()), + started_at: None, + viewer_pubkeys: vec![author_keys.public_key().to_hex()], + payload: serde_json::json!({}), + }; + + publish_relay_observer_event( + &publisher, + &agent_keys, + &agent_keys.public_key().to_hex(), + &owner_keys.public_key().to_hex(), + &owner_keys.public_key(), + frame.clone(), + ) + .await; + + let first = published_rx.recv().await.expect("owner frame"); + let second = published_rx.recv().await.expect("author frame"); + let events = [first, second]; + assert!(events.iter().any(|event| { + decrypt_observer_payload::(&owner_keys, event).is_ok() + })); + assert!(events.iter().any(|event| { + decrypt_observer_payload::(&author_keys, event).is_ok() + })); + + frame.viewer_pubkeys = vec![owner_keys.public_key().to_hex()]; + assert_eq!( + batch_envelope(&[frame]).viewer_pubkeys, + vec![owner_keys.public_key().to_hex()], + ); + } + /// An event emitted between `subscribe()` and `snapshot()` lands in BOTH /// the snapshot and the live receiver; the seq high-water dedupe must /// deliver it exactly once — and never lose events on either side of it. @@ -5534,6 +5949,7 @@ mod observer_publish_queue_tests { session_id: Some("session-1".to_string()), turn_id: Some("turn-1".to_string()), started_at: None, + viewer_pubkeys: Vec::new(), payload: serde_json::json!({ "seq": seq }), } } @@ -6402,6 +6818,7 @@ mod observer_chunk_coalescer_tests { session_id: Some("session-1".to_string()), turn_id: Some("turn-1".to_string()), started_at: None, + viewer_pubkeys: Vec::new(), payload: serde_json::json!({ "jsonrpc": "2.0", "method": "session/update", @@ -6430,6 +6847,7 @@ mod observer_chunk_coalescer_tests { session_id: Some("session-1".to_string()), turn_id: Some("turn-1".to_string()), started_at: None, + viewer_pubkeys: Vec::new(), payload: serde_json::json!({ "type": "turn_started" }), } } @@ -6525,9 +6943,11 @@ mod build_mcp_servers_tests { memory_enabled: false, model: None, session_title: None, + codex_task_binding: None, permission_mode: config::PermissionMode::BypassPermissions, respond_to: config::RespondTo::Anyone, respond_to_allowlist: std::collections::HashSet::new(), + allow_non_owner_dm: false, allowed_respond_to: vec![], persona_env_vars: vec![], has_generated_codex_config: false, @@ -6748,9 +7168,11 @@ mod error_outcome_emission_tests { memory_enabled: false, model: None, session_title: None, + codex_task_binding: None, permission_mode: config::PermissionMode::BypassPermissions, respond_to: config::RespondTo::Anyone, respond_to_allowlist: HashSet::new(), + allow_non_owner_dm: false, allowed_respond_to: vec![], persona_env_vars: vec![], has_generated_codex_config: false, @@ -6798,6 +7220,7 @@ mod error_outcome_emission_tests { // Error branches under test never read this; 1 is the legacy // non-systemPrompt path, the simplest valid value. protocol_version: 1, + supports_load_session: false, } } @@ -8213,6 +8636,7 @@ mod observer_payload_trim_tests { session_id: Some("sess-1".to_string()), turn_id: Some("turn-1".to_string()), started_at: None, + viewer_pubkeys: Vec::new(), payload, } } diff --git a/crates/buzz-acp/src/observer.rs b/crates/buzz-acp/src/observer.rs index 7029e5af6d..456bf93e53 100644 --- a/crates/buzz-acp/src/observer.rs +++ b/crates/buzz-acp/src/observer.rs @@ -1,8 +1,9 @@ //! In-process observer bus for ACP session activity. //! //! This is intentionally process-local infrastructure: it lets the harness -//! collect raw ACP JSON-RPC activity and publish owner-scoped encrypted relay -//! frames without exposing a local HTTP port. +//! collect raw ACP JSON-RPC activity and publish recipient-scoped encrypted +//! relay frames without exposing a local HTTP port. Every frame goes to the +//! owner; channel turns additionally go to the accepted request authors. use std::{ collections::VecDeque, @@ -28,6 +29,9 @@ pub struct ObserverContext { pub turn_id: Option, /// RFC3339 timestamp at which the current turn began, when known. pub started_at: Option, + /// Additional users allowed to observe this turn. The owner is always + /// included separately by the relay publisher. + pub viewer_pubkeys: Vec, } /// Handle used by the harness to publish local observer events. @@ -74,6 +78,9 @@ pub struct ObserverEvent { /// RFC3339 timestamp at which the current turn began, when known. #[serde(skip_serializing_if = "Option::is_none")] pub started_at: Option, + /// Additional recipients for this turn's encrypted relay telemetry. + #[serde(skip_serializing_if = "Vec::is_empty")] + pub viewer_pubkeys: Vec, /// Raw or semantic event payload. pub payload: serde_json::Value, } @@ -117,6 +124,7 @@ impl ObserverHandle { session_id: context.session_id.clone(), turn_id: context.turn_id.clone(), started_at: context.started_at.clone(), + viewer_pubkeys: context.viewer_pubkeys.clone(), payload, }; @@ -147,6 +155,7 @@ pub fn context_for( session_id, turn_id, started_at: None, + viewer_pubkeys: Vec::new(), } } @@ -162,5 +171,12 @@ pub fn context_for_turn( session_id, turn_id: Some(turn_id), started_at: Some(started_at), + viewer_pubkeys: Vec::new(), } } + +/// Attach additional encrypted relay recipients to an observer context. +pub fn with_viewers(mut context: ObserverContext, viewer_pubkeys: Vec) -> ObserverContext { + context.viewer_pubkeys = viewer_pubkeys; + context +} diff --git a/crates/buzz-acp/src/pool.rs b/crates/buzz-acp/src/pool.rs index 2efacce2b1..cfcf7d0c6d 100644 --- a/crates/buzz-acp/src/pool.rs +++ b/crates/buzz-acp/src/pool.rs @@ -32,19 +32,142 @@ use uuid::Uuid; use crate::acp::{ extract_model_config_options, extract_model_state, model_in_catalog, resolve_model_switch_method, AcpClient, AcpError, EnvVar, McpServer, ModelSwitchMethod, - StopReason, SystemPromptTransport, + SessionNewResponse, StopReason, SystemPromptTransport, }; -use crate::config::{compose_session_title, DedupMode, PermissionMode}; +use crate::config::{compose_session_title, CodexTaskBinding, DedupMode, PermissionMode}; use crate::observer; use crate::queue::{ CancelReason, ContextMessage, ConversationContext, FlushBatch, PromptChannelInfo, PromptProfile, PromptProfileLookup, ThreadTags, }; use crate::relay::{ChannelInfo, RestClient}; +use crate::session_store::SessionBindingMode; /// Window within which agent activity before a hard-cap death qualifies /// the turn as "recently active" (eligible for requeue instead of dead-letter). const RECENT_ACTIVITY_WINDOW: Duration = Duration::from_secs(60); +const MAX_PROMPT_IMAGES: usize = 4; +const MAX_PROMPT_IMAGE_BYTES: usize = 5 * 1024 * 1024; +const MAX_PROMPT_IMAGE_TOTAL_BYTES: usize = 8 * 1024 * 1024; + +#[derive(Debug, Clone, PartialEq, Eq)] +struct PromptImageAttachment { + url: String, + mime_type: String, + declared_size: usize, +} + +fn prompt_image_attachments(batch: &FlushBatch) -> Vec { + let mut seen = HashSet::new(); + let mut total = 0usize; + let mut images = Vec::new(); + for event in batch.cancelled_events.iter().chain(&batch.events) { + for tag in event.event.tags.iter() { + let parts = tag.as_slice(); + if parts.first().map(String::as_str) != Some("imeta") { + continue; + } + let mut fields = HashMap::new(); + for part in parts.iter().skip(1) { + if let Some((key, value)) = part.split_once(' ') { + fields.insert(key, value); + } + } + let Some(url) = fields.get("url").copied() else { + continue; + }; + let Some(mime_type) = fields.get("m").copied() else { + continue; + }; + if !matches!( + mime_type, + "image/png" | "image/jpeg" | "image/gif" | "image/webp" + ) || !seen.insert(url.to_string()) + { + continue; + } + let Some(declared_size) = fields + .get("size") + .and_then(|value| value.parse::().ok()) + else { + continue; + }; + if declared_size == 0 + || declared_size > MAX_PROMPT_IMAGE_BYTES + || total.saturating_add(declared_size) > MAX_PROMPT_IMAGE_TOTAL_BYTES + { + continue; + } + total += declared_size; + images.push(PromptImageAttachment { + url: url.to_string(), + mime_type: mime_type.to_string(), + declared_size, + }); + if images.len() == MAX_PROMPT_IMAGES { + return images; + } + } + } + images +} + +fn bytes_match_image_type(mime_type: &str, bytes: &[u8]) -> bool { + match mime_type { + "image/png" => bytes.starts_with(b"\x89PNG\r\n\x1a\n"), + "image/jpeg" => bytes.starts_with(&[0xff, 0xd8, 0xff]), + "image/gif" => bytes.starts_with(b"GIF87a") || bytes.starts_with(b"GIF89a"), + "image/webp" => { + bytes.starts_with(b"RIFF") && bytes.get(8..12).is_some_and(|value| value == b"WEBP") + } + _ => false, + } +} + +async fn native_prompt_image_blocks( + batch: &FlushBatch, + rest: &RestClient, +) -> Vec { + use base64::{engine::general_purpose::STANDARD, Engine as _}; + use futures_util::future::join_all; + + let images = prompt_image_attachments(batch); + let downloads = join_all( + images + .iter() + .map(|image| rest.download_media(&image.url, image.declared_size)), + ) + .await; + images + .into_iter() + .zip(downloads) + .filter_map(|(image, result)| match result { + Ok(bytes) if bytes_match_image_type(&image.mime_type, &bytes) => { + Some(serde_json::json!({ + "type": "image", + "data": STANDARD.encode(bytes), + "mimeType": image.mime_type, + })) + } + Ok(_) => { + tracing::warn!( + target: "pool::media", + url = %image.url, + "relay image bytes did not match declared MIME type" + ); + None + } + Err(error) => { + tracing::warn!( + target: "pool::media", + url = %image.url, + "could not prefetch relay image: {error}" + ); + None + } + }) + .collect() +} // FlushBatch and BatchEvent derive Clone (added in queue.rs) so we can store // a recoverable copy in TaskMeta for panic recovery in Queue mode. @@ -90,6 +213,53 @@ pub struct AgentModelCapabilities { pub available_models_raw: Option, } +fn model_capabilities_from_response( + response: &serde_json::Value, +) -> Option { + let config_options_raw = extract_model_config_options(response); + let available_models_raw = response + .get("models") + .filter(|models| models.is_object()) + .cloned(); + if config_options_raw.is_empty() && available_models_raw.is_none() { + None + } else { + Some(AgentModelCapabilities { + config_options_raw, + available_models_raw, + }) + } +} + +#[derive(Debug, PartialEq)] +enum LoadModelResolution { + Method(ModelSwitchMethod), + Unsupported, + Unverifiable, +} + +fn resolve_load_model_switch( + load_response: &serde_json::Value, + cached: Option<&AgentModelCapabilities>, + desired_model: &str, +) -> LoadModelResolution { + if model_capabilities_from_response(load_response).is_some() { + return resolve_model_switch_method(load_response, desired_model) + .map(LoadModelResolution::Method) + .unwrap_or(LoadModelResolution::Unsupported); + } + + let Some(cached) = cached else { + return LoadModelResolution::Unverifiable; + }; + let cached_response = serde_json::json!({ + "configOptions": cached.config_options_raw, + "models": cached.available_models_raw, + }); + resolve_model_switch_method(&cached_response, desired_model) + .map(LoadModelResolution::Method) + .unwrap_or(LoadModelResolution::Unsupported) +} /// Successful deliveries associated with one live channel session. #[derive(Default)] pub struct ChannelDeliveryState { @@ -108,6 +278,15 @@ pub struct ChannelDeliveryState { pub struct SessionState { /// channel_id → session_id pub sessions: HashMap, + /// Session owned by the Managed Agent identity rather than one Room. + pub identity_session: Option, + /// Whether the first Room turn still needs the identity handoff context. + pub identity_handoff_pending: bool, + /// Whether the shared identity session has successfully received standing + /// context. All Room aliases share this flag because they share one Codex + /// task, while delivered event IDs remain scoped to each Room. + pub identity_standing_context_sent: bool, + pub identity_turn_count: u32, pub heartbeat_session: Option, /// Per-channel turn counters for proactive session rotation. /// Incremented on each successful prompt; reset when the session is rotated. @@ -136,7 +315,11 @@ impl SessionState { pub fn invalidate(&mut self, source: &PromptSource) { match source { PromptSource::Channel(cid) => { - self.invalidate_channel(cid); + if self.identity_session.is_some() { + self.invalidate_identity_session(); + } else { + self.invalidate_channel(cid); + } } PromptSource::Heartbeat => { self.heartbeat_session = None; @@ -156,10 +339,26 @@ impl SessionState { self.sessions.remove(channel_id).is_some() } + fn invalidate_identity_session(&mut self) { + self.sessions.clear(); + self.turn_counts.clear(); + self.core_sections.clear(); + self.canvas_sections.clear(); + self.deliveries.clear(); + self.identity_session = None; + self.identity_handoff_pending = false; + self.identity_standing_context_sent = false; + self.identity_turn_count = 0; + } + /// Invalidate all sessions and turn counters (e.g. after agent exit). pub fn invalidate_all(&mut self) { self.sessions.clear(); self.turn_counts.clear(); + self.identity_session = None; + self.identity_handoff_pending = false; + self.identity_standing_context_sent = false; + self.identity_turn_count = 0; self.heartbeat_session = None; self.heartbeat_turn_count = 0; self.heartbeat_standing_context_sent = false; @@ -174,11 +373,24 @@ impl SessionState { standing_context_sent: bool, event_ids: impl IntoIterator, ) { + if self.identity_session.is_some() { + self.identity_standing_context_sent |= standing_context_sent; + } let delivery = self.deliveries.entry(channel_id).or_default(); delivery.standing_context_sent |= standing_context_sent; delivery.delivered_event_ids.extend(event_ids); } + fn standing_context_sent_for_channel(&self, channel_id: &Uuid) -> bool { + if self.identity_session.is_some() { + self.identity_standing_context_sent + } else { + self.deliveries + .get(channel_id) + .is_some_and(|delivery| delivery.standing_context_sent) + } + } + #[cfg(test)] fn has_channel_state(&self, channel_id: &Uuid) -> bool { self.sessions.contains_key(channel_id) @@ -211,6 +423,8 @@ pub struct OwnedAgent { pub goose_system_prompt_supported: Option, /// Protocol version reported by the agent in its initialize response. pub protocol_version: u32, + /// Whether the agent advertised `agentCapabilities.loadSession` at init. + pub supports_load_session: bool, } /// Package name reported by `claude-agent-acp` in its `initialize` response. @@ -257,6 +471,29 @@ impl OwnedAgent { self.goose_system_prompt_supported, ) } + + /// Load the task owned by this Managed Agent before it is announced online. + pub(crate) async fn preload_identity_session( + &mut self, + ctx: &PromptContext, + ) -> Result, AcpError> { + let Some(binding) = ctx.codex_task_binding.as_ref() else { + return Ok(None); + }; + let loaded = restore_identity_session(self, ctx, binding).await?; + let session_id = loaded.session_id; + self.state.identity_session = Some(session_id.clone()); + self.state.identity_handoff_pending = true; + self.state.identity_standing_context_sent = loaded.loaded_buzz_standing_context; + self.state.identity_turn_count = 0; + tracing::info!( + target: "pool::session", + task_id = %binding.task_id, + workspace = %binding.workspace, + "preloaded identity-bound Codex task" + ); + Ok(Some(session_id)) + } } /// Pool of agents with take-and-return ownership semantics. @@ -310,6 +547,25 @@ fn apply_completed_before_control_signal( } } +fn control_signal_discards_session(control_signal: &ControlSignal) -> bool { + matches!( + control_signal, + ControlSignal::Rotate | ControlSignal::SwitchModel(_) + ) +} + +pub(crate) fn clear_durable_channel_binding(ctx: &PromptContext, channel_id: &Uuid) -> bool { + ctx.session_store + .remove(&ctx.agent_command, &ctx.agent_args, channel_id) +} + +fn clear_durable_source_binding(ctx: &PromptContext, source: &PromptSource) -> bool { + match source { + PromptSource::Channel(channel_id) => clear_durable_channel_binding(ctx, channel_id), + PromptSource::Heartbeat => false, + } +} + /// Control signal for an in-flight channel turn. /// /// Not `Copy`: `SwitchModel` carries an owned `String`. Callers must clone when @@ -606,6 +862,14 @@ pub struct PromptContext { /// the desktop keys per (agent, relay) pair, e.g. `session_config_captured`, /// mirroring the `managed_agent_runtime_lifecycle` frames. pub relay_url: String, + /// Agent binary as configured (for durable session binding identity). + pub agent_command: String, + /// Agent args as configured (for durable session binding identity). + pub agent_args: Vec, + /// Durable channel→session bindings surviving harness restarts. + pub session_store: std::sync::Arc, + /// Existing Codex task owned by this Managed Agent identity. + pub codex_task_binding: Option, } impl AgentPool { @@ -671,6 +935,24 @@ impl AgentPool { self.agents.iter().any(|slot| slot.is_some()) } + /// Preload the identity-bound task while the harness is still offline. + pub async fn preload_identity_session( + &mut self, + ctx: &PromptContext, + ) -> Result, AcpError> { + if ctx.codex_task_binding.is_none() { + return Ok(None); + } + let mut agent = self.try_claim(None).ok_or_else(|| { + AcpError::Protocol( + "no initialized ACP worker is available to load the Codex task".into(), + ) + })?; + let result = agent.preload_identity_session(ctx).await; + self.return_agent(agent); + result + } + /// Whether any idle agent already has a session for `channel_id`. /// Used to compute `affinity_hit` before calling `try_claim`. pub fn has_session_for(&self, channel_id: Uuid) -> bool { @@ -868,7 +1150,7 @@ impl AgentPool { agent.desired_model = Some(model_id.to_string()); agent.model_overridden = true; - agent.state.invalidate_channel(&channel_id); + agent.state.invalidate(&PromptSource::Channel(channel_id)); IdleSwitchResult::Switched } } @@ -952,6 +1234,254 @@ async fn resolve_new_session_channel_context( (is_dm, title_channel, Some(info.channel_type)) } +/// Restore a durable channel session by loading it or forking it once. +/// +/// A fork binding is replaced atomically with a resumable child binding after +/// success. Fork failures preserve the source binding and fail the turn instead +/// of silently creating an unrelated task. +struct LoadedSession { + session_id: String, + workspace: Option, + source_session_id: Option, + loaded_buzz_standing_context: bool, +} + +async fn apply_loaded_session_settings( + agent: &mut OwnedAgent, + ctx: &PromptContext, + response: &SessionNewResponse, +) { + if agent.model_capabilities.is_none() { + agent.model_capabilities = model_capabilities_from_response(&response.raw); + } + if let Some(ref desired) = agent.desired_model { + match resolve_load_model_switch(&response.raw, agent.model_capabilities.as_ref(), desired) { + LoadModelResolution::Method(method) => { + if let Err(error) = + apply_model_switch(&mut agent.acp, &response.session_id, desired, &method).await + { + tracing::warn!( + target: "pool::session", + error = %error, + "model re-apply after session/load failed — continuing with loaded session" + ); + } + } + LoadModelResolution::Unsupported => { + tracing::warn!( + target: "pool::model", + "desired model {desired} is absent from the advertised model catalog after session/load" + ); + } + LoadModelResolution::Unverifiable => { + tracing::debug!( + target: "pool::model", + "session/load returned no model catalog and none is cached — leaving desired model unverifiable" + ); + } + } + } + if !ctx.permission_mode.is_default() + && agent_supports_mode(&response.raw, ctx.permission_mode.as_wire_str()) + { + if let Err(error) = + apply_permission_mode(&mut agent.acp, &response.session_id, &ctx.permission_mode).await + { + tracing::warn!( + target: "pool::session", + error = %error, + "permission mode after session/load failed — continuing" + ); + } + } +} + +async fn restore_identity_session( + agent: &mut OwnedAgent, + ctx: &PromptContext, + binding: &CodexTaskBinding, +) -> Result { + if !agent.supports_load_session { + return Err(AcpError::Protocol( + "the configured Codex adapter does not advertise session/load; update the adapter before starting this task-bound agent" + .into(), + )); + } + let response = agent + .acp + .session_load_full( + &binding.workspace, + &binding.task_id, + ctx.mcp_servers.clone(), + ) + .await?; + apply_loaded_session_settings(agent, ctx, &response).await; + Ok(LoadedSession { + session_id: response.session_id, + workspace: Some(binding.workspace.clone()), + source_session_id: None, + loaded_buzz_standing_context: response.loaded_buzz_standing_context, + }) +} + +async fn try_restore_persisted_session( + agent: &mut OwnedAgent, + ctx: &PromptContext, + channel_id: &Uuid, + _agent_core: Option<&str>, + _agent_canvas: Option<&str>, +) -> Result, AcpError> { + let Some(stored) = ctx + .session_store + .get(&ctx.agent_command, &ctx.agent_args, channel_id) + else { + return Ok(None); + }; + let load_cwd = stored.workspace.as_deref().unwrap_or(&ctx.cwd); + let is_fork = stored.mode == SessionBindingMode::Fork; + if is_fork && !agent.acp.fork_session_supported() { + return Err(AcpError::Protocol( + "stored binding requires session/fork, but the agent did not advertise it".into(), + )); + } + if !is_fork && !agent.supports_load_session { + return Ok(None); + } + + let restore_result = if is_fork { + agent + .acp + .session_fork_full(load_cwd, &stored.session_id, ctx.mcp_servers.clone()) + .await + } else { + agent + .acp + .session_load_full(load_cwd, &stored.session_id, ctx.mcp_servers.clone()) + .await + }; + + match restore_result { + Ok(resp) => { + apply_loaded_session_settings(agent, ctx, &resp).await; + let source_session_id = if is_fork { + ctx.session_store.put_fork_result( + &ctx.agent_command, + &ctx.agent_args, + channel_id, + &resp.session_id, + stored.workspace.as_deref(), + &stored.session_id, + ); + Some(stored.session_id) + } else { + stored.source_session_id + }; + Ok(Some(LoadedSession { + session_id: resp.session_id, + workspace: stored.workspace, + source_session_id, + loaded_buzz_standing_context: resp.loaded_buzz_standing_context, + })) + } + Err(e) if !is_fork && load_failure_is_definitive(&e) => { + tracing::warn!( + target: "pool::session", + session_id = %stored.session_id, + channel_id = %channel_id, + error = %e, + "session/load rejected by agent — clearing stale binding (if unchanged) and creating a new session" + ); + // Only drop the binding we failed to load. A concurrent process may + // already have written a newer session for this channel. + let _ = ctx.session_store.remove_if_equals( + &ctx.agent_command, + &ctx.agent_args, + channel_id, + &stored.session_id, + ); + Ok(None) + } + Err(e) if !is_fork => { + tracing::warn!( + target: "pool::session", + session_id = %stored.session_id, + channel_id = %channel_id, + error = %e, + "session/load outcome indeterminate — keeping binding and creating a new session; \ + the stored session may still be live on the provider" + ); + Ok(None) + } + Err(e) => { + tracing::warn!( + target: "pool::session", + source_session_id = %stored.session_id, + channel_id = %channel_id, + error = %e, + "session/fork failed — preserving the source binding and refusing an unrelated new session" + ); + Err(e) + } + } +} + +fn render_task_handoff( + session_id: &str, + workspace: &str, + source_session_id: Option<&str>, +) -> String { + if let Some(source_session_id) = source_session_id { + format!( + "[Task Handoff]\n\ + This Buzz channel owns an independent Codex task forked from a pre-existing local task.\n\ + Continue the forked task's history, objective, unfinished work, and workspace.\n\ + Do not write Buzz room prompts back into the source task. Room history is collaboration input, not a replacement for the forked task history.\n\ + When asked what you are doing, summarize the forked task before unrelated room connectivity or media probes.\n\ + Buzz Codex task ID: {session_id}\n\ + Source Codex task ID: {source_session_id}\n\ + Workspace: {workspace}" + ) + } else { + format!( + "[Task Handoff]\n\ + This Buzz channel is exclusively resuming a pre-existing Codex task.\n\ + Treat that task's history, objective, unfinished work, and workspace as the work owned by this channel.\n\ + Buzz room history is collaboration input; it does not replace or invalidate the loaded task history.\n\ + When asked what you are doing, summarize the loaded task before unrelated room connectivity or media probes.\n\ + Codex task ID: {session_id}\n\ + Workspace: {workspace}" + ) + } +} + +fn render_identity_task_handoff(session_id: &str, workspace: &str) -> String { + format!( + "[Task Handoff]\n\ + This Buzz Managed Agent identity is bound to a pre-existing Codex task.\n\ + Continue that task's history, objective, unfinished work, and workspace.\n\ + Messages from every Buzz Room this identity joins are collaboration input to this same task; Rooms do not create separate Codex tasks.\n\ + When asked what you are doing, summarize the loaded task before unrelated room connectivity or media probes.\n\ + Codex client access depends on the selected connection: shared app-server clients may remain connected, while exclusive ACP requires stopping this Buzz agent before local reuse.\n\ + Codex task ID: {session_id}\n\ + Workspace: {workspace}" + ) +} + +/// Whether a failed `session/load` proves the stored binding is dead. +/// +/// Only a JSON-RPC error response is definitive: the provider answered and +/// refused, so the session is genuinely gone and the binding is safe to drop. +/// +/// Everything else is indeterminate. A timeout, transport failure or malformed +/// response does NOT prove the provider failed to load — it may hold the session +/// open. Dropping the binding on those and falling through to `session/new` +/// would fork hidden provider state: two live sessions, one unreachable. Keeping +/// the mapping is self-healing, because a provider that has genuinely lost the +/// session answers `AgentError` on a later attempt and that clears it then. +fn load_failure_is_definitive(error: &AcpError) -> bool { + matches!(error, AcpError::AgentError { .. }) +} + /// Create a new ACP session via `session_new_full()`, populate model capabilities /// on the agent (first session only), and apply `desired_model` if set. /// @@ -1492,12 +2022,27 @@ pub async fn run_prompt_task( PromptSource::Channel(channel_id) => Some(*channel_id), PromptSource::Heartbeat => None, }; + let observer_viewers: Vec = batch + .as_ref() + .map(|batch| { + batch + .events + .iter() + .map(|event| event.event.pubkey.to_hex()) + .collect::>() + .into_iter() + .collect() + }) + .unwrap_or_default(); let turn_started_at = chrono::Utc::now().to_rfc3339(); - agent.acp.set_observer_context(observer::context_for_turn( - observer_channel_id, - None, - turn_id.clone(), - turn_started_at.clone(), + agent.acp.set_observer_context(observer::with_viewers( + observer::context_for_turn( + observer_channel_id, + None, + turn_id.clone(), + turn_started_at.clone(), + ), + observer_viewers.clone(), )); let triggering_event_ids: Vec = batch .as_ref() @@ -1523,6 +2068,7 @@ pub async fn run_prompt_task( agent.acp.observer_agent_index(), observer_channel_id, turn_id.clone(), + observer_viewers.clone(), ); // Start liveness with `turn_started`, not the final session/prompt call: @@ -1541,11 +2087,14 @@ pub async fn run_prompt_task( let liveness = run_turn_liveness( agent.acp.observer_handle(), agent.acp.observer_agent_index(), - observer::context_for_turn( - observer_channel_id, - None, - turn_id.clone(), - turn_started_at.clone(), + observer::with_viewers( + observer::context_for_turn( + observer_channel_id, + None, + turn_id.clone(), + turn_started_at.clone(), + ), + observer_viewers.clone(), ), ctx.turn_liveness_interval, Arc::clone(&liveness_state), @@ -1685,10 +2234,113 @@ pub async fn run_prompt_task( PromptSource::Heartbeat => None, }; - let (session_id, is_new_session) = match &source { + let (session_id, is_new_session, task_handoff) = match &source { PromptSource::Channel(cid) => { if let Some(sid) = agent.state.sessions.get(cid) { - (sid.clone(), false) + (sid.clone(), false, None) + } else if let Some(sid) = agent.state.identity_session.clone() { + let task_handoff = if agent.state.identity_handoff_pending { + agent.state.identity_handoff_pending = false; + ctx.codex_task_binding + .as_ref() + .map(|binding| render_identity_task_handoff(&sid, &binding.workspace)) + } else { + None + }; + agent.state.sessions.insert(*cid, sid.clone()); + if let Some((pending_cid, section)) = pending_canvas.take() { + agent.state.canvas_sections.insert(pending_cid, section); + } + tracing::info!( + target: "pool::session", + "aliased channel {cid} to identity session {sid}" + ); + (sid, false, task_handoff) + } else if let Some(binding) = ctx.codex_task_binding.as_ref() { + let loaded = match restore_identity_session(&mut agent, &ctx, binding).await { + Ok(loaded) => loaded, + Err(error) => { + tracing::warn!( + target: "pool::session", + task_id = %binding.task_id, + error = %error, + "failed to load identity-bound Codex task; preserving the binding" + ); + send_prompt_result( + &result_tx, + &turn_id, + agent, + source, + PromptOutcome::Error(error), + requeue_batch_if_queue(&ctx, batch), + ); + return; + } + }; + let sid = loaded.session_id; + let handoff = Some(render_identity_task_handoff( + &sid, + loaded.workspace.as_deref().unwrap_or(&ctx.cwd), + )); + tracing::info!( + target: "pool::session", + "loaded identity-bound Codex task {sid} for channel {cid}" + ); + agent.state.identity_session = Some(sid.clone()); + agent.state.identity_handoff_pending = false; + agent.state.identity_standing_context_sent = loaded.loaded_buzz_standing_context; + agent.state.sessions.insert(*cid, sid.clone()); + if let Some((pending_cid, section)) = pending_canvas.take() { + agent.state.canvas_sections.insert(pending_cid, section); + } + (sid, false, handoff) + } else if let Some(loaded) = match try_restore_persisted_session( + &mut agent, + &ctx, + cid, + agent_core.as_deref(), + agent_canvas.as_deref(), + ) + .await + { + Ok(loaded) => loaded, + Err(e) => { + send_prompt_result( + &result_tx, + &turn_id, + agent, + source, + PromptOutcome::Error(e), + requeue_batch_if_queue(&ctx, batch), + ); + return; + } + } { + let handoff = loaded.workspace.as_deref().map(|workspace| { + render_task_handoff( + &loaded.session_id, + workspace, + loaded.source_session_id.as_deref(), + ) + }); + let sid = loaded.session_id; + tracing::info!( + target: "pool::session", + "restored session {sid} for channel {cid}" + ); + agent.state.sessions.insert(*cid, sid.clone()); + if loaded.loaded_buzz_standing_context { + agent + .state + .deliveries + .entry(*cid) + .or_default() + .standing_context_sent = true; + } + if let Some((pending_cid, section)) = pending_canvas.take() { + agent.state.canvas_sections.insert(pending_cid, section); + } + (sid, false, handoff) } else { // The title is channel-qualified (`Agent · #channel`) so one // agent in several channels doesn't produce identical session @@ -1714,6 +2366,8 @@ pub async fn run_prompt_task( "created session {sid} for channel {cid}" ); agent.state.sessions.insert(*cid, sid.clone()); + ctx.session_store + .put(&ctx.agent_command, &ctx.agent_args, cid, &sid); agent .state .deliveries @@ -1725,7 +2379,7 @@ pub async fn run_prompt_task( if let Some((pending_cid, section)) = pending_canvas.take() { agent.state.canvas_sections.insert(pending_cid, section); } - (sid, true) + (sid, true, None) } Err(AcpError::AgentExited) => { agent.state.invalidate_all(); @@ -1757,7 +2411,7 @@ pub async fn run_prompt_task( } PromptSource::Heartbeat => { if let Some(sid) = &agent.state.heartbeat_session { - (sid.clone(), false) + (sid.clone(), false, None) } else { match create_session_and_apply_model( &mut agent, @@ -1782,7 +2436,7 @@ pub async fn run_prompt_task( agent.state.heartbeat_session = Some(sid.clone()); // Seed a zero usage baseline: buzz-acp spawned this session. agent.acp.notify_session_spawned(&sid); - (sid, true) + (sid, true, None) } Err(AcpError::AgentExited) => { agent.state.invalidate_all(); @@ -1811,11 +2465,14 @@ pub async fn run_prompt_task( } } }; - agent.acp.set_observer_context(observer::context_for_turn( - observer_channel_id, - Some(session_id.clone()), - turn_id.clone(), - turn_started_at, + agent.acp.set_observer_context(observer::with_viewers( + observer::context_for_turn( + observer_channel_id, + Some(session_id.clone()), + turn_id.clone(), + turn_started_at, + ), + observer_viewers, )); // Backfill liveness's shared session ID so ticks after this point carry // it too, matching every other observer frame for this turn. @@ -1847,11 +2504,7 @@ pub async fn run_prompt_task( // sessions created before this field existed fail safe by behaving as // undelivered once, rather than silently omitting standing context. let mut standing_context_sent = match &source { - PromptSource::Channel(cid) => agent - .state - .deliveries - .get(cid) - .is_some_and(|delivery| delivery.standing_context_sent), + PromptSource::Channel(cid) => agent.state.standing_context_sent_for_channel(cid), PromptSource::Heartbeat => agent.state.heartbeat_standing_context_sent, }; @@ -2017,6 +2670,7 @@ pub async fn run_prompt_task( // Event IDs represented by this prompt. Commit only after ACP reports a // successful turn; failed/cancelled prompts must be retryable without loss. let mut pending_delivered_event_ids = HashSet::new(); + let mut turn_profile_lookup: Option = None; let prompt_sections: Vec = if let Some(text) = prompt_text { // Heartbeats create their session before this point, so a Goose method-not-found // probe has already selected the correct framing for this process. @@ -2095,7 +2749,7 @@ pub async fn run_prompt_task( ); } - crate::queue::format_prompt( + let prompt = crate::queue::format_prompt( b, &crate::queue::FormatPromptArgs { agent_core: standing.agent_core, @@ -2110,8 +2764,12 @@ pub async fn run_prompt_task( team_instructions: standing.team_instructions, agent_canvas: standing.agent_canvas, standing_context_sent, + task_handoff: task_handoff.as_deref(), + task_bound_auto_delivery: ctx.codex_task_binding.is_some(), }, - ) + ); + turn_profile_lookup = profile_lookup; + prompt } else { // Should not happen — batch is None only for heartbeats which have prompt_text. // Return the agent to the pool to prevent a permanent slot leak. @@ -2149,6 +2807,23 @@ pub async fn run_prompt_task( .collect(), None => prompt_sections.iter().map(String::as_str).collect(), }; + let mut prompt_content = prompt_blocks + .iter() + .map(|text| serde_json::json!({ "type": "text", "text": text })) + .collect::>(); + if agent.acp.image_prompt_supported() { + if let Some(batch) = batch.as_ref() { + let image_blocks = native_prompt_image_blocks(batch, &ctx.rest_client).await; + if !image_blocks.is_empty() { + tracing::info!( + target: "pool::media", + count = image_blocks.len(), + "prefetched relay images as native ACP content blocks" + ); + prompt_content.extend(image_blocks); + } + } + } let prompt_bytes: usize = prompt_blocks.iter().map(|block| block.len()).sum(); let has_standing_context = match &source { PromptSource::Channel(_) => !standing.sections().is_empty(), @@ -2193,9 +2868,9 @@ pub async fn run_prompt_task( // Heartbeat / non-cancellable path. tokio::select! { biased; - result = agent.acp.session_prompt_blocks_with_idle_timeout( + result = agent.acp.session_prompt_content_with_idle_timeout( &session_id, - &prompt_blocks, + &prompt_content, ctx.idle_timeout, ctx.max_turn_duration, ) => result, @@ -2204,14 +2879,16 @@ pub async fn run_prompt_task( Some(rx) => { tokio::select! { biased; - result = agent.acp.session_prompt_blocks_with_idle_timeout( + result = agent.acp.session_prompt_content_with_idle_timeout( &session_id, - &prompt_blocks, + &prompt_content, ctx.idle_timeout, ctx.max_turn_duration, ) => result, mode = rx => { let control_signal = mode.unwrap_or(ControlSignal::Cancel); + let discard_durable_session = + control_signal_discards_session(&control_signal); // Land the model switch before any cancel/requeue work: setting // `desired_model` here means the fresh session created by the // requeued turn (busy) or the next turn (already-completed) @@ -2232,6 +2909,9 @@ pub async fn run_prompt_task( Ok(stop_reason) => { log_stop_reason(&source, &stop_reason); agent.state.invalidate(&source); + if discard_durable_session { + clear_durable_source_binding(&ctx, &source); + } let retry_batch = requeue_cancelled_batch(&ctx, control_signal, batch); @@ -2270,6 +2950,9 @@ pub async fn run_prompt_task( } else { agent.state.invalidate(&source); } + if discard_durable_session { + clear_durable_source_binding(&ctx, &source); + } let usage = agent.acp.take_turn_usage(); publish_agent_turn_metric( @@ -2334,6 +3017,9 @@ pub async fn run_prompt_task( &source, &control_signal, ); + if discard_durable_session { + clear_durable_source_binding(&ctx, &source); + } let usage = agent.acp.take_turn_usage(); publish_agent_turn_metric( &ctx, @@ -2374,6 +3060,15 @@ pub async fn run_prompt_task( agent.state.heartbeat_standing_context_sent = true; } + let final_answer = agent.acp.take_turn_final_answer(); + publish_task_bound_final_answer( + &ctx, + &source, + batch.as_ref(), + final_answer.as_deref(), + turn_profile_lookup.as_ref(), + ) + .await; let should_rotate = matches!( stop_reason, StopReason::MaxTokens | StopReason::MaxTurnRequests @@ -2384,9 +3079,14 @@ pub async fn run_prompt_task( if limit > 0 { match &source { PromptSource::Channel(cid) => { - let count = agent.state.turn_counts.entry(*cid).or_insert(0); - *count += 1; - *count >= limit + if agent.state.identity_session.is_some() { + agent.state.identity_turn_count += 1; + agent.state.identity_turn_count >= limit + } else { + let count = agent.state.turn_counts.entry(*cid).or_insert(0); + *count += 1; + *count >= limit + } } PromptSource::Heartbeat => { agent.state.heartbeat_turn_count += 1; @@ -2404,6 +3104,7 @@ pub async fn run_prompt_task( "rotating session for {source:?} after {stop_reason:?}", ); agent.state.invalidate(&source); + clear_durable_source_binding(&ctx, &source); } let core_stop = acp_stop_to_core(&stop_reason); @@ -3977,6 +4678,7 @@ struct TurnCompletionGuard { agent_index: Option, channel_id: Option, turn_id: String, + viewer_pubkeys: Vec, } impl TurnCompletionGuard { @@ -3985,12 +4687,14 @@ impl TurnCompletionGuard { agent_index: Option, channel_id: Option, turn_id: String, + viewer_pubkeys: Vec, ) -> Self { Self { observer, agent_index, channel_id, turn_id, + viewer_pubkeys, } } } @@ -3998,7 +4702,10 @@ impl TurnCompletionGuard { impl Drop for TurnCompletionGuard { fn drop(&mut self) { if let Some(observer) = self.observer.take() { - let context = observer::context_for(self.channel_id, None, Some(self.turn_id.clone())); + let context = observer::with_viewers( + observer::context_for(self.channel_id, None, Some(self.turn_id.clone())), + self.viewer_pubkeys.clone(), + ); observer.emit( "turn_completed", self.agent_index, @@ -4281,6 +4988,189 @@ pub(crate) async fn post_failure_notice( } } +/// Resolve visible @mentions in an automatically published task-bound reply +/// to the profile keys already loaded for the current prompt. +fn task_bound_reply_mentions( + content: &str, + profiles: Option<&PromptProfileLookup>, + sender_pubkey: &str, +) -> Vec { + let Some(profiles) = profiles else { + return Vec::new(); + }; + let known_names: Vec<&str> = profiles + .values() + .flat_map(|profile| { + [ + profile.display_name.as_deref(), + profile.nip05_handle.as_deref(), + ] + }) + .flatten() + .collect(); + let stripped = buzz_sdk::mentions::strip_code_regions(content); + let names = buzz_sdk::mentions::extract_at_mentions_with_known(&stripped, &known_names); + let sender = sender_pubkey.to_ascii_lowercase(); + let mut mentioned = Vec::new(); + for (pubkey, profile) in profiles { + let matches = [ + profile.display_name.as_deref(), + profile.nip05_handle.as_deref(), + ] + .into_iter() + .flatten() + .any(|name| { + names + .iter() + .any(|mention| mention.eq_ignore_ascii_case(name)) + }); + if matches && pubkey != &sender && !mentioned.contains(pubkey) { + mentioned.push(pubkey.clone()); + } + } + mentioned +} + +async fn fetch_channel_member_profiles( + channel_id: Uuid, + rest: &RestClient, +) -> Option { + use nostr::{Alphabet, SingleLetterTag}; + + let members_filter = nostr::Filter::new() + .kind(nostr::Kind::Custom( + buzz_core::kind::KIND_NIP29_GROUP_MEMBERS as u16, + )) + .custom_tags( + SingleLetterTag::lowercase(Alphabet::D), + [channel_id.to_string()], + ) + .limit(1); + let members = timeout(CONTEXT_FETCH_TIMEOUT, rest.query(&[members_filter])) + .await + .ok()? + .ok()?; + let member_pubkeys: Vec = members + .as_array()? + .iter() + .flat_map(|event| { + event + .get("tags") + .and_then(serde_json::Value::as_array) + .into_iter() + .flatten() + }) + .filter_map(|tag| { + let parts = tag.as_array()?; + (parts.first()?.as_str()? == "p") + .then(|| parts.get(1)?.as_str()) + .flatten() + }) + .filter_map(|pubkey| nostr::PublicKey::from_hex(pubkey).ok()) + .collect(); + if member_pubkeys.is_empty() { + return None; + } + + let profiles_filter = nostr::Filter::new() + .kind(nostr::Kind::Metadata) + .authors(member_pubkeys.clone()) + .limit(member_pubkeys.len()); + let profiles = timeout(CONTEXT_FETCH_TIMEOUT, rest.query(&[profiles_filter])) + .await + .ok()? + .ok()?; + parse_kind0_profile_lookup(profiles) +} + +/// Publish the final ACP answer for a task-bound Codex identity. +/// +/// The harness already owns this managed agent's signing key. Keeping delivery +/// here avoids placing a per-agent private key in the long-lived shared Codex +/// app-server while preserving the same flat reply anchoring used by Buzz. +async fn publish_task_bound_final_answer( + ctx: &PromptContext, + source: &PromptSource, + batch: Option<&FlushBatch>, + content: Option<&str>, + profiles: Option<&PromptProfileLookup>, +) { + if ctx.codex_task_binding.is_none() { + return; + } + let (PromptSource::Channel(channel_id), Some(batch), Some(content)) = ( + source, + batch, + content.map(str::trim).filter(|text| !text.is_empty()), + ) else { + return; + }; + let Some(trigger) = batch.events.last().map(|event| &event.event) else { + return; + }; + + let thread_tags = crate::queue::parse_thread_tags(trigger); + let root_id = thread_tags + .root_event_id + .as_deref() + .and_then(|id| nostr::EventId::from_hex(id).ok()) + .unwrap_or(trigger.id); + let thread_ref = buzz_sdk::ThreadRef { + root_event_id: root_id, + parent_event_id: root_id, + }; + let channel_profiles = if content.contains('@') { + fetch_channel_member_profiles(*channel_id, &ctx.rest_client).await + } else { + None + }; + let mention_pubkeys = task_bound_reply_mentions( + content, + channel_profiles.as_ref().or(profiles), + &ctx.rest_client.keys.public_key().to_hex(), + ); + let builder = match buzz_sdk::build_message( + *channel_id, + content, + Some(&thread_ref), + &mention_pubkeys + .iter() + .map(String::as_str) + .collect::>(), + false, + &[], + ) { + Ok(builder) => builder, + Err(error) => { + tracing::warn!(channel = %channel_id, "task-bound reply build failed: {error}"); + return; + } + }; + let event = match builder.sign_with_keys(&ctx.rest_client.keys) { + Ok(event) => event, + Err(error) => { + tracing::warn!(channel = %channel_id, "task-bound reply signing failed: {error}"); + return; + } + }; + match tokio::time::timeout( + Duration::from_secs(15), + ctx.rest_client.submit_event(&event), + ) + .await + { + Ok(Ok(_)) => tracing::info!( + channel = %channel_id, + event_id = %event.id, + "task-bound final answer published" + ), + Ok(Err(error)) => { + tracing::warn!(channel = %channel_id, "task-bound reply failed: {error}") + } + Err(_) => tracing::warn!(channel = %channel_id, "task-bound reply timed out"), + } +} + /// Best-effort: remove a reaction via a signed kind:5 (NIP-09) deletion event. /// /// Queries kind:7 reactions by our pubkey targeting the event, finds the matching @@ -4398,10 +5288,115 @@ async fn clear_reactions(rest: crate::relay::RestClient, event_ids: Vec) #[cfg(test)] mod tests { + + /// A `session/load` failure only clears the durable binding when the + /// provider actually answered and refused. Timeouts, transport failures and + /// malformed responses are indeterminate: the provider may hold the session + /// open, and clearing the binding there would fork hidden state into two + /// live sessions with one unreachable. + #[test] + fn only_an_agent_error_is_a_definitive_session_load_failure() { + use std::time::Duration; + + assert!(super::load_failure_is_definitive(&AcpError::AgentError { + code: -32602, + message: "no such session".into(), + })); + + for indeterminate in [ + AcpError::Timeout(Duration::from_secs(1)), + AcpError::IdleTimeout(Duration::from_secs(1)), + AcpError::WriteTimeout(Duration::from_secs(1)), + AcpError::CancelDrainTimeout(Duration::from_secs(1)), + AcpError::HardTimeout { + silence: Duration::from_secs(1), + }, + AcpError::AgentExited, + AcpError::Protocol("truncated frame".into()), + ] { + assert!( + !super::load_failure_is_definitive(&indeterminate), + "{indeterminate:?} must not clear the binding" + ); + } + } use super::*; use nostr::{EventBuilder, Keys, Kind, Tag, Timestamp}; use serde_json::json; + #[test] + fn prompt_image_parser_keeps_supported_relay_images() { + let image_tag = Tag::parse([ + "imeta", + "url http://relay.test/media/abc.png", + "m image/png", + "x aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "size 1024", + "filename probe.png", + ]) + .unwrap(); + let video_tag = Tag::parse([ + "imeta", + "url http://relay.test/media/video.mp4", + "m video/mp4", + "x bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "size 2048", + ]) + .unwrap(); + let event = EventBuilder::new(Kind::TextNote, "inspect these") + .tags([image_tag, video_tag]) + .sign_with_keys(&Keys::generate()) + .unwrap(); + let batch = FlushBatch { + channel_id: Uuid::new_v4(), + events: vec![crate::queue::BatchEvent { + event, + prompt_tag: "mention".into(), + received_at: std::time::Instant::now(), + }], + cancelled_events: vec![], + cancel_reason: None, + }; + + assert_eq!( + prompt_image_attachments(&batch), + vec![PromptImageAttachment { + url: "http://relay.test/media/abc.png".into(), + mime_type: "image/png".into(), + declared_size: 1024, + }] + ); + } + + #[test] + fn fork_handoff_names_active_and_source_tasks() { + let handoff = render_task_handoff("active-task", r"C:\repo", Some("source-task")); + + assert!(handoff.contains("independent Codex task")); + assert!(handoff.contains("Buzz Codex task ID: active-task")); + assert!(handoff.contains("Source Codex task ID: source-task")); + assert!(handoff.contains(r"Workspace: C:\repo")); + } + + #[test] + fn exclusive_resume_handoff_has_no_source_task() { + let handoff = render_task_handoff("resumed-task", r"C:\repo", None); + + assert!(handoff.contains("exclusively resuming")); + assert!(handoff.contains("Codex task ID: resumed-task")); + assert!(!handoff.contains("Source Codex task ID")); + } + + #[test] + fn identity_handoff_explains_shared_and_exclusive_access() { + let handoff = render_identity_task_handoff("bound-task", r"C:\repo"); + + assert!(handoff.contains("is bound to a pre-existing Codex task")); + assert!(handoff.contains("shared app-server clients may remain connected")); + assert!(handoff.contains("exclusive ACP requires stopping this Buzz agent")); + assert!(handoff.contains("Codex task ID: bound-task")); + } + fn test_mcp_server() -> McpServer { McpServer { name: "dev".into(), @@ -5560,6 +6555,40 @@ mod tests { assert_eq!(pubkeys, expected); } + #[test] + fn task_bound_reply_mentions_resolve_member_names_and_ignore_code() { + let sender = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; + let target = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"; + let mut profiles = PromptProfileLookup::new(); + profiles.insert( + sender.to_string(), + PromptProfile { + display_name: Some("Debug".into()), + ..Default::default() + }, + ); + profiles.insert( + target.to_string(), + PromptProfile { + display_name: Some("H2O2".into()), + is_agent: true, + ..Default::default() + }, + ); + + assert_eq!( + task_bound_reply_mentions("@h2o2 please review", Some(&profiles), sender), + vec![target] + ); + assert!(task_bound_reply_mentions( + "`@H2O2` is an example\n\n```text\n@H2O2\n```", + Some(&profiles), + sender, + ) + .is_empty()); + assert!(task_bound_reply_mentions("@Debug self note", Some(&profiles), sender).is_empty()); + } + #[test] fn test_parse_kind0_profile_lookup_extracts_display_name_and_nip05() { let lookup = parse_kind0_profile_lookup(json!([ @@ -5654,6 +6683,7 @@ done"# agent_name: "legacy-test-agent".into(), goose_system_prompt_supported: None, protocol_version: 1, + supports_load_session: false, }; agent.state.heartbeat_session = Some("live-session".into()); @@ -5748,6 +6778,7 @@ done"# agent_name: "legacy-test-agent".into(), goose_system_prompt_supported: None, protocol_version: 1, + supports_load_session: false, }; agent .state @@ -5920,6 +6951,7 @@ done"# agent_name: "legacy-test-agent".into(), goose_system_prompt_supported: None, protocol_version: 1, + supports_load_session: false, }; agent .state @@ -6070,6 +7102,7 @@ printf '%s\n' '{{"jsonrpc":"2.0","id":0,"result":{{"stopReason":"end_turn"}}}}'" agent_name: "legacy-test-agent".into(), goose_system_prompt_supported: None, protocol_version: 1, + supports_load_session: false, }; agent .state @@ -6316,6 +7349,126 @@ printf '%s\n' '{{"jsonrpc":"2.0","id":0,"result":{{"stopReason":"end_turn"}}}}'" (s, ch_a, ch_b) } + #[test] + fn identity_session_invalidation_clears_every_room_alias() { + let (mut state, ch_a, ch_b) = make_state(); + state.identity_session = Some("shared-task".into()); + state.identity_handoff_pending = true; + state.identity_standing_context_sent = true; + state.identity_turn_count = 4; + state.sessions.insert(ch_a, "shared-task".into()); + state.sessions.insert(ch_b, "shared-task".into()); + + state.invalidate(&PromptSource::Channel(ch_a)); + + assert!(state.identity_session.is_none()); + assert!(!state.identity_handoff_pending); + assert!(!state.identity_standing_context_sent); + assert_eq!(state.identity_turn_count, 0); + assert!(state.sessions.is_empty()); + assert!(state.turn_counts.is_empty()); + assert!(state.deliveries.is_empty()); + assert_eq!(state.heartbeat_session.as_deref(), Some("sess-hb")); + } + + #[test] + fn identity_session_shares_standing_context_across_room_aliases() { + let (mut state, ch_a, ch_b) = make_state(); + state.identity_session = Some("shared-task".into()); + state.sessions.insert(ch_a, "shared-task".into()); + + assert!(!state.standing_context_sent_for_channel(&ch_a)); + assert!(!state.standing_context_sent_for_channel(&ch_b)); + + state.mark_channel_delivery_success(ch_a, true, ["event-a".to_string()]); + + assert!(state.standing_context_sent_for_channel(&ch_a)); + assert!(state.standing_context_sent_for_channel(&ch_b)); + assert!(state.deliveries[&ch_a] + .delivered_event_ids + .contains("event-a")); + assert!(!state.deliveries[&ch_b] + .delivered_event_ids + .contains("event-a")); + } + + #[test] + fn room_membership_removal_keeps_identity_task_online() { + let (mut state, ch_a, ch_b) = make_state(); + state.identity_session = Some("shared-task".into()); + state.identity_handoff_pending = true; + state.identity_standing_context_sent = true; + state.sessions.insert(ch_a, "shared-task".into()); + state.sessions.insert(ch_b, "shared-task".into()); + + assert!(state.invalidate_channel(&ch_a)); + + assert_eq!(state.identity_session.as_deref(), Some("shared-task")); + assert!(state.identity_handoff_pending); + assert!(state.identity_standing_context_sent); + assert!(!state.sessions.contains_key(&ch_a)); + assert_eq!( + state.sessions.get(&ch_b).map(String::as_str), + Some("shared-task") + ); + } + + #[test] + fn load_without_catalog_does_not_poison_model_capabilities() { + let response = json!({"sessionId": "loaded"}); + assert!(model_capabilities_from_response(&response).is_none()); + } + + #[test] + fn load_uses_cached_catalog_when_response_omits_it() { + let cached_response = json!({ + "configOptions": [{ + "configId": "model", + "category": "model", + "options": [{"value": "model-a"}] + }] + }); + let cached = model_capabilities_from_response(&cached_response) + .expect("model catalog should be captured"); + + assert_eq!( + resolve_load_model_switch(&json!({}), Some(&cached), "model-a"), + LoadModelResolution::Method(ModelSwitchMethod::ConfigOption { + config_id: "model".into(), + option_value: "model-a".into(), + }) + ); + } + + #[test] + fn advertised_load_catalog_is_authoritative_even_when_empty() { + let cached_response = json!({ + "models": { + "availableModels": [{"modelId": "model-a"}] + } + }); + let cached = model_capabilities_from_response(&cached_response) + .expect("model catalog should be captured"); + let load_response = json!({ + "models": { + "availableModels": [] + } + }); + + assert_eq!( + resolve_load_model_switch(&load_response, Some(&cached), "model-a"), + LoadModelResolution::Unsupported + ); + } + + #[test] + fn load_without_any_catalog_is_unverifiable() { + assert_eq!( + resolve_load_model_switch(&json!({}), None, "model-a"), + LoadModelResolution::Unverifiable + ); + } + #[test] fn test_rotate_after_natural_completion_invalidates_channel_state() { let (mut s, ch_a, ch_b) = make_state(); @@ -7057,6 +8210,7 @@ printf '%s\n' '{{"jsonrpc":"2.0","id":0,"result":{{"stopReason":"end_turn"}}}}'" agent_name: "unknown".into(), goose_system_prompt_supported: None, protocol_version: 2, + supports_load_session: false, }; // Simulate dispatch: install a steer receiver (normally done by @@ -7115,6 +8269,7 @@ printf '%s\n' '{{"jsonrpc":"2.0","id":0,"result":{{"stopReason":"end_turn"}}}}'" agent_name: "unknown".into(), goose_system_prompt_supported: None, protocol_version: 2, + supports_load_session: false, }; // Simulate a completed turn: `steer_rx` was consumed by the read loop @@ -7573,6 +8728,7 @@ printf '%s\n' '{{"jsonrpc":"2.0","id":0,"result":{{"stopReason":"end_turn"}}}}'" dedup_mode: DedupMode::Drop, system_prompt: None, session_title: None, + codex_task_binding: None, team_instructions: None, heartbeat_prompt: None, base_prompt: None, @@ -7600,9 +8756,38 @@ printf '%s\n' '{{"jsonrpc":"2.0","id":0,"result":{{"stopReason":"end_turn"}}}}'" memory_enabled: false, harness_name: "goose".to_string(), relay_url: "ws://127.0.0.1:3000".to_string(), + agent_command: "goose".to_string(), + agent_args: vec!["acp".to_string()], + session_store: std::sync::Arc::new(crate::session_store::SessionStore::open( + std::env::temp_dir().join(format!( + "buzz-acp-test-sessions-{}.json", + uuid::Uuid::new_v4() + )), + )), } } + #[test] + fn explicit_rotation_clears_the_durable_channel_binding() { + let ctx = make_prompt_context_no_owner(); + let channel_id = Uuid::new_v4(); + ctx.session_store.put( + &ctx.agent_command, + &ctx.agent_args, + &channel_id, + "session-before-rotate", + ); + + assert!(clear_durable_source_binding( + &ctx, + &PromptSource::Channel(channel_id) + )); + assert!(ctx + .session_store + .get(&ctx.agent_command, &ctx.agent_args, &channel_id) + .is_none()); + } + // ── huddle instructions ───────────────────────────────────────────────── #[test] diff --git a/crates/buzz-acp/src/queue.rs b/crates/buzz-acp/src/queue.rs index b0f0fa248e..9e1fe03a86 100644 --- a/crates/buzz-acp/src/queue.rs +++ b/crates/buzz-acp/src/queue.rs @@ -1107,6 +1107,68 @@ fn format_prompt_actor(pubkey: &str, profile_lookup: Option<&PromptProfileLookup } } +/// Render NIP-92 `imeta` tags as an agent-readable attachment manifest. +/// +/// Attachment bytes stay out of the prompt (apart from native image blocks, +/// which are handled by the ACP pool). Agents can fetch any file on demand +/// with the authenticated `buzz media get` command. +fn format_event_attachments(event: &nostr::Event) -> Option { + let mut attachments = Vec::new(); + let mut seen_urls = std::collections::HashSet::new(); + for tag in event.tags.iter() { + let parts = tag.as_slice(); + if parts.first().map(String::as_str) != Some("imeta") { + continue; + } + let mut fields = std::collections::HashMap::new(); + for field in parts.iter().skip(1) { + if let Some((key, value)) = field.split_once(' ') { + fields.insert(key, value); + } + } + let Some(url) = fields.get("url").copied() else { + continue; + }; + if !seen_urls.insert(url) { + continue; + } + let filename = fields + .get("filename") + .copied() + .filter(|name| !name.trim().is_empty()) + .unwrap_or("attachment"); + let mime = fields + .get("m") + .copied() + .unwrap_or("application/octet-stream"); + let size = fields.get("size").copied().unwrap_or("unknown"); + let sha256 = fields.get("x").copied().unwrap_or("unknown"); + // The filename is displayed as data, never executed. Keep the example + // output path fixed so a hostile filename cannot inject shell syntax. + let clean = |value: &str| { + value + .chars() + .map(|character| { + if character.is_control() { + ' ' + } else { + character + } + }) + .collect::() + }; + attachments.push(format!( + "- filename: {}\n mime: {}\n size: {} bytes\n sha256: {}\n url: {}\n download: buzz media get --output ./downloaded-file", + clean(filename), + clean(mime), + clean(size), + clean(sha256), + clean(url), + )); + } + (!attachments.is_empty()).then(|| format!("Attachments:\n{}", attachments.join("\n"))) +} + /// Format the per-event `[Event]` block for a single [`BatchEvent`]. /// /// Includes: event_id, channel (name + UUID), kind, sender (hex + npub), @@ -1181,6 +1243,10 @@ pub(crate) fn format_event_block( block.push_str(&format!("\nParsed: {}", parsed_parts.join(", "))); } + if let Some(attachments) = format_event_attachments(&be.event) { + block.push_str(&format!("\n{attachments}")); + } + block } @@ -1471,6 +1537,14 @@ pub struct FormatPromptArgs<'a> { /// For modern agents (protocol_version >= 2) the section is delivered via /// the system role in session/new; omit here to avoid duplication. pub agent_canvas: Option<&'a str>, + /// Explicit ownership context for a manually bound pre-existing task. + /// This must ride in the user turn for every protocol version because + /// `session/load` cannot retrofit a new system prompt. + pub task_handoff: Option<&'a str>, + /// The harness signs and publishes the final ACP answer for a task-bound + /// shared-runtime agent. This compact reminder prevents duplicate CLI + /// sends and keeps per-agent credentials out of the shared Codex process. + pub task_bound_auto_delivery: bool, /// Set once this session's standing context has already been delivered — /// see [`StandingContext`]. Only meaningful for legacy agents; modern /// agents are gated by `has_system_prompt_support` regardless. @@ -1549,11 +1623,14 @@ pub(crate) fn base_section(base_prompt: &str) -> String { /// /// Produces a stable prompt with these sections (in order): /// 0. [`StandingContext`] — `[Base]`, `[System]`, `[Team Instructions]`, -/// `[Agent Memory — core]`, `[Channel Canvas]`. Legacy agents only, and only -/// on the session's first message (see `standing_context_sent`) -/// 1. `[Context]` — scope, channel name, and contextual hints for the agent -/// 2. `[Thread Context]` or `[Conversation Context]` — if fetched -/// 3. `[Event]` / `[Buzz events]` — the triggering event(s) +/// `[Agent Memory — core]`, `[Huddle Instructions]`, `[Channel Canvas]`. +/// Legacy agents only, and only on the session's first message (see +/// `standing_context_sent`) +/// 1. `[Task Handoff]` — the first turn after loading an external task +/// 2. `[Buzz Delivery]` — reminder for shared-runtime task-bound delivery +/// 3. `[Context]` — scope, channel name, and contextual hints for the agent +/// 4. `[Thread Context]` or `[Conversation Context]` — if fetched +/// 5. `[Event]` / `[Buzz events]` — the triggering event(s) /// /// Each section is returned as its own block rather than one joined string so /// the observer frame's size trimmer (`fit_observer_event_to_budget`) elides @@ -1583,7 +1660,7 @@ pub fn format_prompt(batch: &FlushBatch, args: &FormatPromptArgs<'_>) -> Vec = Vec::with_capacity(7); + let mut sections: Vec = Vec::with_capacity(8); // Standing context — base prompt, persona, team instructions, core memory // and canvas. Modern agents received all of it via the system role in @@ -1604,7 +1681,21 @@ pub fn format_prompt(batch: &FlushBatch, args: &FormatPromptArgs<'_>) -> Vec` yourself so the files become real attachments, then return a brief delivery summary as your final answer. Do not paste a local path or a bare upload URL as a substitute for an attachment." + .to_string(), + ); + } + + // Context hints (with a human-aware reply anchor). // // Human-facing turns are anchored so replies stay readable at layer 1: // - in a thread → anchor to the thread ROOT (no depth-2 nesting) @@ -3905,6 +3996,72 @@ mod tests { ); } + #[test] + fn test_format_event_block_renders_attachment_manifest() { + let ch = Uuid::new_v4(); + let event = make_event_with_tags( + "attached", + vec![vec![ + "imeta".into(), + "url https://relay.test/media/report.md".into(), + "m text/markdown".into(), + "x deadbeef".into(), + "size 42".into(), + "filename report.md".into(), + ]], + ); + let batch = FlushBatch { + channel_id: ch, + events: vec![BatchEvent { + event, + prompt_tag: "test".into(), + received_at: Instant::now(), + }], + cancelled_events: vec![], + cancel_reason: None, + }; + + let prompt = format_prompt(&batch, &FormatPromptArgs::default()).join("\n\n"); + assert!(prompt.contains("Attachments:")); + assert!(prompt.contains("filename: report.md")); + assert!(prompt.contains("mime: text/markdown")); + assert!(prompt.contains("size: 42 bytes")); + assert!(prompt.contains("sha256: deadbeef")); + assert!(prompt.contains("buzz media get --output ./downloaded-file")); + } + + #[test] + fn test_format_event_block_deduplicates_attachment_urls_and_handles_missing_fields() { + let ch = Uuid::new_v4(); + let event = make_event_with_tags( + "attached", + vec![ + vec!["imeta".into(), "url https://relay.test/media/a".into()], + vec![ + "imeta".into(), + "url https://relay.test/media/a".into(), + "filename a.txt".into(), + ], + vec!["imeta".into(), "m text/plain".into()], + ], + ); + let batch = FlushBatch { + channel_id: ch, + events: vec![BatchEvent { + event, + prompt_tag: "test".into(), + received_at: Instant::now(), + }], + cancelled_events: vec![], + cancel_reason: None, + }; + + let prompt = format_prompt(&batch, &FormatPromptArgs::default()).join("\n\n"); + assert_eq!(prompt.matches("url: https://relay.test/media/a").count(), 1); + assert!(!prompt.contains("mime: text/plain")); + assert!(prompt.contains("filename: attachment")); + } + #[test] fn test_drain_channel_removes_pending_events() { let mut q = EventQueue::new(DedupMode::Queue); @@ -4911,6 +5068,72 @@ mod tests { ); } + #[test] + fn test_format_prompt_task_handoff_precedes_room_context_for_modern_agent() { + let batch = FlushBatch { + channel_id: Uuid::new_v4(), + events: vec![BatchEvent { + event: make_event("summarize your current work"), + prompt_tag: "test".into(), + received_at: Instant::now(), + }], + cancelled_events: vec![], + cancel_reason: None, + }; + let handoff = "[Task Handoff]\nCodex task ID: session-1\nWorkspace: C:\\repo"; + let sections = format_prompt( + &batch, + &FormatPromptArgs { + has_system_prompt_support: true, + task_handoff: Some(handoff), + ..Default::default() + }, + ); + + let handoff_index = sections + .iter() + .position(|section| section.starts_with("[Task Handoff]")) + .expect("task handoff section"); + let context_index = sections + .iter() + .position(|section| section.starts_with("[Context]")) + .expect("room context section"); + assert_eq!(sections[handoff_index], handoff); + assert!(handoff_index < context_index); + } + + #[test] + fn test_task_bound_auto_delivery_precedes_room_context() { + let batch = FlushBatch { + channel_id: Uuid::new_v4(), + events: vec![BatchEvent { + event: make_event("reply through the bridge"), + prompt_tag: "test".into(), + received_at: Instant::now(), + }], + cancelled_events: vec![], + cancel_reason: None, + }; + let sections = format_prompt( + &batch, + &FormatPromptArgs { + task_bound_auto_delivery: true, + ..Default::default() + }, + ); + + let delivery_index = sections + .iter() + .position(|section| section.starts_with("[Buzz Delivery]")) + .expect("Buzz delivery section"); + let context_index = sections + .iter() + .position(|section| section.starts_with("[Context]")) + .expect("room context section"); + assert!(sections[delivery_index].contains("buzz messages send --file ")); + assert!(delivery_index < context_index); + } + #[test] fn default_in_flight_deadline_exceeds_default_max_turn_duration() { let q = EventQueue::new(DedupMode::Queue); diff --git a/crates/buzz-acp/src/relay.rs b/crates/buzz-acp/src/relay.rs index 17a818867d..fd1107bbb6 100644 --- a/crates/buzz-acp/src/relay.rs +++ b/crates/buzz-acp/src/relay.rs @@ -277,6 +277,103 @@ fn unix_now_secs() -> u64 { } impl RestClient { + fn blossom_get_header(&self, media_url: &str) -> Result { + use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _}; + + let parsed = url::Url::parse(media_url) + .map_err(|error| RelayError::Http(format!("invalid media URL: {error}")))?; + let host = parsed + .host_str() + .ok_or_else(|| RelayError::Http("media URL has no host".to_string()))?; + let server = match parsed.port() { + Some(port) => format!("{host}:{port}"), + None => host.to_string(), + }; + let expiration = (unix_now_secs() + 600).to_string(); + let event = EventBuilder::new(Kind::from(24242), "Get media") + .tags([ + Tag::parse(["t", "get"]).map_err(|error| RelayError::Http(error.to_string()))?, + Tag::parse(["expiration", &expiration]) + .map_err(|error| RelayError::Http(error.to_string()))?, + Tag::parse(["server", &server]) + .map_err(|error| RelayError::Http(error.to_string()))?, + ]) + .sign_with_keys(&self.keys) + .map_err(|error| RelayError::Http(format!("media auth signing failed: {error}")))?; + let json = serde_json::to_string(&event) + .map_err(|error| RelayError::Http(format!("media auth encoding failed: {error}")))?; + Ok(format!("Nostr {}", URL_SAFE_NO_PAD.encode(json))) + } + + /// Download a relay-local Blossom object without forwarding credentials + /// through redirects. The caller supplies a strict byte ceiling. + pub async fn download_media( + &self, + media_url: &str, + max_bytes: usize, + ) -> Result, RelayError> { + let base = url::Url::parse(&self.base_url) + .map_err(|error| RelayError::Http(format!("invalid relay URL: {error}")))?; + let media = url::Url::parse(media_url) + .map_err(|error| RelayError::Http(format!("invalid media URL: {error}")))?; + let same_origin = base.scheme() == media.scheme() + && base.host_str() == media.host_str() + && base.port_or_known_default() == media.port_or_known_default(); + if !same_origin + || !media.path().starts_with("/media/") + || !media.username().is_empty() + || media.password().is_some() + || media.query().is_some() + || media.fragment().is_some() + { + return Err(RelayError::Http("refusing non-relay media URL".to_string())); + } + + let client = reqwest::Client::builder() + .timeout(Duration::from_secs(20)) + .redirect(reqwest::redirect::Policy::none()) + .build() + .map_err(|error| RelayError::Http(format!("media client init failed: {error}")))?; + let mut request = client + .get(media.clone()) + .header("Authorization", self.blossom_get_header(media_url)?); + if let Some(auth_tag) = &self.auth_tag_json { + request = request.header("x-auth-tag", auth_tag); + } + let response = request + .send() + .await + .map_err(|error| RelayError::Http(format!("media download failed: {error}")))?; + if !response.status().is_success() { + return Err(RelayError::Http(format!( + "media download returned HTTP {}", + response.status() + ))); + } + if response + .content_length() + .is_some_and(|length| length > max_bytes as u64) + { + return Err(RelayError::Http(format!( + "media exceeds {max_bytes} byte limit" + ))); + } + + let mut bytes = Vec::new(); + let mut stream = response.bytes_stream(); + while let Some(chunk) = stream.next().await { + let chunk = + chunk.map_err(|error| RelayError::Http(format!("media read failed: {error}")))?; + if bytes.len().saturating_add(chunk.len()) > max_bytes { + return Err(RelayError::Http(format!( + "media exceeds {max_bytes} byte limit" + ))); + } + bytes.extend_from_slice(&chunk); + } + Ok(bytes) + } + /// Sign a NIP-98 HTTP Auth event (kind:27235) for the given method/URL/body. /// /// Returns the `Authorization: Nostr ` header value (without the diff --git a/crates/buzz-acp/src/session_store.rs b/crates/buzz-acp/src/session_store.rs new file mode 100644 index 0000000000..66aec64348 --- /dev/null +++ b/crates/buzz-acp/src/session_store.rs @@ -0,0 +1,676 @@ +//! Durable channel → ACP session bindings for harness restarts. +//! +//! `SessionState` is in-memory only. Agents that advertise `loadSession` (e.g. +//! Hermes) can restore a prior ACP conversation after the harness respawns if +//! the channel→session mapping survives. This module persists that mapping as +//! a small JSON sidecar under the process data directory. +//! +//! Keyed by `(agent_command_identity, agent_args, channel_id)` so different +//! agent binaries / profiles do not share bindings. Heartbeats are never +//! stored — they stay ephemeral. +//! +//! Cross-process safety: the store is a shared file. Every read and mutation +//! takes a sibling lockfile, reloads the on-disk map under that lock, then +//! writes atomically. A process-local cache alone is unsafe when two +//! `buzz-acp` processes share the same agent command/args identity. + +use std::collections::HashMap; +use std::ffi::OsString; +use std::fs::{self, File, OpenOptions}; +use std::path::{Path, PathBuf}; + +use fs2::FileExt; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +use crate::config::normalize_agent_command_identity; + +/// Environment override for the session store path (tests / operators). +pub const SESSION_STORE_ENV: &str = "BUZZ_ACP_SESSION_STORE"; + +/// Durable data needed to resume a channel's ACP session faithfully. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SessionBinding { + pub session_id: String, + pub workspace: Option, + pub mode: SessionBindingMode, + pub source_session_id: Option, +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, Default, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum SessionBindingMode { + #[default] + Resume, + Fork, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(untagged)] +enum StoredSessionBinding { + /// Backward-compatible form written by the original session store. + Legacy(String), + Detailed { + session_id: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + workspace: Option, + #[serde(default)] + mode: SessionBindingMode, + #[serde(default, skip_serializing_if = "Option::is_none")] + source_session_id: Option, + }, +} + +impl StoredSessionBinding { + fn session_id(&self) -> &str { + match self { + Self::Legacy(session_id) | Self::Detailed { session_id, .. } => session_id, + } + } + + fn into_binding(self) -> SessionBinding { + match self { + Self::Legacy(session_id) => SessionBinding { + session_id, + workspace: None, + mode: SessionBindingMode::Resume, + source_session_id: None, + }, + Self::Detailed { + session_id, + workspace, + mode, + source_session_id, + } => SessionBinding { + session_id, + workspace, + mode, + source_session_id, + }, + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)] +struct StoreFile { + /// version for future migrations + version: u32, + /// map key → ACP session binding + sessions: HashMap, +} + +/// Durable session binding store shared across buzz-acp processes. +pub struct SessionStore { + path: PathBuf, + lock_path: PathBuf, +} + +/// RAII wrapper that unlocks the OS file lock on drop. +struct StoreLock { + file: File, +} + +impl Drop for StoreLock { + fn drop(&mut self) { + let _ = FileExt::unlock(&self.file); + } +} + +impl SessionStore { + /// Open or create the store at the resolved path. + /// + /// Does not cache file contents; each operation reloads under lock. + pub fn open(path: PathBuf) -> Self { + let lock_path = sibling_lock_path(&path); + Self { path, lock_path } + } + + /// Resolve the default store path for this agent identity. + pub fn default_path(agent_command: &str, agent_args: &[String]) -> PathBuf { + if let Ok(override_path) = std::env::var(SESSION_STORE_ENV) { + if !override_path.trim().is_empty() { + return PathBuf::from(override_path); + } + } + let identity = store_identity(agent_command, agent_args); + let base = dirs::data_local_dir() + .or_else(dirs::data_dir) + .unwrap_or_else(|| PathBuf::from(".")); + base.join("buzz-acp") + .join("sessions") + .join(format!("{identity}.json")) + } + + /// Look up a stored ACP session binding for a channel. + pub fn get( + &self, + agent_command: &str, + agent_args: &[String], + channel_id: &Uuid, + ) -> Option { + let key = binding_key(agent_command, agent_args, channel_id); + let _lock = self.acquire_lock(false)?; + match load_store(&self.path) { + Ok(data) => data + .sessions + .get(&key) + .cloned() + .map(StoredSessionBinding::into_binding), + Err(e) => { + self.warn_io("failed to read ACP session bindings", &e); + None + } + } + } + + /// Persist a channel → session binding. + pub fn put( + &self, + agent_command: &str, + agent_args: &[String], + channel_id: &Uuid, + session_id: &str, + ) { + self.put_value( + agent_command, + agent_args, + channel_id, + StoredSessionBinding::Legacy(session_id.to_owned()), + ); + } + + /// Persist the child produced by a one-time fork while retaining provenance. + pub fn put_fork_result( + &self, + agent_command: &str, + agent_args: &[String], + channel_id: &Uuid, + session_id: &str, + workspace: Option<&str>, + source_session_id: &str, + ) { + self.put_value( + agent_command, + agent_args, + channel_id, + StoredSessionBinding::Detailed { + session_id: session_id.to_owned(), + workspace: workspace.map(str::to_owned), + mode: SessionBindingMode::Resume, + source_session_id: Some(source_session_id.to_owned()), + }, + ); + } + + fn put_value( + &self, + agent_command: &str, + agent_args: &[String], + channel_id: &Uuid, + binding: StoredSessionBinding, + ) { + let key = binding_key(agent_command, agent_args, channel_id); + let Some(_lock) = self.acquire_lock(true) else { + return; + }; + let mut data = match load_store(&self.path) { + Ok(data) => data, + Err(e) if e.kind() == std::io::ErrorKind::InvalidData => { + // Corrupt sidecar: log and recover empty rather than wedging puts forever. + self.warn_io( + "corrupt ACP session store on update — rewriting from empty map", + &e, + ); + StoreFile::default() + } + Err(e) => { + self.warn_io("failed to read ACP session bindings before update", &e); + return; + } + }; + data.version = 2; + data.sessions.insert(key, binding); + if let Err(e) = save_store(&self.path, &data) { + self.warn_io("failed to persist ACP session binding", &e); + } + } + + /// Remove the current binding for a channel, regardless of session id. + /// + /// This is reserved for explicit discard semantics such as owner-requested + /// rotation. Failed loads must use [`Self::remove_if_equals`] so they cannot + /// delete a newer binding written by another process. + /// + /// Returns `true` when a binding was removed. + pub fn remove(&self, agent_command: &str, agent_args: &[String], channel_id: &Uuid) -> bool { + let key = binding_key(agent_command, agent_args, channel_id); + let Some(_lock) = self.acquire_lock(true) else { + return false; + }; + match load_store(&self.path) { + Ok(mut data) => { + if data.sessions.remove(&key).is_none() { + return false; + } + if let Err(e) = save_store(&self.path, &data) { + self.warn_io("failed to persist ACP session binding removal", &e); + return false; + } + true + } + Err(e) => { + self.warn_io("failed to read ACP session bindings before removal", &e); + false + } + } + } + + /// Remove a binding only if it still points at `expected_session_id`. + /// + /// Used after a failed `session/load`: another process may have already + /// written a newer session for the same channel, and a key-only remove + /// would delete that fresher binding. + /// + /// Returns `true` when a matching binding was removed. + pub fn remove_if_equals( + &self, + agent_command: &str, + agent_args: &[String], + channel_id: &Uuid, + expected_session_id: &str, + ) -> bool { + let key = binding_key(agent_command, agent_args, channel_id); + let Some(_lock) = self.acquire_lock(true) else { + return false; + }; + match load_store(&self.path) { + Ok(mut data) => { + let matches = data + .sessions + .get(&key) + .is_some_and(|current| current.session_id() == expected_session_id); + if !matches { + return false; + } + data.sessions.remove(&key); + if let Err(e) = save_store(&self.path, &data) { + self.warn_io( + "failed to persist conditional ACP session binding removal", + &e, + ); + return false; + } + true + } + Err(e) => { + self.warn_io( + "failed to read ACP session bindings before conditional removal", + &e, + ); + false + } + } + } + + fn acquire_lock(&self, exclusive: bool) -> Option { + if let Some(parent) = self.lock_path.parent() { + if let Err(e) = fs::create_dir_all(parent) { + self.warn_io("failed to create ACP session store directory", &e); + return None; + } + } + let file = match OpenOptions::new() + .read(true) + .write(true) + .create(true) + .truncate(false) + .open(&self.lock_path) + { + Ok(file) => file, + Err(e) => { + self.warn_io("failed to open ACP session store lock", &e); + return None; + } + }; + let result = if exclusive { + FileExt::lock_exclusive(&file) + } else { + FileExt::lock_shared(&file) + }; + if let Err(e) = result { + self.warn_io("failed to lock ACP session store", &e); + return None; + } + Some(StoreLock { file }) + } + + fn warn_io(&self, message: &'static str, error: &std::io::Error) { + tracing::warn!( + target: "session_store", + path = %self.path.display(), + lock_path = %self.lock_path.display(), + error = %error, + "{message}" + ); + } +} + +fn sibling_lock_path(path: &Path) -> PathBuf { + let mut name = path.as_os_str().to_owned(); + name.push(OsString::from(".lock")); + PathBuf::from(name) +} + +fn store_identity(agent_command: &str, agent_args: &[String]) -> String { + let cmd = normalize_agent_command_identity(agent_command); + let args = agent_args.join(" "); + let raw = if args.is_empty() { + cmd + } else { + format!("{cmd} {args}") + }; + // Keep the filename filesystem-safe and short. + let mut out = String::with_capacity(raw.len()); + for ch in raw.chars() { + if ch.is_ascii_alphanumeric() || ch == '-' || ch == '_' { + out.push(ch); + } else { + out.push('_'); + } + } + if out.is_empty() { + "agent".into() + } else { + out + } +} + +fn binding_key(agent_command: &str, agent_args: &[String], channel_id: &Uuid) -> String { + format!( + "{}|{}|{}", + normalize_agent_command_identity(agent_command), + agent_args.join("\u{1f}"), + channel_id + ) +} + +fn load_store(path: &Path) -> std::io::Result { + match fs::read_to_string(path) { + Ok(text) => serde_json::from_str(&text) + .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e)), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(StoreFile::default()), + Err(e) => Err(e), + } +} + +fn save_store(path: &Path, data: &StoreFile) -> std::io::Result<()> { + if let Some(parent) = path.parent() { + fs::create_dir_all(parent)?; + } + let tmp = path.with_extension("json.tmp"); + let json = serde_json::to_string_pretty(data) + .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?; + fs::write(&tmp, json)?; + fs::rename(&tmp, path)?; + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::tempdir; + + #[test] + fn round_trip_binding() { + let dir = tempdir().unwrap(); + let path = dir.path().join("sessions.json"); + let store = SessionStore::open(path); + let channel = Uuid::new_v4(); + assert!(store.get("hermes", &["acp".into()], &channel).is_none()); + store.put("hermes", &["acp".into()], &channel, "sess-1"); + assert_eq!( + store + .get("hermes", &["acp".into()], &channel) + .map(|binding| binding.session_id), + Some("sess-1".to_string()) + ); + // Re-open from disk. + let store2 = SessionStore::open(store.path.clone()); + assert_eq!( + store2 + .get("hermes", &["acp".into()], &channel) + .map(|binding| binding.session_id), + Some("sess-1".to_string()) + ); + assert!(store2.remove_if_equals("hermes", &["acp".into()], &channel, "sess-1")); + assert!(store2.get("hermes", &["acp".into()], &channel).is_none()); + } + + #[test] + fn round_trip_binding_with_workspace() { + let dir = tempdir().unwrap(); + let path = dir.path().join("sessions.json"); + let store = SessionStore::open(path.clone()); + let channel = Uuid::new_v4(); + + let key = binding_key("codex-acp", &[], &channel); + fs::write( + &path, + serde_json::to_string_pretty(&serde_json::json!({ + "version": 2, + "sessions": { + (key): { + "session_id": "019eca9a-beb9-7902-8ce6-527b2ba56020", + "workspace": r"C:\Users\test\gelatin_doe" + } + } + })) + .unwrap(), + ) + .unwrap(); + + assert_eq!( + store.get("codex-acp", &[], &channel), + Some(SessionBinding { + session_id: "019eca9a-beb9-7902-8ce6-527b2ba56020".to_string(), + workspace: Some(r"C:\Users\test\gelatin_doe".to_string()), + mode: SessionBindingMode::Resume, + source_session_id: None, + }) + ); + + let raw = fs::read_to_string(path).unwrap(); + assert!(raw.contains("\"workspace\"")); + } + + #[test] + fn fork_result_becomes_resumable_and_keeps_source() { + let dir = tempdir().unwrap(); + let path = dir.path().join("sessions.json"); + let store = SessionStore::open(path); + let channel = Uuid::new_v4(); + + store.put_fork_result( + "codex-acp", + &[], + &channel, + "child-session", + Some(r"C:\repo"), + "source-session", + ); + + assert_eq!( + store.get("codex-acp", &[], &channel), + Some(SessionBinding { + session_id: "child-session".to_string(), + workspace: Some(r"C:\repo".to_string()), + mode: SessionBindingMode::Resume, + source_session_id: Some("source-session".to_string()), + }) + ); + } + + #[test] + fn different_args_are_isolated() { + let dir = tempdir().unwrap(); + let store = SessionStore::open(dir.path().join("s.json")); + let channel = Uuid::new_v4(); + store.put("hermes", &["acp".into()], &channel, "a"); + store.put( + "hermes", + &["-p".into(), "chad".into(), "acp".into()], + &channel, + "b", + ); + assert_eq!( + store + .get("hermes", &["acp".into()], &channel) + .map(|binding| binding.session_id), + Some("a".to_string()) + ); + assert_eq!( + store + .get( + "hermes", + &["-p".into(), "chad".into(), "acp".into()], + &channel + ) + .map(|binding| binding.session_id), + Some("b".to_string()) + ); + } + + #[test] + fn independently_opened_stores_do_not_lose_updates() { + let dir = tempdir().unwrap(); + let path = dir.path().join("sessions.json"); + let store_a = SessionStore::open(path.clone()); + let store_b = SessionStore::open(path.clone()); + let channel_a = Uuid::new_v4(); + let channel_b = Uuid::new_v4(); + let channel_c = Uuid::new_v4(); + let args = ["acp".into()]; + + store_a.put("hermes", &args, &channel_a, "session-a"); + store_b.put("hermes", &args, &channel_b, "session-b"); + + let reopened = SessionStore::open(path.clone()); + assert_eq!( + reopened + .get("hermes", &args, &channel_a) + .map(|binding| binding.session_id), + Some("session-a".to_string()) + ); + assert_eq!( + reopened + .get("hermes", &args, &channel_b) + .map(|binding| binding.session_id), + Some("session-b".to_string()) + ); + + // Open both before either mutation. A stale process-local snapshot would + // resurrect channel A when the second store writes channel C. + let remover = SessionStore::open(path.clone()); + let writer = SessionStore::open(path.clone()); + assert!(remover.remove_if_equals("hermes", &args, &channel_a, "session-a")); + writer.put("hermes", &args, &channel_c, "session-c"); + + let final_store = SessionStore::open(path); + assert!(final_store.get("hermes", &args, &channel_a).is_none()); + assert_eq!( + final_store + .get("hermes", &args, &channel_b) + .map(|binding| binding.session_id), + Some("session-b".to_string()) + ); + assert_eq!( + final_store + .get("hermes", &args, &channel_c) + .map(|binding| binding.session_id), + Some("session-c".to_string()) + ); + } + + #[test] + fn put_recovers_from_corrupt_store() { + let dir = tempdir().unwrap(); + let path = dir.path().join("sessions.json"); + fs::write(&path, "{not-json").unwrap(); + let store = SessionStore::open(path.clone()); + let channel = Uuid::new_v4(); + store.put("hermes", &["acp".into()], &channel, "recovered"); + assert_eq!( + store + .get("hermes", &["acp".into()], &channel) + .map(|binding| binding.session_id), + Some("recovered".to_string()) + ); + } + + #[test] + fn remove_if_equals_does_not_delete_newer_binding() { + let dir = tempdir().unwrap(); + let path = dir.path().join("sessions.json"); + let args = ["acp".into()]; + let channel = Uuid::new_v4(); + + // Process A reads X. + let process_a = SessionStore::open(path.clone()); + process_a.put("hermes", &args, &channel, "session-x"); + let read_x = process_a + .get("hermes", &args, &channel) + .expect("process A read X"); + assert_eq!(read_x.session_id, "session-x"); + + // Process B writes Y for the same channel. + let process_b = SessionStore::open(path.clone()); + process_b.put("hermes", &args, &channel, "session-y"); + assert_eq!( + process_b + .get("hermes", &args, &channel) + .map(|binding| binding.session_id), + Some("session-y".to_string()) + ); + + // Process A's failed load of X must not delete Y. + let removed = process_a.remove_if_equals("hermes", &args, &channel, &read_x.session_id); + assert!(!removed); + + let final_store = SessionStore::open(path); + assert_eq!( + final_store + .get("hermes", &args, &channel) + .map(|binding| binding.session_id), + Some("session-y".to_string()) + ); + } + + #[test] + fn remove_if_equals_clears_matching_stale_binding() { + let dir = tempdir().unwrap(); + let path = dir.path().join("sessions.json"); + let args = ["acp".into()]; + let channel = Uuid::new_v4(); + let store = SessionStore::open(path.clone()); + store.put("hermes", &args, &channel, "session-x"); + assert!(store.remove_if_equals("hermes", &args, &channel, "session-x")); + assert!(store.get("hermes", &args, &channel).is_none()); + // No-op when already gone. + assert!(!store.remove_if_equals("hermes", &args, &channel, "session-x")); + } + + #[test] + fn remove_clears_whichever_binding_is_current() { + let dir = tempdir().unwrap(); + let path = dir.path().join("sessions.json"); + let args = ["acp".into()]; + let channel = Uuid::new_v4(); + let store = SessionStore::open(path); + store.put("hermes", &args, &channel, "session-x"); + store.put("hermes", &args, &channel, "session-y"); + + assert!(store.remove("hermes", &args, &channel)); + assert!(store.get("hermes", &args, &channel).is_none()); + assert!(!store.remove("hermes", &args, &channel)); + } +} diff --git a/crates/buzz-acp/src/setup_mode.rs b/crates/buzz-acp/src/setup_mode.rs index b1a9372ea4..6a63103d25 100644 --- a/crates/buzz-acp/src/setup_mode.rs +++ b/crates/buzz-acp/src/setup_mode.rs @@ -73,7 +73,7 @@ pub(crate) enum AcpAvailabilityStatus { use crate::{ author_allowed, config::Config, - event_mentions_agent, filter, + filter, relay::{HarnessRelay, RelayEventPublisher}, }; @@ -421,7 +421,11 @@ pub(crate) async fn run_setup_listener(config: Config, payload: SetupPayload) -> // Require an explicit @mention of this agent — setup mode must not // nudge on every channel event even if subscribe_mode is "all". - if !event_mentions_agent(&buzz_event.event, &pubkey_hex) { + if !filter::event_mentions_agent( + &buzz_event.event, + &pubkey_hex, + config.session_title.as_deref(), + ) { continue; } @@ -441,11 +445,12 @@ pub(crate) async fn run_setup_listener(config: Config, payload: SetupPayload) -> .await; // Apply channel/kind filter rules. - let filter_matched = filter::match_event( + let filter_matched = filter::match_event_with_display_name( &buzz_event.event, buzz_event.channel_id, &rules, &pubkey_hex, + config.session_title.as_deref(), ) .await .is_some(); diff --git a/crates/buzz-cli/Cargo.toml b/crates/buzz-cli/Cargo.toml index 1476e60bfd..f3bdb2046a 100644 --- a/crates/buzz-cli/Cargo.toml +++ b/crates/buzz-cli/Cargo.toml @@ -63,6 +63,11 @@ bytes = "1" # MIME type detection via magic bytes — file upload validation infer = "0.19" +# Agent-uploaded figures follow the same metadata-free, 25 MP contract as the +# desktop uploader and relay. +image = { version = "0.25", default-features = false, features = ["jpeg", "png", "webp"] } +imagesize = "0.14" + # URL parsing — extract server domain for Blossom auth tag url = { workspace = true } diff --git a/crates/buzz-cli/src/client.rs b/crates/buzz-cli/src/client.rs index ee8868ad92..6d781bf7b2 100644 --- a/crates/buzz-cli/src/client.rs +++ b/crates/buzz-cli/src/client.rs @@ -34,6 +34,10 @@ pub struct BlobDescriptor { /// Duration in seconds for video/audio (optional). #[serde(skip_serializing_if = "Option::is_none")] pub duration: Option, + /// Original client-side filename. The content-addressed relay does not + /// persist it, but message imeta uses it for attachment labels. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub filename: Option, } /// Build an `imeta` tag array from a BlobDescriptor (NIP-92 media metadata). @@ -57,24 +61,70 @@ pub fn build_imeta_tag(d: &BlobDescriptor) -> Vec { if let Some(dur) = d.duration { tag.push(format!("duration {dur}")); } + if let Some(ref filename) = d.filename { + tag.push(format!("filename {filename}")); + } tag } -/// MIME types accepted for upload. -const ALLOWED_MIMES: &[&str] = &[ - "image/jpeg", - "image/png", - "image/gif", - "image/webp", - "video/mp4", -]; - /// Maximum file size for image uploads (50 MB). const MAX_IMAGE_BYTES: u64 = 50 * 1024 * 1024; /// Maximum file size for video uploads (500 MB). const MAX_VIDEO_BYTES: u64 = 500 * 1024 * 1024; +/// Maximum file size for generic attachments (100 MB). +const MAX_FILE_BYTES: u64 = 100 * 1024 * 1024; + +fn sanitize_upload_filename(file_path: &str) -> String { + let base = file_path + .rsplit(['/', '\\']) + .next() + .unwrap_or(file_path) + .trim(); + let cleaned: String = base.chars().filter(|c| !c.is_control()).take(255).collect(); + if cleaned.is_empty() { + "file".to_string() + } else { + cleaned + } +} + +fn text_mime_from_filename(filename: &str, bytes: &[u8]) -> Option<&'static str> { + if bytes.contains(&0) || std::str::from_utf8(bytes).is_err() { + return None; + } + let extension = std::path::Path::new(filename) + .extension()? + .to_str()? + .to_ascii_lowercase(); + match extension.as_str() { + "md" | "markdown" | "mdown" | "mkd" => Some("text/markdown"), + "txt" | "log" => Some("text/plain"), + "csv" => Some("text/csv"), + "json" | "jsonc" | "jsonl" => Some("application/json"), + "c" | "cc" | "cpp" | "cs" | "css" | "go" | "h" | "hpp" | "java" | "js" | "jsx" | "kt" + | "kts" | "lua" | "php" | "pl" | "ps1" | "py" | "rb" | "rs" | "sh" | "sql" | "swift" + | "toml" | "ts" | "tsx" | "xml" | "yaml" | "yml" => Some("text/plain"), + _ => None, + } +} + +fn detect_upload_mime(bytes: &[u8], filename: &str) -> String { + infer::get(bytes) + .map(|t| t.mime_type().to_string()) + .or_else(|| text_mime_from_filename(filename, bytes).map(str::to_string)) + .unwrap_or_else(|| "application/octet-stream".to_string()) +} + +fn preserve_client_text_mime(descriptor: &mut BlobDescriptor, upload_mime: &str) { + if descriptor.mime_type == "application/octet-stream" + && (upload_mime.starts_with("text/") || upload_mime == "application/json") + { + descriptor.mime_type = upload_mime.to_string(); + } +} + /// Sign a NIP-98 HTTP auth event (kind:27235) and return the Authorization header value. /// /// The event includes: @@ -1107,21 +1157,20 @@ impl BuzzClient { let bytes = std::fs::read(file_path) .map_err(|e| CliError::Other(format!("failed to read {file_path}: {e}")))?; + let filename = sanitize_upload_filename(file_path); - // 2. Detect MIME from magic bytes - let mime = infer::get(&bytes) - .map(|t| t.mime_type().to_string()) - .unwrap_or_else(|| "application/octet-stream".to_string()); - - if !ALLOWED_MIMES.contains(&mime.as_str()) { - return Err(CliError::Usage(format!("unsupported file type: {mime}"))); - } + // 2. Prefer magic bytes, then use a text filename hint only for valid + // UTF-8 without NUL bytes. The relay remains authoritative for binary + // validation, while Markdown and source files keep useful metadata. + let mime = detect_upload_mime(&bytes, &filename); // 3. Size check let max = if mime.starts_with("video/") { MAX_VIDEO_BYTES - } else { + } else if mime.starts_with("image/") { MAX_IMAGE_BYTES + } else { + MAX_FILE_BYTES }; if bytes.len() as u64 > max { return Err(CliError::Usage(format!( @@ -1131,6 +1180,31 @@ impl BuzzClient { ))); } + // Agent-generated figures often contain editor metadata that the relay + // intentionally rejects, and scientific plots can exceed its 25 MP + // decode budget. Apply the same metadata-free contract as the desktop + // uploader and scale by pixel area (not an arbitrary 5000px edge cap). + let bytes = if mime.starts_with("image/") { + let prepared = + crate::image_upload::prepare_image_upload(bytes, &mime).map_err(CliError::Usage)?; + if let Some((original_width, original_height)) = prepared.resized_from { + eprintln!( + "buzz: resized figure from {original_width}x{original_height} to {}x{} to fit the relay's 25 MP limit", + prepared.dimensions.0, prepared.dimensions.1 + ); + } + prepared.bytes + } else { + bytes + }; + if bytes.len() as u64 > max { + return Err(CliError::Usage(format!( + "sanitized file is too large: {} bytes (max {})", + bytes.len(), + max + ))); + } + // 4. SHA-256 let sha256 = hex::encode(Sha256::digest(&bytes)); @@ -1153,6 +1227,7 @@ impl BuzzClient { let url = url.clone(); let mime = mime.clone(); let sha256 = sha256.clone(); + let filename = filename.clone(); async move { let auth_header = sign_blossom_upload(&self.keys, &sha256, &mime, &self.relay_url)?; @@ -1164,6 +1239,11 @@ impl BuzzClient { .header("Authorization", auth_header) .header("Content-Type", &mime) .header("X-SHA-256", &sha256) + .header( + "X-Buzz-Filename", + base64::engine::general_purpose::URL_SAFE_NO_PAD + .encode(&filename), + ) .body(upload_body), ) .send() @@ -1183,7 +1263,11 @@ impl BuzzClient { // (404 or 405), fall back to the legacy /media/upload endpoint. The 404/405 switch // itself is not retried; only transient failures on the selected legacy endpoint are. match result { - Ok(desc) => return Ok(desc), + Ok(mut desc) => { + preserve_client_text_mime(&mut desc, &mime); + desc.filename = Some(filename); + return Ok(desc); + } Err(CliError::Relay { status: s, body: _ }) if should_retry_legacy_upload( reqwest::StatusCode::from_u16(s).unwrap_or(reqwest::StatusCode::NOT_FOUND), @@ -1195,34 +1279,45 @@ impl BuzzClient { } let legacy_url = format!("{}/media/upload", self.relay_url); - self.with_retry_body(|| { - let upload_body = upload_body.clone(); - let legacy_url = legacy_url.clone(); - let mime = mime.clone(); - let sha256 = sha256.clone(); - async move { - let auth_header = sign_blossom_upload(&self.keys, &sha256, &mime, &self.relay_url)?; - let resp = self - .with_auth_tag( - self.http - .put(&legacy_url) - .timeout(upload_timeout) - .header("Authorization", auth_header) - .header("Content-Type", &mime) - .header("X-SHA-256", &sha256) - .body(upload_body), - ) - .send() - .await?; - if !resp.status().is_success() { - let status = resp.status().as_u16(); - let body = resp.text().await.unwrap_or_default(); - return Err(CliError::Relay { status, body }); + let mut desc = self + .with_retry_body(|| { + let upload_body = upload_body.clone(); + let legacy_url = legacy_url.clone(); + let mime = mime.clone(); + let sha256 = sha256.clone(); + let filename = filename.clone(); + async move { + let auth_header = + sign_blossom_upload(&self.keys, &sha256, &mime, &self.relay_url)?; + let resp = self + .with_auth_tag( + self.http + .put(&legacy_url) + .timeout(upload_timeout) + .header("Authorization", auth_header) + .header("Content-Type", &mime) + .header("X-SHA-256", &sha256) + .header( + "X-Buzz-Filename", + base64::engine::general_purpose::URL_SAFE_NO_PAD + .encode(&filename), + ) + .body(upload_body), + ) + .send() + .await?; + if !resp.status().is_success() { + let status = resp.status().as_u16(); + let body = resp.text().await.unwrap_or_default(); + return Err(CliError::Relay { status, body }); + } + resp.json::().await.map_err(CliError::from) } - resp.json::().await.map_err(CliError::from) - } - }) - .await + }) + .await?; + preserve_client_text_mime(&mut desc, &mime); + desc.filename = Some(filename); + Ok(desc) } /// Download a Blossom media blob using BUD-01 `t=get` auth. @@ -2125,13 +2220,14 @@ mod retry_policy_tests { use tokio::io::AsyncReadExt; use tokio::io::AsyncWriteExt; - // Write a minimal JPEG file so MIME detection works. + // Write a real tiny JPEG so the upload sanitizer and MIME detection + // exercise the same path as production figures. let mut tmp = tempfile::NamedTempFile::new().unwrap(); - // JPEG magic + JFIF app0 marker: enough for `infer` to detect image/jpeg. - let jpeg_header: &[u8] = &[ - 0xFF, 0xD8, 0xFF, 0xE0, 0x00, 0x10, 0x4A, 0x46, 0x49, 0x46, 0x00, 0x01, - ]; - tmp.write_all(jpeg_header).unwrap(); + let mut jpeg = std::io::Cursor::new(Vec::new()); + image::DynamicImage::new_rgb8(1, 1) + .write_to(&mut jpeg, image::ImageFormat::Jpeg) + .unwrap(); + tmp.write_all(jpeg.get_ref()).unwrap(); let file_path = tmp.path().to_str().unwrap().to_string(); let counter = Arc::new(AtomicU32::new(0)); @@ -2205,6 +2301,64 @@ mod retry_policy_tests { ); } + #[tokio::test] + async fn upload_accepts_markdown_and_preserves_its_filename() { + use tokio::io::{AsyncReadExt, AsyncWriteExt}; + + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("release-notes.md"); + std::fs::write(&path, b"# Release notes\n\nOne-click message sending.\n").unwrap(); + + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + let server = tokio::spawn(async move { + let (mut stream, _) = listener.accept().await.unwrap(); + let mut request = vec![0u8; 8192]; + let read = stream.read(&mut request).await.unwrap(); + let request = String::from_utf8_lossy(&request[..read]); + assert!(request.contains("content-type: text/markdown")); + + let body = r#"{"url":"https://relay.test/media/aabbcc.bin","sha256":"aabbcc","size":44,"type":"application/octet-stream","uploaded":0}"#; + let response = format!( + "HTTP/1.1 200 OK\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{}", + body.len(), + body + ); + stream.write_all(response.as_bytes()).await.unwrap(); + }); + + let client = test_client(&format!("http://{addr}")); + let descriptor = client.upload_file(path.to_str().unwrap()).await.unwrap(); + assert_eq!(descriptor.mime_type, "text/markdown"); + assert_eq!(descriptor.filename.as_deref(), Some("release-notes.md")); + assert!(super::build_imeta_tag(&descriptor) + .iter() + .any(|field| field == "filename release-notes.md")); + server.await.unwrap(); + } + + #[test] + fn upload_mime_uses_filename_only_for_safe_text() { + assert_eq!( + super::detect_upload_mime(b"# Notes\n", "notes.md"), + "text/markdown" + ); + assert_eq!( + super::detect_upload_mime(br#"{"ok":true}"#, "result.json"), + "application/json" + ); + assert_eq!( + super::detect_upload_mime(b"fn main() {}\n", "main.rs"), + "text/plain" + ); + assert_eq!( + super::detect_upload_mime(b"not utf-8: \xff", "fake.md"), + "application/octet-stream" + ); + let elf = [b"\x7fELF".as_slice(), &[0u8; 60]].concat(); + assert_ne!(super::detect_upload_mime(&elf, "fake.md"), "text/markdown"); + } + /// When all retry attempts for a stored event end with a partial body (200 /// headers, dropped connection), the final error must be `DeliveryUnknown` /// (retryable:false) — the relay may have stored the event on any attempt, so diff --git a/crates/buzz-cli/src/commands/agents.rs b/crates/buzz-cli/src/commands/agents.rs index 568249d082..e47d8f77f7 100644 --- a/crates/buzz-cli/src/commands/agents.rs +++ b/crates/buzz-cli/src/commands/agents.rs @@ -1,4 +1,7 @@ -use buzz_core::kind::KIND_IA_ARCHIVED_LIST; +use buzz_core::agent_handoff::{ + build_agent_handoff_event, decrypt_agent_handoff, AgentHandoffPayload, HANDOFF_VERSION, +}; +use buzz_core::kind::{KIND_AGENT_HANDOFF, KIND_IA_ARCHIVED_LIST}; use buzz_sdk::builders::{build_archive_identity_request, build_unarchive_identity_request}; use nostr::PublicKey; use serde_json::json; @@ -6,8 +9,8 @@ use serde_json::json; use crate::agent_management::{build_create, build_update, CreateAgentDraft, UpdateAgentDraft}; use crate::client::BuzzClient; use crate::error::CliError; -use crate::validate::{read_or_stdin, validate_hex64}; -use crate::{AgentsCmd, RespondToArg}; +use crate::validate::{parse_event_id, read_file_or_stdin, read_or_stdin, validate_hex64}; +use crate::{AgentHandoffCmd, AgentsCmd, RespondToArg}; pub async fn dispatch(command: AgentsCmd, client: &BuzzClient) -> Result<(), CliError> { match command { @@ -85,6 +88,8 @@ pub async fn dispatch(command: AgentsCmd, client: &BuzzClient) -> Result<(), Cli Ok(()) } + AgentsCmd::Handoff(command) => dispatch_handoff(command, client).await, + AgentsCmd::Archive { target_pubkey, reason, @@ -167,6 +172,124 @@ pub async fn dispatch(command: AgentsCmd, client: &BuzzClient) -> Result<(), Cli } } +async fn dispatch_handoff(command: AgentHandoffCmd, client: &BuzzClient) -> Result<(), CliError> { + match command { + AgentHandoffCmd::Send { + to, + title, + summary, + history_file, + } => { + validate_hex64(&to)?; + let recipient = PublicKey::from_hex(&to) + .map_err(|e| CliError::Usage(format!("invalid --to pubkey: {e}")))?; + if recipient == client.keys().public_key() { + return Err(CliError::Usage( + "handoff recipient must be a different Agent".into(), + )); + } + let payload = AgentHandoffPayload { + version: HANDOFF_VERSION, + title, + summary, + history: read_file_or_stdin(&history_file)?, + }; + let event = build_agent_handoff_event(client.keys(), &recipient, &payload) + .map_err(|e| CliError::Usage(format!("invalid handoff: {e}")))?; + let event_id = event.id.to_hex(); + let raw = client.submit_event(event).await?; + let response: serde_json::Value = serde_json::from_str(&raw) + .map_err(|e| CliError::Other(format!("invalid relay response: {e}")))?; + println!( + "{}", + json!({ + "event_id": event_id, + "accepted": response.get("accepted").and_then(|v| v.as_bool()).unwrap_or(false), + "to": recipient.to_hex(), + "title": payload.title, + }) + ); + Ok(()) + } + AgentHandoffCmd::List { limit } => { + if limit == 0 || limit > 500 { + return Err(CliError::Usage("--limit must be between 1 and 500".into())); + } + let me = client.keys().public_key().to_hex(); + let filter = json!({ + "kinds": [KIND_AGENT_HANDOFF], + "#p": [me], + "limit": limit, + }); + let events = parse_handoff_events(&client.query(&filter).await?)?; + let mut records = Vec::new(); + for event in events { + if event.verify().is_err() { + continue; + } + let Ok(payload) = decrypt_agent_handoff(client.keys(), &event) else { + continue; + }; + records.push(json!({ + "event_id": event.id.to_hex(), + "from": event.pubkey.to_hex(), + "created_at": event.created_at.as_secs(), + "title": payload.title, + "summary": payload.summary, + })); + } + records.sort_by_key(|record| { + std::cmp::Reverse(record["created_at"].as_u64().unwrap_or_default()) + }); + println!("{}", serde_json::Value::Array(records)); + Ok(()) + } + AgentHandoffCmd::Show { event_id } => { + parse_event_id(&event_id)?; + let me = client.keys().public_key().to_hex(); + let filter = json!({ + "kinds": [KIND_AGENT_HANDOFF], + "ids": [event_id], + "#p": [me], + "limit": 1, + }); + let mut events = parse_handoff_events(&client.query(&filter).await?)?; + let event = events.pop().ok_or_else(|| { + CliError::Other("handoff not found or not addressed to this Agent".into()) + })?; + event + .verify() + .map_err(|e| CliError::Other(format!("handoff signature is invalid: {e}")))?; + let payload = decrypt_agent_handoff(client.keys(), &event) + .map_err(|e| CliError::Other(format!("failed to decrypt handoff: {e}")))?; + println!( + "{}", + json!({ + "event_id": event.id.to_hex(), + "from": event.pubkey.to_hex(), + "created_at": event.created_at.as_secs(), + "title": payload.title, + "summary": payload.summary, + "history": payload.history, + }) + ); + Ok(()) + } + } +} + +fn parse_handoff_events(raw: &str) -> Result, CliError> { + let value: serde_json::Value = serde_json::from_str(raw) + .map_err(|e| CliError::Other(format!("relay returned invalid JSON: {e}")))?; + let array = value + .as_array() + .ok_or_else(|| CliError::Other("relay response is not an array".into()))?; + Ok(array + .iter() + .filter_map(|value| serde_json::from_value(value.clone()).ok()) + .collect()) +} + /// Require `BUZZ_AUTH_TAG` and parse the owner pubkey from it. Used only by /// the `draft-create` and `draft-update` paths. fn require_owner(client: &BuzzClient) -> Result { diff --git a/crates/buzz-cli/src/commands/messages.rs b/crates/buzz-cli/src/commands/messages.rs index 40a9ae80b5..8581c29f54 100644 --- a/crates/buzz-cli/src/commands/messages.rs +++ b/crates/buzz-cli/src/commands/messages.rs @@ -571,6 +571,32 @@ pub struct SendMessageParams { pub mentions: Vec, } +fn format_uploaded_attachment(desc: &crate::client::BlobDescriptor) -> String { + if desc.mime_type.starts_with("video/") { + return format!("\n![video]({})", desc.url); + } + if desc.mime_type.starts_with("image/") { + return format!("\n![image]({})", desc.url); + } + + // Generic files must be links, not image syntax. The matching imeta tag + // carries the original filename even when an older relay stores opaque + // content under a `.bin` URL. + let raw_label = desc + .filename + .as_deref() + .or_else(|| desc.url.rsplit('/').next()) + .unwrap_or("file"); + let mut label = String::with_capacity(raw_label.len()); + for character in raw_label.chars() { + if matches!(character, '\\' | '[' | ']') { + label.push('\\'); + } + label.push(character); + } + format!("\n[{label}]({})", desc.url) +} + pub async fn cmd_send_message( client: &BuzzClient, mut p: SendMessageParams, @@ -619,13 +645,7 @@ pub async fn cmd_send_message( .await .map_err(|e| CliError::Other(format!("upload failed for {file_path}: {e}")))?; media_tags.push(crate::client::build_imeta_tag(&desc)); - if desc.mime_type.starts_with("video/") { - media_content.push_str("\n![video]("); - } else { - media_content.push_str("\n![image]("); - } - media_content.push_str(&desc.url); - media_content.push(')'); + media_content.push_str(&format_uploaded_attachment(&desc)); } let final_content = if media_content.is_empty() { p.content.clone() @@ -993,9 +1013,9 @@ pub async fn dispatch( #[cfg(test)] mod tests { use super::{ - event_mention_pubkeys, find_root_from_tags, match_profiles_by_name, merge_message_mentions, - missing_members, normalize_explicit_mentions, parse_member_pubkeys, - resolve_names_to_pubkeys, + event_mention_pubkeys, find_root_from_tags, format_uploaded_attachment, + match_profiles_by_name, merge_message_mentions, missing_members, + normalize_explicit_mentions, parse_member_pubkeys, resolve_names_to_pubkeys, }; use buzz_sdk::mentions::{ extract_at_mentions_with_known, extract_at_names, match_names_to_profiles, MentionProfile, @@ -1006,6 +1026,52 @@ mod tests { const ID_B: &str = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"; const PUBKEY: &str = "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc"; + fn attachment( + mime_type: &str, + filename: Option<&str>, + url: &str, + ) -> crate::client::BlobDescriptor { + crate::client::BlobDescriptor { + url: url.to_string(), + sha256: "a".repeat(64), + size: 12, + mime_type: mime_type.to_string(), + uploaded: 0, + dim: None, + blurhash: None, + thumb: None, + duration: None, + filename: filename.map(str::to_string), + } + } + + #[test] + fn generic_attachment_uses_filename_link_even_for_bin_url() { + let descriptor = attachment( + "application/octet-stream", + Some("release-notes.md"), + "https://relay.test/media/aaaaaaaa.bin", + ); + assert_eq!( + format_uploaded_attachment(&descriptor), + "\n[release-notes.md](https://relay.test/media/aaaaaaaa.bin)" + ); + } + + #[test] + fn media_attachments_keep_inline_markdown() { + let image = attachment("image/png", Some("plot.png"), "https://relay.test/a.png"); + let video = attachment("video/mp4", Some("clip.mp4"), "https://relay.test/a.mp4"); + assert_eq!( + format_uploaded_attachment(&image), + "\n![image](https://relay.test/a.png)" + ); + assert_eq!( + format_uploaded_attachment(&video), + "\n![video](https://relay.test/a.mp4)" + ); + } + // Three real pubkeys (lowercase 64-char hex) used by parse_member_pubkeys tests. // See the test's own comment on what `PublicKey::from_hex` actually validates. const PK_VALID_A: &str = "35c18ae273fccfaf80d629e20e7f8721b90499379addff533054acc2504c12b4"; diff --git a/crates/buzz-cli/src/commands/upload.rs b/crates/buzz-cli/src/commands/upload.rs index d7543cb71a..a91bf6bd45 100644 --- a/crates/buzz-cli/src/commands/upload.rs +++ b/crates/buzz-cli/src/commands/upload.rs @@ -1,19 +1,80 @@ use crate::client::BuzzClient; use crate::error::CliError; +fn standalone_upload_output( + descriptor: &crate::client::BlobDescriptor, +) -> Result { + let mut output = + serde_json::to_value(descriptor).map_err(|error| CliError::Other(error.to_string()))?; + let object = output + .as_object_mut() + .ok_or_else(|| CliError::Other("upload result was not an object".to_string()))?; + let raw_label = descriptor.filename.as_deref().unwrap_or("file"); + let mut label = String::with_capacity(raw_label.len()); + for character in raw_label.chars() { + if matches!(character, '\\' | '[' | ']') { + label.push('\\'); + } + label.push(character); + } + object.insert( + "attachment_markdown".to_string(), + serde_json::Value::String(format!("[{label}]({})", descriptor.url)), + ); + object.insert( + "delivery_hint".to_string(), + serde_json::Value::String( + "Do not send the bare url to a Buzz channel. Retry with `buzz messages send --channel --content \"attached\" --file ` so the original filename and preview metadata are included." + .to_string(), + ), + ); + Ok(output) +} + pub async fn dispatch(cmd: crate::UploadCmd, client: &BuzzClient) -> Result<(), CliError> { match cmd { crate::UploadCmd::File { file } => { let desc = client.upload_file(&file).await?; println!( "{}", - serde_json::to_string_pretty(&desc).map_err(|e| CliError::Other(e.to_string()))? + serde_json::to_string_pretty(&standalone_upload_output(&desc)?) + .map_err(|e| CliError::Other(e.to_string()))? ); Ok(()) } } } +#[cfg(test)] +mod tests { + use super::standalone_upload_output; + + #[test] + fn standalone_upload_warns_against_bare_urls_and_preserves_filename_link() { + let descriptor = crate::client::BlobDescriptor { + url: "https://relay.test/media/abc.bin".to_string(), + sha256: "a".repeat(64), + size: 12, + mime_type: "application/octet-stream".to_string(), + uploaded: 0, + dim: None, + blurhash: None, + thumb: None, + duration: None, + filename: Some("notes[final].md".to_string()), + }; + + let output = standalone_upload_output(&descriptor).expect("serialize upload output"); + assert_eq!( + output["attachment_markdown"], + "[notes\\[final\\].md](https://relay.test/media/abc.bin)" + ); + assert!(output["delivery_hint"] + .as_str() + .is_some_and(|hint| hint.contains("messages send") && hint.contains("--file"))); + } +} + pub async fn dispatch_media(cmd: crate::MediaCmd, client: &BuzzClient) -> Result<(), CliError> { match cmd { crate::MediaCmd::Get { input, output } => { diff --git a/crates/buzz-cli/src/image_upload.rs b/crates/buzz-cli/src/image_upload.rs new file mode 100644 index 0000000000..686759fd5a --- /dev/null +++ b/crates/buzz-cli/src/image_upload.rs @@ -0,0 +1,298 @@ +use image::{GenericImageView, ImageDecoder}; + +const MAX_IMAGE_PIXELS: u64 = 25_000_000; +const PNG_SNAPSHOT_KEYWORDS: [&[u8]; 2] = [b"buzz_agent_snapshot", b"buzz_team_snapshot"]; + +pub(crate) struct PreparedImageUpload { + pub bytes: Vec, + pub resized_from: Option<(u32, u32)>, + pub dimensions: (u32, u32), +} + +fn image_format(mime: &str) -> Option { + match mime { + "image/jpeg" => Some(image::ImageFormat::Jpeg), + "image/png" => Some(image::ImageFormat::Png), + "image/webp" => Some(image::ImageFormat::WebP), + _ => None, + } +} + +fn is_animated_image(bytes: &[u8], mime: &str) -> bool { + match mime { + "image/png" if bytes.starts_with(b"\x89PNG\r\n\x1a\n") => { + let mut offset = 8usize; + while offset.checked_add(12).is_some_and(|end| end <= bytes.len()) { + let length = u32::from_be_bytes([ + bytes[offset], + bytes[offset + 1], + bytes[offset + 2], + bytes[offset + 3], + ]) as usize; + let Some(end) = offset + .checked_add(12) + .and_then(|value| value.checked_add(length)) + else { + return false; + }; + if end > bytes.len() { + return false; + } + if &bytes[offset + 4..offset + 8] == b"acTL" { + return true; + } + offset = end; + } + false + } + "image/webp" + if bytes.len() >= 12 && bytes.starts_with(b"RIFF") && &bytes[8..12] == b"WEBP" => + { + let mut offset = 12usize; + while offset.checked_add(8).is_some_and(|end| end <= bytes.len()) { + let chunk = &bytes[offset..offset + 4]; + if chunk == b"ANIM" || chunk == b"ANMF" { + return true; + } + let length = u32::from_le_bytes([ + bytes[offset + 4], + bytes[offset + 5], + bytes[offset + 6], + bytes[offset + 7], + ]) as usize; + let Some(end) = length + .checked_add(length & 1) + .and_then(|value| offset.checked_add(8 + value)) + else { + return false; + }; + if end > bytes.len() { + return false; + } + offset = end; + } + false + } + _ => false, + } +} + +fn fit_dimensions_to_pixel_limit(width: u32, height: u32, max_pixels: u64) -> (u32, u32) { + let pixels = u64::from(width) * u64::from(height); + if pixels <= max_pixels || pixels == 0 { + return (width, height); + } + let scale = (max_pixels as f64 / pixels as f64).sqrt(); + let mut next_width = ((f64::from(width) * scale).floor() as u32).max(1); + let mut next_height = ((f64::from(height) * scale).floor() as u32).max(1); + while u64::from(next_width) * u64::from(next_height) > max_pixels { + if next_width >= next_height { + next_width -= 1; + } else { + next_height -= 1; + } + } + (next_width, next_height) +} + +fn extract_snapshot_text_chunk(bytes: &[u8]) -> Option> { + const SIGNATURE: &[u8] = b"\x89PNG\r\n\x1a\n"; + if !bytes.starts_with(SIGNATURE) { + return None; + } + let mut offset = SIGNATURE.len(); + while offset + 12 <= bytes.len() { + let length = u32::from_be_bytes(bytes[offset..offset + 4].try_into().ok()?) as usize; + let end = offset.checked_add(12)?.checked_add(length)?; + if end > bytes.len() { + return None; + } + let kind = &bytes[offset + 4..offset + 8]; + if kind == b"tEXt" { + let payload = &bytes[offset + 8..offset + 8 + length]; + let is_snapshot = PNG_SNAPSHOT_KEYWORDS.iter().any(|keyword| { + payload.len() > keyword.len() + && &payload[..keyword.len()] == *keyword + && payload[keyword.len()] == 0 + }); + if is_snapshot { + return Some(bytes[offset..end].to_vec()); + } + } + if kind == b"IEND" { + return None; + } + offset = end; + } + None +} + +fn inject_snapshot_text_chunk(png: Vec, chunk: &[u8]) -> Result, String> { + const SIGNATURE_LENGTH: usize = 8; + if png.len() < SIGNATURE_LENGTH + 12 + || &png[SIGNATURE_LENGTH + 4..SIGNATURE_LENGTH + 8] != b"IHDR" + { + return Err("sanitized PNG is missing its IHDR chunk".to_string()); + } + let ihdr_length = u32::from_be_bytes( + png[SIGNATURE_LENGTH..SIGNATURE_LENGTH + 4] + .try_into() + .map_err(|_| "sanitized PNG has a malformed IHDR length".to_string())?, + ) as usize; + let ihdr_end = SIGNATURE_LENGTH + .checked_add(12) + .and_then(|value| value.checked_add(ihdr_length)) + .filter(|end| *end <= png.len()) + .ok_or_else(|| "sanitized PNG has a malformed IHDR chunk".to_string())?; + let mut output = Vec::with_capacity(png.len() + chunk.len()); + output.extend_from_slice(&png[..ihdr_end]); + output.extend_from_slice(chunk); + output.extend_from_slice(&png[ihdr_end..]); + Ok(output) +} + +fn prepare_image_upload_with_limit( + bytes: Vec, + mime: &str, + max_pixels: u64, +) -> Result { + let Some(format) = image_format(mime) else { + let dimensions = imagesize::blob_size(&bytes) + .map(|size| (size.width as u32, size.height as u32)) + .unwrap_or((0, 0)); + return Ok(PreparedImageUpload { + bytes, + resized_from: None, + dimensions, + }); + }; + // Re-encoding animated PNG/WebP would flatten the animation. Preserve it + // byte-for-byte and leave the relay as the final structural validator. + if is_animated_image(&bytes, mime) { + let dimensions = imagesize::blob_size(&bytes) + .map(|size| (size.width as u32, size.height as u32)) + .unwrap_or((0, 0)); + return Ok(PreparedImageUpload { + bytes, + resized_from: None, + dimensions, + }); + } + + let snapshot_chunk = (format == image::ImageFormat::Png) + .then(|| extract_snapshot_text_chunk(&bytes)) + .flatten(); + let reader = image::ImageReader::with_format(std::io::Cursor::new(&bytes), format); + let mut decoder = reader + .into_decoder() + .map_err(|_| "failed to decode image for safe upload".to_string())?; + decoder + .set_limits(image::Limits::default()) + .map_err(|_| "image exceeds safe decoding limits".to_string())?; + let orientation = decoder + .orientation() + .map_err(|_| "failed to read image orientation".to_string())?; + let mut image = image::DynamicImage::from_decoder(decoder) + .map_err(|_| "failed to decode image for safe upload".to_string())?; + image.apply_orientation(orientation); + + let original_dimensions = image.dimensions(); + let fitted = + fit_dimensions_to_pixel_limit(original_dimensions.0, original_dimensions.1, max_pixels); + let resized_from = (fitted != original_dimensions).then_some(original_dimensions); + if resized_from.is_some() { + image = image.resize_exact(fitted.0, fitted.1, image::imageops::FilterType::Lanczos3); + } + + let mut output = std::io::Cursor::new(Vec::new()); + if format == image::ImageFormat::Jpeg { + image::codecs::jpeg::JpegEncoder::new_with_quality(&mut output, 92) + .encode_image(&image) + .map_err(|_| "failed to encode JPEG without metadata".to_string())?; + } else { + image + .write_to(&mut output, format) + .map_err(|_| "failed to encode image without metadata".to_string())?; + } + let output = match snapshot_chunk { + Some(chunk) => inject_snapshot_text_chunk(output.into_inner(), &chunk)?, + None => output.into_inner(), + }; + Ok(PreparedImageUpload { + bytes: output, + resized_from, + dimensions: fitted, + }) +} + +pub(crate) fn prepare_image_upload( + bytes: Vec, + mime: &str, +) -> Result { + prepare_image_upload_with_limit(bytes, mime, MAX_IMAGE_PIXELS) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn scientific_figure_dimensions_use_the_full_pixel_budget() { + let fitted = fit_dimensions_to_pixel_limit(6_000, 5_000, MAX_IMAGE_PIXELS); + assert!(u64::from(fitted.0) * u64::from(fitted.1) <= MAX_IMAGE_PIXELS); + assert!( + fitted.0 > 5_000, + "wide figures should not use a 5000px edge cap" + ); + assert!((f64::from(fitted.0) / f64::from(fitted.1) - 1.2).abs() < 0.001); + } + + #[test] + fn static_png_is_scrubbed_and_resized_without_changing_aspect_ratio() { + let image = image::DynamicImage::ImageRgba8(image::RgbaImage::from_pixel( + 100, + 80, + image::Rgba([10, 20, 30, 255]), + )); + let mut source = std::io::Cursor::new(Vec::new()); + image + .write_to(&mut source, image::ImageFormat::Png) + .unwrap(); + + let prepared = + prepare_image_upload_with_limit(source.into_inner(), "image/png", 5_000).unwrap(); + assert_eq!(prepared.resized_from, Some((100, 80))); + assert!(u64::from(prepared.dimensions.0) * u64::from(prepared.dimensions.1) <= 5_000); + assert!( + (f64::from(prepared.dimensions.0) / f64::from(prepared.dimensions.1) - 1.25).abs() + < 0.01 + ); + assert_eq!( + image::load_from_memory_with_format(&prepared.bytes, image::ImageFormat::Png) + .unwrap() + .dimensions(), + prepared.dimensions + ); + } + + #[test] + fn static_jpeg_metadata_is_removed_before_upload() { + let mut source = std::io::Cursor::new(Vec::new()); + image::DynamicImage::new_rgb8(2, 2) + .write_to(&mut source, image::ImageFormat::Jpeg) + .unwrap(); + let mut source = source.into_inner(); + source.splice(2..2, [0xff, 0xfe, 0x00, 0x06, b'B', b'U', b'Z', b'Z']); + assert!(source.windows(4).any(|window| window == b"BUZZ")); + + let prepared = prepare_image_upload(source, "image/jpeg").unwrap(); + + assert!(!prepared.bytes.windows(4).any(|window| window == b"BUZZ")); + assert_eq!( + image::load_from_memory_with_format(&prepared.bytes, image::ImageFormat::Jpeg) + .unwrap() + .dimensions(), + (2, 2) + ); + } +} diff --git a/crates/buzz-cli/src/lib.rs b/crates/buzz-cli/src/lib.rs index 3893c5b642..7b570e5cc8 100644 --- a/crates/buzz-cli/src/lib.rs +++ b/crates/buzz-cli/src/lib.rs @@ -2,6 +2,7 @@ pub mod agent_management; mod client; mod commands; mod error; +mod image_upload; mod links; mod validate; @@ -296,6 +297,9 @@ pub enum AgentsCmd { #[arg(long, value_enum)] respond_to: Option, }, + /// Send and read encrypted Agent handoff records + #[command(subcommand)] + Handoff(AgentHandoffCmd), /// Submit a NIP-IA archive request for an identity (kind 9035) #[command( after_help = "Auth flow: when target != signer, the CLI fetches the target's kind:0 and \ @@ -367,11 +371,44 @@ buzz agents archived" Archived, } +#[derive(Subcommand)] +pub enum AgentHandoffCmd { + /// Send a curated task history to another Agent + #[command( + after_help = "Example:\n buzz agents handoff send --to --title \"Continue file previews\" --summary \"Core implementation is complete\" --history-file handoff.md\n git diff | buzz agents handoff send --to --title \"Review current changes\" --history-file -" + )] + Send { + /// Receiving Agent pubkey (64-hex) + #[arg(long)] + to: String, + /// Short task title + #[arg(long)] + title: String, + /// Optional quick summary + #[arg(long)] + summary: Option, + /// Markdown history file, or '-' to read stdin + #[arg(long)] + history_file: String, + }, + /// List handoffs addressed to this Agent + List { + /// Maximum number of recent handoffs + #[arg(long, default_value_t = 50)] + limit: u32, + }, + /// Show and decrypt one handoff addressed to this Agent + Show { + /// Handoff event ID + event_id: String, + }, +} + #[derive(Subcommand)] pub enum MessagesCmd { /// Send a message to a channel #[command( - after_help = "Examples:\n buzz messages send --channel --content \"hello\"\n buzz messages send --channel --content \"@alice check this\"\n echo \"hello from stdin\" | buzz messages send --channel --content -" + after_help = "Examples:\n buzz messages send --channel --content \"hello\"\n buzz messages send --channel --content \"@alice check this\"\n buzz messages send --channel --content \"attached\" --file ./report.md\n echo \"hello from stdin\" | buzz messages send --channel --content -" )] Send { /// Channel UUID (from 'buzz channels list') @@ -1699,7 +1736,7 @@ pub enum IssuesCmd { #[derive(Subcommand)] pub enum UploadCmd { - /// Upload a file to the relay's Blossom store + /// Upload a file; static images are metadata-scrubbed and fit to 25 MP File { /// Path to the file to upload #[arg(long)] @@ -2175,6 +2212,7 @@ mod tests { "archived", "draft-create", "draft-update", + "handoff", "unarchive" ] ); @@ -2313,7 +2351,7 @@ mod tests { #[test] fn subcommand_counts_are_stable() { let expected: Vec<(&str, usize)> = vec![ - ("agents", 5), + ("agents", 6), ("canvas", 2), ("channels", 16), ("dms", 4), diff --git a/crates/buzz-core/src/agent_handoff.rs b/crates/buzz-core/src/agent_handoff.rs new file mode 100644 index 0000000000..97853a0974 --- /dev/null +++ b/crates/buzz-core/src/agent_handoff.rs @@ -0,0 +1,187 @@ +//! NIP-AH: encrypted agent-to-agent handoff records. +//! +//! A handoff is a durable, sender-authored record encrypted specifically for +//! one receiving agent. It carries a curated task transcript and transition +//! notes, not hidden model reasoning or an open-ended grant to future activity. + +use nostr::{Event, EventBuilder, Keys, Kind, PublicKey, Tag}; +use serde::{Deserialize, Serialize}; + +use crate::kind::KIND_AGENT_HANDOFF; +use crate::observer::{ + content_looks_like_nip44, decrypt_observer_payload, encrypt_observer_payload, + ObserverPayloadError, +}; + +/// Current handoff payload schema version. +pub const HANDOFF_VERSION: u8 = 1; +/// Maximum UTF-8 bytes accepted for a handoff title. +pub const MAX_TITLE_BYTES: usize = 200; +/// Maximum UTF-8 bytes accepted for the optional summary. +pub const MAX_SUMMARY_BYTES: usize = 4_000; +/// Maximum UTF-8 bytes accepted for the curated history body. +pub const MAX_HISTORY_BYTES: usize = 56_000; + +/// Decrypted handoff content. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AgentHandoffPayload { + /// Payload schema version. + pub version: u8, + /// Short human-readable task name. + pub title: String, + /// Optional executive summary for quick scanning. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub summary: Option, + /// Curated Markdown transcript and transition notes. + pub history: String, +} + +impl AgentHandoffPayload { + /// Validate payload version and bounded text fields. + pub fn validate(&self) -> Result<(), ObserverPayloadError> { + if self.version != HANDOFF_VERSION { + return Err(ObserverPayloadError::InvalidPayload(format!( + "unsupported handoff version {}", + self.version + ))); + } + validate_text("title", &self.title, 1, MAX_TITLE_BYTES)?; + if let Some(summary) = &self.summary { + validate_text("summary", summary, 1, MAX_SUMMARY_BYTES)?; + } + validate_text("history", &self.history, 1, MAX_HISTORY_BYTES) + } +} + +fn validate_text( + field: &str, + value: &str, + min: usize, + max: usize, +) -> Result<(), ObserverPayloadError> { + let bytes = value.as_bytes().len(); + if value.trim().is_empty() || bytes < min || bytes > max { + return Err(ObserverPayloadError::InvalidPayload(format!( + "{field} must contain {min}..={max} UTF-8 bytes (got {bytes})" + ))); + } + Ok(()) +} + +/// Build and sign a handoff event encrypted to `recipient`. +pub fn build_agent_handoff_event( + sender_keys: &Keys, + recipient: &PublicKey, + payload: &AgentHandoffPayload, +) -> Result { + payload.validate()?; + let ciphertext = encrypt_observer_payload(sender_keys, recipient, payload)?; + EventBuilder::new(Kind::Custom(KIND_AGENT_HANDOFF as u16), ciphertext) + .tags([ + Tag::public_key(*recipient), + Tag::parse(["handoff", &HANDOFF_VERSION.to_string()]).map_err(|error| { + ObserverPayloadError::InvalidPayload(format!("invalid handoff tag: {error}")) + })?, + ]) + .sign_with_keys(sender_keys) + .map_err(|error| { + ObserverPayloadError::InvalidPayload(format!("failed to sign handoff: {error}")) + }) +} + +/// Decrypt and validate a handoff addressed to `recipient_keys`. +pub fn decrypt_agent_handoff( + recipient_keys: &Keys, + event: &Event, +) -> Result { + validate_agent_handoff_envelope(event)?; + let payload: AgentHandoffPayload = decrypt_observer_payload(recipient_keys, event)?; + payload.validate()?; + Ok(payload) +} + +/// Validate the public envelope without decrypting its content. +pub fn validate_agent_handoff_envelope(event: &Event) -> Result<(), ObserverPayloadError> { + if event.kind.as_u16() as u32 != KIND_AGENT_HANDOFF { + return Err(ObserverPayloadError::InvalidPayload( + "not an agent handoff event".to_string(), + )); + } + if !content_looks_like_nip44(&event.content) { + return Err(ObserverPayloadError::InvalidCiphertextLength( + event.content.len(), + )); + } + let p_tags = event + .tags + .iter() + .filter(|tag| tag.kind().to_string() == "p") + .count(); + if p_tags != 1 { + return Err(ObserverPayloadError::InvalidPayload(format!( + "handoff requires exactly one p tag (got {p_tags})" + ))); + } + let version_ok = event.tags.iter().any(|tag| { + let parts = tag.as_slice(); + parts.len() == 2 + && parts[0].as_str() == "handoff" + && parts[1].as_str() == HANDOFF_VERSION.to_string() + }); + if !version_ok { + return Err(ObserverPayloadError::InvalidPayload( + "handoff version tag is missing or unsupported".to_string(), + )); + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn sample() -> AgentHandoffPayload { + AgentHandoffPayload { + version: HANDOFF_VERSION, + title: "Continue attachment previews".to_string(), + summary: Some("Markdown and PDF work; CSV needs tests.".to_string()), + history: "## Completed\n- Added preview routing\n\n## Next\n- Test CSV".to_string(), + } + } + + #[test] + fn handoff_round_trips_only_for_recipient() { + let sender = Keys::generate(); + let recipient = Keys::generate(); + let unrelated = Keys::generate(); + let event = build_agent_handoff_event(&sender, &recipient.public_key(), &sample()) + .expect("build handoff"); + + assert_eq!(decrypt_agent_handoff(&recipient, &event).unwrap(), sample()); + assert!(decrypt_agent_handoff(&unrelated, &event).is_err()); + } + + #[test] + fn rejects_blank_or_oversized_history() { + let mut payload = sample(); + payload.history = " ".to_string(); + assert!(payload.validate().is_err()); + payload.history = "x".repeat(MAX_HISTORY_BYTES + 1); + assert!(payload.validate().is_err()); + } + + #[test] + fn envelope_requires_exactly_one_recipient() { + let sender = Keys::generate(); + let recipient = Keys::generate(); + let event = EventBuilder::new( + Kind::Custom(KIND_AGENT_HANDOFF as u16), + encrypt_observer_payload(&sender, &recipient.public_key(), &sample()).unwrap(), + ) + .tags([Tag::parse(["handoff", "1"]).unwrap()]) + .sign_with_keys(&sender) + .unwrap(); + assert!(validate_agent_handoff_envelope(&event).is_err()); + } +} diff --git a/crates/buzz-core/src/filter.rs b/crates/buzz-core/src/filter.rs index 1671f76224..35b0d06467 100644 --- a/crates/buzz-core/src/filter.rs +++ b/crates/buzz-core/src/filter.rs @@ -12,8 +12,9 @@ pub fn filters_match(filters: &[Filter], event: &StoredEvent) -> bool { } /// Result-level read authorization for relay-signed events whose content is -/// private to a single viewer. Currently gates `KIND_DM_VISIBILITY` and -/// `KIND_AGENT_TURN_METRIC`: the reader MUST equal the event's `#p` tag +/// private to a single viewer. Currently gates `KIND_DM_VISIBILITY`, +/// `KIND_AGENT_TURN_METRIC`, and `KIND_AGENT_HANDOFF`: the reader MUST equal +/// the event's `#p` tag /// (owner). Returns `true` for every other kind. /// /// This guards every delivery surface — WS historical pull (`req.rs`), HTTP @@ -22,7 +23,10 @@ pub fn filters_match(filters: &[Filter], event: &StoredEvent) -> bool { /// a known event id) still cannot read another user's private event. pub fn reader_authorized_for_event(event: &nostr::Event, reader_pubkey_hex: &str) -> bool { let kind = crate::kind::event_kind_u32(event); - if kind != crate::kind::KIND_DM_VISIBILITY && kind != crate::kind::KIND_AGENT_TURN_METRIC { + if kind != crate::kind::KIND_DM_VISIBILITY + && kind != crate::kind::KIND_AGENT_TURN_METRIC + && kind != crate::kind::KIND_AGENT_HANDOFF + { return true; } let p = nostr::SingleLetterTag::lowercase(nostr::Alphabet::P); @@ -297,4 +301,28 @@ mod tests { "the authoring agent must NOT be authorized to read its own metric event (owner-only)" ); } + + #[test] + fn reader_authorized_for_event_gates_agent_handoff_by_recipient() { + let sender = Keys::generate(); + let recipient = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; + let unrelated = "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc"; + let handoff = EventBuilder::new( + Kind::Custom(crate::kind::KIND_AGENT_HANDOFF as u16), + "encrypted-payload", + ) + .tags([ + Tag::parse(["p", recipient]).unwrap(), + Tag::parse(["handoff", "1"]).unwrap(), + ]) + .sign_with_keys(&sender) + .expect("sign"); + + assert!(reader_authorized_for_event(&handoff, recipient)); + assert!(!reader_authorized_for_event(&handoff, unrelated)); + assert!(!reader_authorized_for_event( + &handoff, + &sender.public_key().to_hex() + )); + } } diff --git a/crates/buzz-core/src/kind.rs b/crates/buzz-core/src/kind.rs index 3c6f1d5913..0d3c081760 100644 --- a/crates/buzz-core/src/kind.rs +++ b/crates/buzz-core/src/kind.rs @@ -93,6 +93,12 @@ pub const KIND_AGENT_PROFILE: u32 = 10100; /// `docs/nips/NIP-AE.md` and [`crate::engram`]. pub const KIND_AGENT_ENGRAM: u32 = 30174; +/// NIP-AH: durable agent-to-agent handoff record. +/// +/// Sender-authored and NIP-44 encrypted to the single receiving agent named +/// by its `p` tag. Relay reads are recipient-gated, including event-id lookup. +pub const KIND_AGENT_HANDOFF: u32 = 44201; + /// NIP-ER: Event Reminder (parameterized replaceable, author-only). /// /// Encrypted, author-only reminder addressed by `(pubkey, kind, d_tag)`. The @@ -139,7 +145,11 @@ pub const AUTHOR_ONLY_KINDS: &[u32] = &[ /// /// Used by `filter_can_match_result_gated_kinds` to force the per-event /// fallback path in COUNT rather than the fast SQL `count_events()`. -pub const RESULT_GATED_KINDS: &[u32] = &[KIND_DM_VISIBILITY, KIND_AGENT_TURN_METRIC]; +pub const RESULT_GATED_KINDS: &[u32] = &[ + KIND_DM_VISIBILITY, + KIND_AGENT_TURN_METRIC, + KIND_AGENT_HANDOFF, +]; /// Kinds whose stored events have `#p`-bound read access — readable only by /// subscribers whose pubkey appears in the event's `#p` tag. @@ -166,6 +176,7 @@ pub const P_GATED_KINDS: &[u32] = &[ // readable by any unauthenticated or non-owner party, including via `ids` // filters — see NIP-AM §Relay Behavior. KIND_AGENT_TURN_METRIC, + KIND_AGENT_HANDOFF, ]; /// NIP-AP: Agent Persona (parameterized replaceable, owner-authored). @@ -651,6 +662,7 @@ pub const ALL_KINDS: &[u32] = &[ KIND_FILE_METADATA, KIND_AGENT_PROFILE, KIND_AGENT_ENGRAM, + KIND_AGENT_HANDOFF, KIND_EVENT_REMINDER, KIND_PERSONA, KIND_TEAM, diff --git a/crates/buzz-core/src/lib.rs b/crates/buzz-core/src/lib.rs index 7424915c83..c17857130e 100644 --- a/crates/buzz-core/src/lib.rs +++ b/crates/buzz-core/src/lib.rs @@ -5,6 +5,8 @@ //! Provides [`StoredEvent`], filter matching, kind constants, and event //! verification. All other Buzz crates depend on this one. +/// NIP-AH: encrypted agent-to-agent handoff records. +pub mod agent_handoff; /// NIP-AM: Agent Turn Metric — payload type and encrypt/decrypt helpers. pub mod agent_turn_metric; /// Channel and membership enums shared across crates. diff --git a/crates/buzz-db/src/lib.rs b/crates/buzz-db/src/lib.rs index 330525d310..1699e3cc99 100644 --- a/crates/buzz-db/src/lib.rs +++ b/crates/buzz-db/src/lib.rs @@ -1265,12 +1265,21 @@ impl Db { ) -> Result> { let row = sqlx::query( r#" - SELECT id, host - FROM communities - WHERE lower(host) = lower($1) - AND archived_at IS NULL - AND deleted_at IS NULL - AND deletion_state = 'active' + SELECT c.id, c.host + FROM communities c + WHERE lower(c.host) = lower($1) + AND c.archived_at IS NULL + AND c.deleted_at IS NULL + AND c.deletion_state = 'active' + UNION ALL + SELECT c.id, $1 AS host + FROM community_host_aliases a + JOIN communities c ON c.id = a.community_id + WHERE lower(a.host) = lower($1) + AND c.archived_at IS NULL + AND c.deleted_at IS NULL + AND c.deletion_state = 'active' + LIMIT 1 "#, ) .bind(normalized_host) @@ -1310,7 +1319,19 @@ impl Db { &self, normalized_host: &str, ) -> Result> { - let row = sqlx::query("SELECT id, host FROM communities WHERE lower(host) = lower($1)") + let row = sqlx::query( + r#" + SELECT c.id, c.host + FROM communities c + WHERE lower(c.host) = lower($1) + UNION ALL + SELECT c.id, $1 AS host + FROM community_host_aliases a + JOIN communities c ON c.id = a.community_id + WHERE lower(a.host) = lower($1) + LIMIT 1 + "#, + ) .bind(normalized_host) .fetch_optional(&self.pool) .await?; diff --git a/crates/buzz-db/src/migration.rs b/crates/buzz-db/src/migration.rs index be87faa1ac..9cb90df901 100644 --- a/crates/buzz-db/src/migration.rs +++ b/crates/buzz-db/src/migration.rs @@ -625,7 +625,7 @@ mod tests { let mut migrations: Vec<_> = MIGRATOR.iter().collect(); migrations.sort_by_key(|migration| migration.version); - assert_eq!(migrations.len(), 31); + assert_eq!(migrations.len(), 32); assert_eq!(migrations[0].version, 1); assert_eq!(&*migrations[0].description, "initial schema"); assert!(migrations[0] @@ -1044,6 +1044,8 @@ mod tests { migrations.sort_by_key(|migration| migration.version); assert_eq!(migrations[30].version, 31); + assert_eq!(migrations[31].version, 32); + assert!(migrations[31].sql.as_str().contains("44201")); let sql = migrations[30].sql.as_str(); assert!(sql.contains("ALTER TABLE workflow_runs ADD COLUMN error_code TEXT")); assert!(sql.contains("SET error_code = 'legacy_unclassified'")); diff --git a/crates/buzz-dev-mcp/src/download_attachment.rs b/crates/buzz-dev-mcp/src/download_attachment.rs new file mode 100644 index 0000000000..2bd872901a --- /dev/null +++ b/crates/buzz-dev-mcp/src/download_attachment.rs @@ -0,0 +1,160 @@ +//! Authenticated download support for non-image files attached to Buzz messages. + +use crate::shell::SharedState; +use crate::view_image::fetch_relay_attachment; +use rmcp::{model::CallToolResult, model::Content, ErrorData}; +use schemars::JsonSchema; +use serde::Deserialize; +use std::path::{Path, PathBuf}; + +const MAX_FILENAME_BYTES: usize = 180; + +#[derive(Debug, Deserialize, JsonSchema)] +pub struct DownloadAttachmentParams { + /// Buzz relay `/media/` URL from the message attachment link. + pub url: String, + /// Original attachment filename shown in the message. When omitted, the + /// final URL path segment is used. + #[serde(default)] + pub filename: Option, +} + +pub async fn run( + state: &SharedState, + p: DownloadAttachmentParams, +) -> Result { + let bytes = fetch_relay_attachment(p.url.trim()).await?; + let filename = attachment_filename(&p)?; + let directory = state.session_dir.path().join("attachments"); + std::fs::create_dir_all(&directory).map_err(|e| { + ErrorData::internal_error( + format!( + "cannot create attachment directory {}: {e}", + directory.display() + ), + None, + ) + })?; + let destination = unique_destination(&directory, &filename); + let mut temporary = tempfile::NamedTempFile::new_in(&directory).map_err(|e| { + ErrorData::internal_error( + format!("cannot create temporary attachment file: {e}"), + None, + ) + })?; + use std::io::Write; + temporary.write_all(&bytes).map_err(|e| { + ErrorData::internal_error(format!("cannot write downloaded attachment: {e}"), None) + })?; + temporary.persist(&destination).map_err(|e| { + ErrorData::internal_error( + format!( + "cannot persist downloaded attachment {}: {}", + destination.display(), + e.error + ), + None, + ) + })?; + + Ok(CallToolResult::success(vec![Content::text(format!( + "Downloaded Buzz attachment ({} bytes) to {}", + bytes.len(), + destination.display() + ))])) +} + +fn attachment_filename(p: &DownloadAttachmentParams) -> Result { + let candidate = p + .filename + .as_deref() + .filter(|value| !value.trim().is_empty()); + let from_url; + let raw = match candidate { + Some(value) => value, + None => { + let parsed = reqwest::Url::parse(p.url.trim()).map_err(|e| { + ErrorData::invalid_params(format!("invalid attachment URL: {} ({e})", p.url), None) + })?; + from_url = parsed + .path_segments() + .and_then(|mut segments| segments.next_back()) + .unwrap_or("attachment.bin") + .to_string(); + &from_url + } + }; + Ok(sanitize_filename(raw)) +} + +fn sanitize_filename(value: &str) -> String { + let basename = Path::new(value.trim()) + .file_name() + .and_then(|name| name.to_str()) + .unwrap_or("attachment.bin"); + let mut clean = String::with_capacity(basename.len().min(MAX_FILENAME_BYTES)); + for ch in basename.chars() { + let replacement = if ch.is_ascii_alphanumeric() || matches!(ch, '.' | '-' | '_') { + ch + } else { + '_' + }; + if clean.len() + replacement.len_utf8() > MAX_FILENAME_BYTES { + break; + } + clean.push(replacement); + } + let clean = clean.trim_matches('.'); + if clean.is_empty() { + "attachment.bin".to_string() + } else { + clean.to_string() + } +} + +fn unique_destination(directory: &Path, filename: &str) -> PathBuf { + let initial = directory.join(filename); + if !initial.exists() { + return initial; + } + let path = Path::new(filename); + let stem = path + .file_stem() + .and_then(|value| value.to_str()) + .unwrap_or("attachment"); + let extension = path.extension().and_then(|value| value.to_str()); + for suffix in 1..=9999 { + let candidate = match extension { + Some(extension) => directory.join(format!("{stem}-{suffix}.{extension}")), + None => directory.join(format!("{stem}-{suffix}")), + }; + if !candidate.exists() { + return candidate; + } + } + directory.join(format!("attachment-{}.bin", std::process::id())) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn filename_is_reduced_to_a_safe_basename() { + assert_eq!( + sanitize_filename(r"..\papers/cysteine mediated?.pdf"), + "cysteine_mediated_.pdf" + ); + assert_eq!(sanitize_filename("../.."), "attachment.bin"); + } + + #[test] + fn duplicate_names_get_a_stable_suffix() { + let directory = tempfile::tempdir().unwrap(); + std::fs::write(directory.path().join("paper.pdf"), b"one").unwrap(); + assert_eq!( + unique_destination(directory.path(), "paper.pdf"), + directory.path().join("paper-1.pdf") + ); + } +} diff --git a/crates/buzz-dev-mcp/src/lib.rs b/crates/buzz-dev-mcp/src/lib.rs index 9b98974802..712b7362da 100644 --- a/crates/buzz-dev-mcp/src/lib.rs +++ b/crates/buzz-dev-mcp/src/lib.rs @@ -10,6 +10,7 @@ use rmcp::{ use std::path::Path; use std::sync::Arc; +mod download_attachment; mod paths; mod read_file; mod rg; @@ -71,6 +72,17 @@ impl DevMcp { view_image::run(&self.state, p).await } + #[tool( + name = "download_attachment", + description = "Download a non-image file attached to a Buzz message into a temporary local file, using the managed agent's Buzz identity for relay media authorization. Pass the /media/ link and, when available, its displayed filename. Returns an absolute path readable by local PDF/document tools. Use this instead of shell/curl for Buzz attachment links. Only the configured Buzz relay is allowed; files are capped at 20 MiB and removed when this MCP session ends." + )] + async fn download_attachment( + &self, + Parameters(p): Parameters, + ) -> Result { + download_attachment::run(&self.state, p).await + } + #[tool( name = "str_replace", description = "Atomic find-and-replace in a file. old_str must occur exactly once unless replace_all is true, in which case all occurrences are replaced. Returns a unified diff. Path resolved relative to workdir (defaults to server cwd). Prefer over sed/awk." diff --git a/crates/buzz-dev-mcp/src/shell.rs b/crates/buzz-dev-mcp/src/shell.rs index 7aa95b1d87..34a7027e94 100644 --- a/crates/buzz-dev-mcp/src/shell.rs +++ b/crates/buzz-dev-mcp/src/shell.rs @@ -74,12 +74,14 @@ impl SharedState { fn build_bootstrap(cwd: &Path, shell_hint: &str) -> String { let stack = detect_stack(cwd); - let buzz_hint = - if std::env::var("BUZZ_RELAY_URL").is_ok() && std::env::var("BUZZ_PRIVATE_KEY").is_ok() { - "\nBuzz relay configured. Run `buzz --help` to see available commands.\n" - } else { - "" - }; + let buzz_hint = if std::env::var("BUZZ_RELAY_URL").is_ok() + && std::env::var("BUZZ_PRIVATE_KEY").is_ok() + { + "\nBuzz relay configured. Run `buzz --help` to see available commands. \ + For Buzz `/media/` attachment links, use the `download_attachment` tool instead of shell/curl.\n" + } else { + "" + }; format!( "Working directory: {}\n\ Detected stack: {}\n\ diff --git a/crates/buzz-dev-mcp/src/view_image.rs b/crates/buzz-dev-mcp/src/view_image.rs index 441338ab12..9f5125a0ed 100644 --- a/crates/buzz-dev-mcp/src/view_image.rs +++ b/crates/buzz-dev-mcp/src/view_image.rs @@ -363,7 +363,7 @@ async fn fetch_url(url: &str) -> Result, ErrorData> { if let Some(len) = resp.content_length() { if len as usize > MAX_SOURCE_BYTES { return Err(invalid_params(format!( - "remote image too large: Content-Length {} bytes (limit {})", + "remote media too large: Content-Length {} bytes (limit {})", len, MAX_SOURCE_BYTES ))); } @@ -379,7 +379,7 @@ async fn fetch_url(url: &str) -> Result, ErrorData> { Some(bytes) => { if buf.len() + bytes.len() > MAX_SOURCE_BYTES { return Err(invalid_params(format!( - "remote image exceeded {} byte cap mid-stream", + "remote media exceeded {} byte cap mid-stream", MAX_SOURCE_BYTES ))); } @@ -391,6 +391,28 @@ async fn fetch_url(url: &str) -> Result, ErrorData> { Ok(buf) } +/// Download a Buzz attachment with the same signed media-read flow used by +/// `view_image`. Unlike `view_image`'s general URL support, this entry point is +/// deliberately restricted to the configured relay's `/media/` namespace so +/// callers cannot turn the authenticated helper into an arbitrary downloader. +pub(crate) async fn fetch_relay_attachment(url: &str) -> Result, ErrorData> { + let parsed = reqwest::Url::parse(url) + .map_err(|e| invalid_params(format!("invalid attachment URL: {url} ({e})")))?; + let relay_value = std::env::var("BUZZ_RELAY_URL").map_err(|_| { + invalid_params( + "BUZZ_RELAY_URL is not configured; cannot authenticate Buzz attachment".to_string(), + ) + })?; + let relay = reqwest::Url::parse(&relay_value) + .map_err(|e| invalid_params(format!("BUZZ_RELAY_URL is invalid: {relay_value} ({e})")))?; + if !is_relay_media_url(&parsed, &relay) { + return Err(invalid_params( + "attachment URL must be under /media/ on the configured Buzz relay".to_string(), + )); + } + fetch_url(url).await +} + /// Sniff the image format from magic bytes alone (do not trust extensions /// or `Content-Type`). Returns the canonical MIME type. fn sniff_mime(bytes: &[u8]) -> Result<&'static str, String> { diff --git a/crates/buzz-media/src/upload.rs b/crates/buzz-media/src/upload.rs index 524b033280..b03932f71e 100644 --- a/crates/buzz-media/src/upload.rs +++ b/crates/buzz-media/src/upload.rs @@ -13,7 +13,7 @@ use crate::thumbnail::generate_image_metadata_sync; use crate::types::BlobDescriptor; use crate::upload_record::{record_upload_event, UploadAttribution, UploadEventFacts}; use crate::validation::{ - looks_like_mp4_iso_bmff, mime_to_ext, validate_content, validate_file_content, + looks_like_mp4_iso_bmff, mime_to_ext, validate_content, validate_file_content_with_filename, validate_video_file, }; @@ -248,6 +248,7 @@ pub async fn process_file_upload( ctx: &TenantContext, auth_event: &nostr::Event, body: Bytes, + filename: Option<&str>, attribution: Option, ) -> Result { process_buffered_upload( @@ -259,7 +260,10 @@ pub async fn process_file_upload( body, attribution, }, - |bytes, cfg| validate_file_content(bytes, cfg), + { + let filename = filename.map(str::to_owned); + move |bytes, cfg| validate_file_content_with_filename(bytes, cfg, filename.as_deref()) + }, |input| async move { // Minimal sidecar — no thumbnail/dim/blurhash/duration for generic files. let meta = BlobMeta { diff --git a/crates/buzz-media/src/validation.rs b/crates/buzz-media/src/validation.rs index dfc61c4275..824db96842 100644 --- a/crates/buzz-media/src/validation.rs +++ b/crates/buzz-media/src/validation.rs @@ -171,6 +171,21 @@ fn file_mime_to_ext(mime: &str) -> Option<&'static str> { pub fn validate_file_content( bytes: &[u8], config: &MediaConfig, +) -> Result<(String, String), MediaError> { + validate_file_content_with_filename(bytes, config, None) +} + +/// Validate a generic file and, when it has no detectable magic bytes, retain +/// the caller's safe filename extension for the content-addressed URL. +/// +/// The filename is presentation metadata only: MIME detection and the deny +/// list below remain authoritative. Only a short ASCII extension is accepted, +/// so a client cannot inject path separators or control characters into the +/// storage key. +pub fn validate_file_content_with_filename( + bytes: &[u8], + config: &MediaConfig, + filename: Option<&str>, ) -> Result<(String, String), MediaError> { // 1. Size cap. if bytes.len() as u64 > config.max_file_bytes { @@ -214,8 +229,24 @@ pub fn validate_file_content( .unwrap_or_else(|| kind.extension().to_string()); Ok((mime, ext)) } - None => Ok(("application/octet-stream".to_string(), "bin".to_string())), + None => Ok(( + "application/octet-stream".to_string(), + safe_filename_extension(filename) + .unwrap_or("bin") + .to_string(), + )), + } +} + +fn safe_filename_extension(filename: Option<&str>) -> Option<&str> { + let extension = filename?.rsplit(['/', '\\']).next()?.rsplit_once('.')?.1; + if extension.is_empty() || extension.len() > 16 { + return None; } + extension + .bytes() + .all(|byte| byte.is_ascii_alphanumeric()) + .then_some(extension) } /// Whether a stored blob should be served inline (rendered in the client) or as @@ -2597,6 +2628,24 @@ mod tests { assert_eq!(ext, "bin"); } + #[test] + fn test_validate_file_plaintext_preserves_safe_filename_extension() { + let config = test_config(); + let (mime, ext) = validate_file_content_with_filename( + b"# release notes\n", + &config, + Some("release-notes.md"), + ) + .unwrap(); + assert_eq!(mime, "application/octet-stream"); + assert_eq!(ext, "md"); + + let (_, fallback) = + validate_file_content_with_filename(b"data", &config, Some("../../payload.bad-ext!")) + .unwrap(); + assert_eq!(fallback, "bin"); + } + #[test] fn test_validate_file_html_accepted_as_inert_download() { // HTML is accepted on the generic file path as an inert attachment. diff --git a/crates/buzz-relay/src/api/bridge.rs b/crates/buzz-relay/src/api/bridge.rs index 0856c85cf3..c808bb8d4e 100644 --- a/crates/buzz-relay/src/api/bridge.rs +++ b/crates/buzz-relay/src/api/bridge.rs @@ -192,6 +192,7 @@ async fn check_nip98_replay_with_guard( /// pass and the relay would proceed against the wrong tenant's auth context), /// and (b) reject every legitimate request whose community host isn't the /// single configured one. Substituting `tenant.host()` closes both directions. +#[cfg(test)] pub(crate) fn nip98_expected_url( config_relay_url: &str, tenant: &TenantContext, @@ -205,6 +206,39 @@ pub(crate) fn nip98_expected_url( format!("{scheme}://{}{path}", tenant.host()) } +/// Construct the NIP-98 URL using the protocol seen by the client. +/// +/// A relay may be reachable directly over HTTP and through an HTTPS reverse +/// proxy at the same time. `RELAY_URL` supplies the fallback for direct/internal +/// callers; `X-Forwarded-Proto` lets a trusted proxy preserve the public scheme. +pub(crate) fn nip98_expected_url_for_headers( + config_relay_url: &str, + tenant: &TenantContext, + path: &str, + headers: &axum::http::HeaderMap, +) -> String { + let configured_host = crate::tenant::relay_url_authority(config_relay_url); + let scheme = headers + .get("x-forwarded-proto") + .and_then(|value| value.to_str().ok()) + .and_then(|value| value.split(',').next()) + .map(str::trim) + .filter(|value| matches!(*value, "http" | "https")) + .unwrap_or_else(|| { + // Direct LAN aliases do not carry proxy headers. Only the + // deployment's canonical public host inherits the configured TLS + // posture; an explicit alias is a direct HTTP/WS connection. + if tenant.host() == configured_host + && config_relay_url.trim_start().starts_with("wss://") + { + "https" + } else { + "http" + } + }); + format!("{scheme}://{}{path}", tenant.host()) +} + /// Construct the NIP-42 expected `relay` URL for a connection bound to `tenant`. /// /// NIP-42 (WebSocket AUTH) sibling of [`nip98_expected_url`]. Conformance row 44 @@ -228,6 +262,14 @@ pub(crate) fn nip42_expected_relay_url(config_relay_url: &str, tenant: &TenantCo } else { "ws" }; + nip42_expected_relay_url_for_scheme(scheme, tenant) +} + +/// Construct the NIP-42 URL using the protocol seen by the client. +pub(crate) fn nip42_expected_relay_url_for_scheme( + scheme: &str, + tenant: &TenantContext, +) -> String { format!("{scheme}://{}", tenant.host()) } @@ -636,7 +678,7 @@ pub async fn submit_event( ) })?; - let url = nip98_expected_url(&state.config.relay_url, &tenant, "/events"); + let url = nip98_expected_url_for_headers(&state.config.relay_url, &tenant, "/events", &headers); let (pubkey, event_id_bytes) = verify_bridge_auth( &headers, "POST", @@ -907,7 +949,7 @@ pub async fn query_events( ) })?; - let url = nip98_expected_url(&state.config.relay_url, &tenant, "/query"); + let url = nip98_expected_url_for_headers(&state.config.relay_url, &tenant, "/query", &headers); let (pubkey, event_id_bytes) = verify_bridge_auth( &headers, "POST", @@ -1350,7 +1392,7 @@ pub async fn count_events( ) })?; - let url = nip98_expected_url(&state.config.relay_url, &tenant, "/count"); + let url = nip98_expected_url_for_headers(&state.config.relay_url, &tenant, "/count", &headers); let (pubkey, event_id_bytes) = verify_bridge_auth( &headers, "POST", @@ -2092,7 +2134,12 @@ async fn authorize_moderation_read( Some(q) if !q.is_empty() => format!("{path}?{q}"), _ => path.to_string(), }; - let url = nip98_expected_url(&state.config.relay_url, &tenant, &path_with_query); + let url = nip98_expected_url_for_headers( + &state.config.relay_url, + &tenant, + &path_with_query, + headers, + ); let (pubkey, event_id_bytes) = verify_bridge_auth(headers, "GET", &url, None, state.config.require_auth_token)?; check_nip98_replay(state, &tenant, event_id_bytes).await?; diff --git a/crates/buzz-relay/src/api/invites.rs b/crates/buzz-relay/src/api/invites.rs index d09c7fc611..d7aa6f73f9 100644 --- a/crates/buzz-relay/src/api/invites.rs +++ b/crates/buzz-relay/src/api/invites.rs @@ -246,7 +246,12 @@ async fn authenticate( ) })?; - let url = bridge::nip98_expected_url(&state.config.relay_url, &tenant, path); + let url = bridge::nip98_expected_url_for_headers( + &state.config.relay_url, + &tenant, + path, + headers, + ); let (pubkey, event_id_bytes) = bridge::verify_bridge_auth_with_options( headers, "POST", diff --git a/crates/buzz-relay/src/api/media.rs b/crates/buzz-relay/src/api/media.rs index 3b6e07bad6..6fdb94b94c 100644 --- a/crates/buzz-relay/src/api/media.rs +++ b/crates/buzz-relay/src/api/media.rs @@ -322,6 +322,19 @@ pub async fn upload_blob( body: axum::body::Body, ) -> Result, MediaError> { let attribution = upload_attribution(&state, &auth, &headers).await; + // Optional client filename is transport metadata only. It is base64url + // encoded so Unicode names remain valid HTTP header values; the media + // validator accepts only a short safe extension and never trusts this for + // MIME or executable-content decisions. + let filename = headers + .get("x-buzz-filename") + .and_then(|value| value.to_str().ok()) + .and_then(|value| { + base64::engine::general_purpose::URL_SAFE_NO_PAD + .decode(value) + .ok() + }) + .and_then(|bytes| String::from_utf8(bytes).ok()); let serving_write = buzz_deletion::acquire_serving_write(&state.db, auth.tenant.community(), "media_upload") @@ -416,6 +429,7 @@ pub async fn upload_blob( &auth.tenant, &auth.auth_event, bytes, + filename.as_deref(), attribution, ) .await? diff --git a/crates/buzz-relay/src/api/workflows.rs b/crates/buzz-relay/src/api/workflows.rs index a3d5a6c729..4ed57d671c 100644 --- a/crates/buzz-relay/src/api/workflows.rs +++ b/crates/buzz-relay/src/api/workflows.rs @@ -61,7 +61,12 @@ async fn authorize_workflow_read( })?; let path_with_query = request_path(path, raw_query); - let url = bridge::nip98_expected_url(&state.config.relay_url, &tenant, &path_with_query); + let url = bridge::nip98_expected_url_for_headers( + &state.config.relay_url, + &tenant, + &path_with_query, + headers, + ); let (pubkey, event_id_bytes) = bridge::verify_bridge_auth(headers, "GET", &url, None, state.config.require_auth_token)?; bridge::enforce_http_admission(state, &tenant, &pubkey).await?; diff --git a/crates/buzz-relay/src/connection.rs b/crates/buzz-relay/src/connection.rs index c37421e7e8..d999311463 100644 --- a/crates/buzz-relay/src/connection.rs +++ b/crates/buzz-relay/src/connection.rs @@ -65,6 +65,8 @@ pub struct ConnectionState { /// host at row zero (before any frame is read) and never overridable by /// client-supplied input. Every handler reads tenant scope from here. pub tenant: TenantContext, + /// WebSocket scheme presented by the client (`ws` or `wss`). + pub relay_scheme: String, /// Remote socket address of the client. pub remote_addr: SocketAddr, /// Current NIP-42 authentication state. @@ -128,6 +130,7 @@ pub async fn handle_connection( state: Arc, addr: SocketAddr, tenant: TenantContext, + relay_scheme: String, ) { let conn_id = Uuid::new_v4(); let cancel = CancellationToken::new(); @@ -142,7 +145,17 @@ pub async fn handle_connection( community_id, control, move || async move { check_state.db.is_community_active(community_id).await }, - move |control| handle_active_connection(socket, run_state, addr, tenant, conn_id, control), + move |control| { + handle_active_connection( + socket, + run_state, + addr, + tenant, + relay_scheme, + conn_id, + control, + ) + }, ) .await; } @@ -152,6 +165,7 @@ async fn handle_active_connection( state: Arc, addr: SocketAddr, tenant: TenantContext, + relay_scheme: String, conn_id: Uuid, control: CommunityConnectionControl, ) { @@ -183,6 +197,7 @@ async fn handle_active_connection( let conn = Arc::new(ConnectionState { conn_id, tenant, + relay_scheme, remote_addr: addr, auth_state: RwLock::new(AuthState::Pending { challenge: challenge.clone(), diff --git a/crates/buzz-relay/src/handlers/auth.rs b/crates/buzz-relay/src/handlers/auth.rs index 127f1fc40e..39cc11de18 100644 --- a/crates/buzz-relay/src/handlers/auth.rs +++ b/crates/buzz-relay/src/handlers/auth.rs @@ -77,8 +77,10 @@ pub async fn handle_auth(event: nostr::Event, conn: Arc, state: // tampered, NIP-42 verification will fail before we ever inspect it. let auth_tag_json = extract_auth_tag_json(&event); - let relay_url = - crate::api::bridge::nip42_expected_relay_url(&state.config.relay_url, &conn.tenant); + let relay_url = crate::api::bridge::nip42_expected_relay_url_for_scheme( + &conn.relay_scheme, + &conn.tenant, + ); let auth_svc = Arc::clone(&state.auth); metrics::counter!("buzz_auth_attempts_total", "method" => "nip42").increment(1); diff --git a/crates/buzz-relay/src/handlers/event.rs b/crates/buzz-relay/src/handlers/event.rs index ccba40f328..bcd8105a25 100644 --- a/crates/buzz-relay/src/handlers/event.rs +++ b/crates/buzz-relay/src/handlers/event.rs @@ -43,7 +43,7 @@ pub(crate) fn bounded_kind_label(kind: u32) -> String { 41001 | 41010..=41012 => kind.to_string(), 43001..=43006 => kind.to_string(), 44100..=44101 => kind.to_string(), - 44200 => kind.to_string(), + 44200..=44201 => kind.to_string(), 45001..=45003 => kind.to_string(), 46001..=46012 | 46020 | 46030..=46031 => kind.to_string(), 48001 | 48100..=48103 | 48106 => kind.to_string(), @@ -947,8 +947,11 @@ fn observer_frame_rate_limited( /// Handle encrypted agent observer frames (kind 24200). /// /// These frames bypass storage and are routed as global ephemeral events. The -/// relay gates publication by the existing `agent_owner_pubkey` mapping and -/// gates subscription in the REQ handler via the cleartext `p` tag. +/// The relay verifies that the cleartext `agent` identity is a registered +/// managed agent, then gates subscription in the REQ handler via the `p` tag. +/// Recipient-level telemetry sharing and control authorization are enforced by +/// the agent harness after NIP-44 decryption; the relay cannot inspect either +/// encrypted payload without breaking end-to-end confidentiality. async fn handle_agent_observer_event( event: Event, conn_id: uuid::Uuid, @@ -1004,58 +1007,38 @@ async fn handle_agent_observer_event( } }; - // Fast path: if this connection authenticated via NIP-OA and the verified - // owner matches the observer frame's target owner, skip the DB lookup entirely. - let session_owner_match = { - let auth = conn.auth_state.read().await; - if let crate::connection::AuthState::Authenticated(ctx) = &*auth { - ctx.agent_owner_pubkey.as_ref() == Some(&route.owner) - } else { - false - } - }; - let agent_bytes = route.agent.to_bytes().to_vec(); - let owner_bytes = route.owner.to_bytes().to_vec(); - let cache_key = ( - conn.tenant.community(), - agent_bytes.clone(), - owner_bytes.clone(), - ); - let is_owner = if session_owner_match { - true + let cache_key = (conn.tenant.community(), agent_bytes.clone()); + let is_registered_agent = if let Some(cached) = state.author_type_cache.get(&cache_key) { + cached } else { - match state.observer_owner_cache.get(&cache_key) { - Some(cached) => cached, - None => { - let result = state - .db - .is_agent_owner(conn.tenant.community(), &agent_bytes, &owner_bytes) - .await; - match result { - Ok(v) => { - state.observer_owner_cache.insert(cache_key, v); - v - } - Err(e) => { - warn!(conn_id = %conn_id, event_id = %event_id_hex, "agent observer owner check failed: {e}"); - conn.send(RelayMessage::ok( - event_id_hex, - false, - "error: internal server error", - )); - return; - } - } + match state + .db + .get_agent_channel_policy(conn.tenant.community(), &agent_bytes) + .await + { + Ok(policy) => { + let is_agent = policy.is_some_and(|(_, owner)| owner.is_some()); + state.author_type_cache.insert(cache_key, is_agent); + is_agent + } + Err(e) => { + warn!(conn_id = %conn_id, event_id = %event_id_hex, "agent observer identity check failed: {e}"); + conn.send(RelayMessage::ok( + event_id_hex, + false, + "error: internal server error", + )); + return; } } }; - if !is_owner { + if !is_registered_agent { reject("auth"); conn.send(RelayMessage::ok( event_id_hex, false, - "restricted: observer frame is not authorized for this agent owner", + "restricted: observer frame agent is not registered", )); return; } @@ -1091,7 +1074,7 @@ async fn handle_agent_observer_event( debug!( event_id = %event_id_hex, agent = %route.agent.to_hex(), - owner = %route.owner.to_hex(), + recipient = %route.owner.to_hex(), direction = ?route.direction, "Agent observer fan-out" ); @@ -1165,8 +1148,6 @@ fn single_tag_content<'a>(event: &'a Event, tag_name: &str) -> Result<&'a str, S #[cfg(test)] mod tests { - use std::collections::HashMap; - use std::sync::atomic::AtomicU8; use std::sync::Arc; use buzz_core::kind::{ @@ -1178,8 +1159,6 @@ mod tests { OBSERVER_FRAME_TELEMETRY, }; use nostr::{EventBuilder, Keys, Kind, Tag}; - use tokio::sync::{mpsc, Mutex, RwLock}; - use tokio_util::sync::CancellationToken; use uuid::Uuid; #[test] @@ -1347,91 +1326,21 @@ mod tests { } #[tokio::test] - async fn observer_owner_cache_is_scoped_to_community() { + async fn observer_agent_identity_cache_is_scoped_to_community() { let state = fanout_access::test_state().await; - let agent = Keys::generate(); - let owner = Keys::generate(); - let agent_bytes = agent.public_key().to_bytes().to_vec(); - let owner_bytes = owner.public_key().to_bytes().to_vec(); + let agent_bytes = Keys::generate().public_key().to_bytes().to_vec(); let community_a = buzz_core::CommunityId::from_uuid(Uuid::new_v4()); let community_b = buzz_core::CommunityId::from_uuid(Uuid::new_v4()); - state.observer_owner_cache.insert( - (community_a, agent_bytes.clone(), owner_bytes.clone()), - true, - ); + state + .author_type_cache + .insert((community_a, agent_bytes.clone()), true); assert_eq!( - state.observer_owner_cache.get(&( - community_b, - agent_bytes.clone(), - owner_bytes.clone() - )), + state + .author_type_cache + .get(&(community_b, agent_bytes.clone())), None, - "A cached allow must not populate B's observer authorization key" - ); - state.observer_owner_cache.insert( - (community_b, agent_bytes.clone(), owner_bytes.clone()), - false, - ); - - let encrypted = encrypt_observer_payload( - &agent, - &owner.public_key(), - &serde_json::json!({"type": "acp_read"}), - ) - .expect("encrypt observer payload"); - let event = EventBuilder::new(Kind::Custom(KIND_AGENT_OBSERVER_FRAME as u16), encrypted) - .tags([ - Tag::parse(["p", &owner.public_key().to_hex()]).expect("p tag"), - Tag::parse([OBSERVER_AGENT_TAG, &agent.public_key().to_hex()]).expect("agent tag"), - Tag::parse([OBSERVER_FRAME_TAG, OBSERVER_FRAME_TELEMETRY]).expect("frame tag"), - ]) - .sign_with_keys(&agent) - .expect("sign event"); - - let (send_tx, mut send_rx) = mpsc::channel(1); - let (ctrl_tx, _ctrl_rx) = mpsc::channel(1); - let conn = Arc::new(crate::connection::ConnectionState { - conn_id: Uuid::new_v4(), - tenant: buzz_core::TenantContext::resolved(community_b, "b.example"), - remote_addr: "127.0.0.1:1234".parse().expect("socket addr"), - auth_state: RwLock::new(crate::connection::AuthState::Authenticated( - buzz_auth::AuthContext { - pubkey: agent.public_key(), - scopes: vec![], - channel_ids: None, - auth_method: buzz_auth::AuthMethod::Nip42, - agent_owner_pubkey: None, - }, - )), - subscriptions: Arc::new(Mutex::new(HashMap::new())), - send_tx, - ctrl_tx, - cancel: CancellationToken::new(), - backpressure_count: Arc::new(AtomicU8::new(0)), - grace_limit: 3, - }); - - super::handle_agent_observer_event( - event.clone(), - conn.conn_id, - &event.id.to_hex(), - conn, - state, - ) - .await; - - let axum::extract::ws::Message::Text(text) = - send_rx.try_recv().expect("observer rejection sent") - else { - panic!("expected text relay message"); - }; - let frame: serde_json::Value = serde_json::from_str(&text).expect("relay frame JSON"); - assert_eq!(frame[0], "OK"); - assert_eq!(frame[2], false); - assert_eq!( - frame[3], - "restricted: observer frame is not authorized for this agent owner" + "A cached agent identity must not authorize the same key in B" ); } diff --git a/crates/buzz-relay/src/handlers/ingest.rs b/crates/buzz-relay/src/handlers/ingest.rs index 5ba9650e91..433fa620cd 100644 --- a/crates/buzz-relay/src/handlers/ingest.rs +++ b/crates/buzz-relay/src/handlers/ingest.rs @@ -10,31 +10,32 @@ use tracing::{debug, error, info, warn}; use uuid::Uuid; use buzz_auth::Scope; +use buzz_core::agent_handoff::validate_agent_handoff_envelope; use buzz_core::kind::{ event_kind_u32, is_identity_archive_request_kind, is_parameterized_replaceable, - is_relay_admin_kind, KIND_AGENT_ENGRAM, KIND_AGENT_PROFILE, KIND_AGENT_TURN_METRIC, - KIND_APPROVAL_DENY, KIND_APPROVAL_GRANT, KIND_AUTH, KIND_BOOKMARK_LIST, KIND_BOOKMARK_SET, - KIND_CANVAS, KIND_CONTACT_LIST, KIND_DELETION, KIND_DM_ADD_MEMBER, KIND_DM_HIDE, KIND_DM_OPEN, - KIND_EMOJI_LIST, KIND_EMOJI_SET, KIND_EVENT_REMINDER, KIND_FOLLOW_SET, KIND_FORUM_COMMENT, - KIND_FORUM_POST, KIND_FORUM_VOTE, KIND_GIFT_WRAP, KIND_GIT_ISSUE, KIND_GIT_PATCH, - KIND_GIT_PR_UPDATE, KIND_GIT_PULL_REQUEST, KIND_GIT_REPO_ANNOUNCEMENT, KIND_GIT_REPO_STATE, - KIND_GIT_STATUS_CLOSED, KIND_GIT_STATUS_DRAFT, KIND_GIT_STATUS_MERGED, KIND_GIT_STATUS_OPEN, - KIND_HUDDLE_ENDED, KIND_HUDDLE_GUIDELINES, KIND_HUDDLE_PARTICIPANT_JOINED, - KIND_HUDDLE_PARTICIPANT_LEFT, KIND_HUDDLE_STARTED, KIND_IA_ARCHIVE_REQUEST, - KIND_IA_UNARCHIVE_REQUEST, KIND_LONG_FORM, KIND_MANAGED_AGENT, KIND_MEMBER_ADDED_NOTIFICATION, - KIND_MEMBER_REMOVED_NOTIFICATION, KIND_MODERATION_BAN, KIND_MODERATION_RESOLVE_REPORT, - KIND_MODERATION_TIMEOUT, KIND_MODERATION_UNBAN, KIND_MODERATION_UNTIMEOUT, KIND_MUTE_LIST, - KIND_NIP29_CREATE_GROUP, KIND_NIP29_DELETE_EVENT, KIND_NIP29_DELETE_GROUP, - KIND_NIP29_EDIT_METADATA, KIND_NIP29_JOIN_REQUEST, KIND_NIP29_LEAVE_REQUEST, - KIND_NIP29_PUT_USER, KIND_NIP29_REMOVE_USER, KIND_NIP43_LEAVE_REQUEST, - KIND_NIP65_RELAY_LIST_METADATA, KIND_PERSONA, KIND_PIN_LIST, KIND_PRESENCE_UPDATE, - KIND_PRIVATE_MANAGED_AGENT, KIND_PRODUCT_FEEDBACK, KIND_PROFILE, KIND_PROJECT, KIND_REACTION, - KIND_READ_STATE, KIND_REPORT, KIND_STREAM_MESSAGE, KIND_STREAM_MESSAGE_BOOKMARKED, - KIND_STREAM_MESSAGE_DIFF, KIND_STREAM_MESSAGE_EDIT, KIND_STREAM_MESSAGE_PINNED, - KIND_STREAM_MESSAGE_SCHEDULED, KIND_STREAM_MESSAGE_V2, KIND_STREAM_REMINDER, KIND_TEAM, - KIND_TEAM_CATALOG, KIND_TEXT_NOTE, KIND_USER_STATUS, KIND_WORKFLOW_DEF, KIND_WORKFLOW_TRIGGER, - RELAY_ADMIN_ADD_MEMBER, RELAY_ADMIN_CHANGE_ROLE, RELAY_ADMIN_REMOVE_MEMBER, - RELAY_ADMIN_SET_WORKSPACE_PROFILE, + is_relay_admin_kind, KIND_AGENT_ENGRAM, KIND_AGENT_HANDOFF, KIND_AGENT_PROFILE, + KIND_AGENT_TURN_METRIC, KIND_APPROVAL_DENY, KIND_APPROVAL_GRANT, KIND_AUTH, KIND_BOOKMARK_LIST, + KIND_BOOKMARK_SET, KIND_CANVAS, KIND_CONTACT_LIST, KIND_DELETION, KIND_DM_ADD_MEMBER, + KIND_DM_HIDE, KIND_DM_OPEN, KIND_EMOJI_LIST, KIND_EMOJI_SET, KIND_EVENT_REMINDER, + KIND_FOLLOW_SET, KIND_FORUM_COMMENT, KIND_FORUM_POST, KIND_FORUM_VOTE, KIND_GIFT_WRAP, + KIND_GIT_ISSUE, KIND_GIT_PATCH, KIND_GIT_PR_UPDATE, KIND_GIT_PULL_REQUEST, + KIND_GIT_REPO_ANNOUNCEMENT, KIND_GIT_REPO_STATE, KIND_GIT_STATUS_CLOSED, KIND_GIT_STATUS_DRAFT, + KIND_GIT_STATUS_MERGED, KIND_GIT_STATUS_OPEN, KIND_HUDDLE_ENDED, KIND_HUDDLE_GUIDELINES, + KIND_HUDDLE_PARTICIPANT_JOINED, KIND_HUDDLE_PARTICIPANT_LEFT, KIND_HUDDLE_STARTED, + KIND_IA_ARCHIVE_REQUEST, KIND_IA_UNARCHIVE_REQUEST, KIND_LONG_FORM, KIND_MANAGED_AGENT, + KIND_MEMBER_ADDED_NOTIFICATION, KIND_MEMBER_REMOVED_NOTIFICATION, KIND_MODERATION_BAN, + KIND_MODERATION_RESOLVE_REPORT, KIND_MODERATION_TIMEOUT, KIND_MODERATION_UNBAN, + KIND_MODERATION_UNTIMEOUT, KIND_MUTE_LIST, KIND_NIP29_CREATE_GROUP, KIND_NIP29_DELETE_EVENT, + KIND_NIP29_DELETE_GROUP, KIND_NIP29_EDIT_METADATA, KIND_NIP29_JOIN_REQUEST, + KIND_NIP29_LEAVE_REQUEST, KIND_NIP29_PUT_USER, KIND_NIP29_REMOVE_USER, + KIND_NIP43_LEAVE_REQUEST, KIND_NIP65_RELAY_LIST_METADATA, KIND_PERSONA, KIND_PIN_LIST, + KIND_PRESENCE_UPDATE, KIND_PRIVATE_MANAGED_AGENT, KIND_PRODUCT_FEEDBACK, KIND_PROFILE, + KIND_PROJECT, KIND_REACTION, KIND_READ_STATE, KIND_REPORT, KIND_STREAM_MESSAGE, + KIND_STREAM_MESSAGE_BOOKMARKED, KIND_STREAM_MESSAGE_DIFF, KIND_STREAM_MESSAGE_EDIT, + KIND_STREAM_MESSAGE_PINNED, KIND_STREAM_MESSAGE_SCHEDULED, KIND_STREAM_MESSAGE_V2, + KIND_STREAM_REMINDER, KIND_TEAM, KIND_TEAM_CATALOG, KIND_TEXT_NOTE, KIND_USER_STATUS, + KIND_WORKFLOW_DEF, KIND_WORKFLOW_TRIGGER, RELAY_ADMIN_ADD_MEMBER, RELAY_ADMIN_CHANGE_ROLE, + RELAY_ADMIN_REMOVE_MEMBER, RELAY_ADMIN_SET_WORKSPACE_PROFILE, }; use buzz_core::tenant::TenantContext; use buzz_core::verification::verify_event; @@ -352,7 +353,7 @@ fn required_scope_for_kind(kind: u32, event: &Event) -> Result Ok(Scope::MessagesWrite), + KIND_AGENT_TURN_METRIC | KIND_AGENT_HANDOFF => Ok(Scope::MessagesWrite), // NIP-56 reports are ordinary member writes into the mod-only queue. // Ingest persists them to `moderation_reports` and suppresses public // storage/fanout; reports are signals, never enforcement triggers. @@ -603,6 +604,7 @@ pub(crate) fn is_global_only_kind(kind: u32) -> bool { // NIP-AM: agent turn metrics are owner-scoped global events. // Channel identity is encrypted inside the payload — no `h` tag. | KIND_AGENT_TURN_METRIC + | KIND_AGENT_HANDOFF // NIP-PL leases are author-owned, addressable global state. | super::push_lease::KIND_PUSH_LEASE ) @@ -2509,6 +2511,11 @@ async fn ingest_event_inner( .map_err(|e| IngestError::Rejected(format!("invalid: {e}")))?; } + if kind_u32 == KIND_AGENT_HANDOFF { + validate_agent_handoff_envelope(&event) + .map_err(|e| IngestError::Rejected(format!("invalid: {e}")))?; + } + if kind_u32 == KIND_AGENT_TURN_METRIC { validate_agent_turn_metric_envelope(&event) .map_err(|e| IngestError::Rejected(format!("invalid: {e}")))?; diff --git a/crates/buzz-relay/src/handlers/req.rs b/crates/buzz-relay/src/handlers/req.rs index fd7deadf51..6490815d00 100644 --- a/crates/buzz-relay/src/handlers/req.rs +++ b/crates/buzz-relay/src/handlers/req.rs @@ -7,8 +7,9 @@ use tracing::{debug, warn}; use buzz_core::filter::filters_match; use buzz_core::kind::{ - is_unshared_gated_event, AUTHOR_ONLY_KINDS, KIND_AGENT_ENGRAM, KIND_AGENT_TURN_METRIC, - KIND_DM_VISIBILITY, P_GATED_KINDS, RESULT_GATED_KINDS, SHARED_GATED_KINDS, + is_unshared_gated_event, AUTHOR_ONLY_KINDS, KIND_AGENT_ENGRAM, KIND_AGENT_HANDOFF, + KIND_AGENT_TURN_METRIC, KIND_DM_VISIBILITY, P_GATED_KINDS, RESULT_GATED_KINDS, + SHARED_GATED_KINDS, }; use buzz_core::tenant::TenantContext; use buzz_db::EventQuery; @@ -1078,7 +1079,7 @@ pub(crate) fn p_gated_filters_authorized(filters: &[Filter], authed_pubkey_hex: let explicitly_no_ids_exemption = filter.kinds.as_ref().is_some_and(|ks| { ks.iter().any(|kind| { let k = kind.as_u16() as u32; - k == KIND_DM_VISIBILITY || k == KIND_AGENT_TURN_METRIC + k == KIND_DM_VISIBILITY || k == KIND_AGENT_TURN_METRIC || k == KIND_AGENT_HANDOFF }) }); if !explicitly_no_ids_exemption && filter.ids.as_ref().is_some_and(|ids| !ids.is_empty()) { @@ -1687,6 +1688,32 @@ mod tests { ); } + #[test] + fn agent_handoff_requires_recipient_p_tag_even_with_ids() { + let p_tag = SingleLetterTag::lowercase(Alphabet::P); + let recipient = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; + let other = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"; + let event_id = "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc"; + let kind = nostr::Kind::Custom(buzz_core::kind::KIND_AGENT_HANDOFF as u16); + + let ids_only = Filter::new() + .kind(kind) + .id(nostr::EventId::from_hex(event_id).unwrap()); + assert!(!p_gated_filters_authorized(&[ids_only], recipient)); + + let wrong_recipient = Filter::new() + .kind(kind) + .id(nostr::EventId::from_hex(event_id).unwrap()) + .custom_tags(p_tag, [other]); + assert!(!p_gated_filters_authorized(&[wrong_recipient], recipient)); + + let addressed = Filter::new() + .kind(kind) + .id(nostr::EventId::from_hex(event_id).unwrap()) + .custom_tags(p_tag, [recipient]); + assert!(p_gated_filters_authorized(&[addressed], recipient)); + } + #[test] fn test_mixed_search_and_non_search_detection() { let search_filter = Filter::new().search("hello"); diff --git a/crates/buzz-relay/src/router.rs b/crates/buzz-relay/src/router.rs index 1dce66e91e..7ae5265807 100644 --- a/crates/buzz-relay/src/router.rs +++ b/crates/buzz-relay/src/router.rs @@ -332,7 +332,18 @@ async fn nip11_or_ws_handler( return (StatusCode::SERVICE_UNAVAILABLE, "relay restarting").into_response(); } limit_relay_websocket(ws, max_frame_bytes) - .on_upgrade(move |socket| handle_connection(socket, state, addr, tenant)) + .on_upgrade(move |socket| { + let relay_scheme = headers + .get("x-forwarded-proto") + .and_then(|value| value.to_str().ok()) + .and_then(|value| value.split(',').next()) + .map(str::trim) + .filter(|value| matches!(*value, "http" | "https")) + .map(|value| if value == "https" { "wss" } else { "ws" }) + .unwrap_or("ws") + .to_string(); + handle_connection(socket, state, addr, tenant, relay_scheme) + }) .into_response() } Err(_) => { diff --git a/crates/buzz-relay/src/state.rs b/crates/buzz-relay/src/state.rs index 2f544e188c..0140989ab2 100644 --- a/crates/buzz-relay/src/state.rs +++ b/crates/buzz-relay/src/state.rs @@ -1499,6 +1499,7 @@ mod tests { buzz_core::tenant::CommunityId::from_uuid(Uuid::nil()), "test.local".to_string(), ), + relay_scheme: "ws".to_string(), remote_addr: "127.0.0.1:1234".parse().unwrap(), auth_state: RwLock::new(AuthState::Failed), subscriptions: Arc::new(Mutex::new(HashMap::new())), diff --git a/desktop/package.json b/desktop/package.json index b6581d4805..ca8e4043fa 100644 --- a/desktop/package.json +++ b/desktop/package.json @@ -66,6 +66,7 @@ "embla-carousel-react": "^8.6.0", "emoji-mart": "^5.6.0", "jdenticon": "^3.3.0", + "katex": "^0.18.4", "lucide-react": "^1.0.0", "motion": "^12.38.0", "qrcode": "^1.5.4", @@ -74,8 +75,10 @@ "react-diff-view": "^3.3.2", "react-dom": "^19.1.0", "react-markdown": "^10.1.0", + "rehype-katex": "^7.0.1", "remark-breaks": "^4.0.0", "remark-gfm": "^4.0.1", + "remark-math": "^6.0.0", "shiki": "^4.0.2", "sonner": "^2.0.7", "tailwind-merge": "^3.5.0", diff --git a/desktop/playwright.config.ts b/desktop/playwright.config.ts index e930f0ef61..9655e6dbcf 100644 --- a/desktop/playwright.config.ts +++ b/desktop/playwright.config.ts @@ -59,6 +59,7 @@ export default defineConfig({ "**/pubkey-display-screenshots.spec.ts", "**/file-attachment.spec.ts", "**/image-attachment-gallery.spec.ts", + "**/media-proxy-readiness.spec.ts", "**/composer-image-draw.spec.ts", "**/video-attachment.spec.ts", "**/spoiler.spec.ts", diff --git a/desktop/src-tauri/Cargo.lock b/desktop/src-tauri/Cargo.lock index 7fa6c4cb7e..6c2684afa8 100644 --- a/desktop/src-tauri/Cargo.lock +++ b/desktop/src-tauri/Cargo.lock @@ -1105,6 +1105,7 @@ dependencies = [ "earshot", "ed25519-dalek", "flate2", + "fs2", "futures-util", "getrandom 0.2.17", "hex", @@ -3029,6 +3030,16 @@ dependencies = [ "percent-encoding", ] +[[package]] +name = "fs2" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9564fc758e15025b46aa6643b1b77d047d1a56a1aea6e01002ac0c7026876213" +dependencies = [ + "libc", + "winapi", +] + [[package]] name = "fs_extra" version = "1.3.0" diff --git a/desktop/src-tauri/Cargo.toml b/desktop/src-tauri/Cargo.toml index 5467645873..d53827e051 100644 --- a/desktop/src-tauri/Cargo.toml +++ b/desktop/src-tauri/Cargo.toml @@ -144,6 +144,7 @@ earshot = "1.0" rubato = "3.0" audioadapter-buffers = "3.0" tempfile = "3" +fs2 = "0.4" strip-ansi-escapes = "0.2" tracing = "0.1" diff --git a/desktop/src-tauri/installer-hooks.nsh b/desktop/src-tauri/installer-hooks.nsh new file mode 100644 index 0000000000..4920cf9798 --- /dev/null +++ b/desktop/src-tauri/installer-hooks.nsh @@ -0,0 +1,22 @@ +!macro BUZZ_STOP_PROCESS image_name + nsExec::ExecToLog '"$SYSDIR\taskkill.exe" /F /T /IM "${image_name}"' + Pop $0 +!macroend + +!macro BUZZ_STOP_RUNNING_PROCESSES + !insertmacro BUZZ_STOP_PROCESS "buzz-desktop.exe" + !insertmacro BUZZ_STOP_PROCESS "buzz-acp.exe" + !insertmacro BUZZ_STOP_PROCESS "buzz-agent.exe" + !insertmacro BUZZ_STOP_PROCESS "buzz-dev-mcp.exe" + !insertmacro BUZZ_STOP_PROCESS "buzz.exe" + !insertmacro BUZZ_STOP_PROCESS "git-credential-nostr.exe" + Sleep 500 +!macroend + +!macro NSIS_HOOK_PREINSTALL + !insertmacro BUZZ_STOP_RUNNING_PROCESSES +!macroend + +!macro NSIS_HOOK_PREUNINSTALL + !insertmacro BUZZ_STOP_RUNNING_PROCESSES +!macroend diff --git a/desktop/src-tauri/src/app_state.rs b/desktop/src-tauri/src/app_state.rs index fc90e6ab14..6732508021 100644 --- a/desktop/src-tauri/src/app_state.rs +++ b/desktop/src-tauri/src/app_state.rs @@ -23,6 +23,10 @@ pub struct AppState { /// recovery flags are cleared so `get_identity` reports a consistent state. pub(crate) identity_storage: AtomicU8, pub http_client: reqwest::Client, + /// Direct client for loopback/private relay HTTP endpoints. System proxies + /// commonly cannot route LAN relay addresses even when WebSocket traffic + /// reaches them directly. + pub direct_relay_http_client: reqwest::Client, /// A no-redirect client for authenticated relay media fetches (download, /// clipboard copy, snapshot, editor). Every caller pre-validates the URL /// origin, but the app-wide `http_client` follows redirects by default, so @@ -35,6 +39,10 @@ pub struct AppState { /// Workspace-provided relay URL override. Set by `apply_workspace` on app /// init and takes priority over env vars and compile-time defaults. pub relay_url_override: Mutex>, + /// Optional private-network WebSocket transport for the active community. + /// The canonical relay URL above remains authoritative for Host binding, + /// authentication, HTTP APIs, media, and deep links. + pub relay_lan_url_override: Mutex>, /// Set during backend setup when managed agents are eligible for launch /// restore. `apply_workspace` consumes it after installing the workspace /// relay and identity, so agents never start against the fallback relay. @@ -201,12 +209,20 @@ pub fn build_app_state() -> AppState { .pool_max_idle_per_host(1) .build() .unwrap_or_else(|_| reqwest::Client::new()), + direct_relay_http_client: reqwest::Client::builder() + .no_proxy() + .resolve("localhost", std::net::SocketAddr::from(([127, 0, 0, 1], 0))) + .pool_idle_timeout(std::time::Duration::from_secs(10)) + .pool_max_idle_per_host(1) + .build() + .expect("direct relay HTTP client must build"), media_fetch_client: build_media_fetch_client().expect( "media_fetch_client must build with redirect::Policy::none(); a \ redirect-following fallback would forward the minted media auth \ header across origins (redirect-hop SSRF)", ), relay_url_override: Mutex::new(None), + relay_lan_url_override: Mutex::new(None), managed_agent_restore_pending: AtomicBool::new(false), managed_agent_profile_reconcile_enabled: AtomicBool::new(true), shutdown_started: AtomicBool::new(false), diff --git a/desktop/src-tauri/src/commands/agent_discovery.rs b/desktop/src-tauri/src/commands/agent_discovery.rs index 9609db5f2d..c2c0966486 100644 --- a/desktop/src-tauri/src/commands/agent_discovery.rs +++ b/desktop/src-tauri/src/commands/agent_discovery.rs @@ -7,11 +7,10 @@ use crate::{ DiscoverManagedAgentPrereqsRequest, InstallRuntimeResult, ManagedAgentPrereqsInfo, RelayAgentInfo, DEFAULT_ACP_COMMAND, }, - nostr_convert, - relay::query_relay, }; mod post_install_verification; +mod relay_agent_directory; fn active_installs() -> &'static std::sync::Mutex> { use std::collections::HashSet; @@ -333,35 +332,63 @@ fn install_acp_runtime_blocking( ) { let use_managed_npm = cmds.iter().any(|cmd| is_npm_global_install(cmd)) && managed_node_runtime_supported(); - if use_managed_npm { + let bundled_adapter_version = if use_managed_npm && runtime_id == "codex" { + match install_bundled_codex_acp(app) { + Ok(version) => version, + Err(error) => { + eprintln!( + "buzz-desktop: bundled Codex ACP install failed; falling back to npm: {error}" + ); + None + } + } + } else { + None + }; + if let Some(version) = bundled_adapter_version.as_deref() { + reporter.record_step( + &mut steps, + crate::managed_agents::InstallStepResult { + step: "adapter".to_string(), + command: format!("bundled @agentclientprotocol/codex-acp@{version}"), + success: true, + stdout: "Installed from the Buzz Codex Lab offline bundle.".to_string(), + stderr: String::new(), + exit_code: Some(0), + hint: None, + }, + ); + } else if use_managed_npm { if let Err(step) = ensure_managed_node_runtime_blocking() { reporter.record_step(&mut steps, *step); return Ok(reporter.failed(steps)); } } - for cmd in cmds { - let planned = match if use_managed_npm { - managed_npm_command(cmd) - } else { - Ok(None) - } { - Ok(Some(command)) => command, - Ok(None) => cmd.to_string(), - Err(step) => { - reporter.record_step(&mut steps, *step); + if bundled_adapter_version.is_none() { + for cmd in cmds { + let planned = match if use_managed_npm { + managed_npm_command(cmd) + } else { + Ok(None) + } { + Ok(Some(command)) => command, + Ok(None) => cmd.to_string(), + Err(step) => { + reporter.record_step(&mut steps, *step); + return Ok(reporter.failed(steps)); + } + }; + + let mut result = run_install_command_with_retry("adapter", &planned, &reporter); + if !result.success && result.hint.is_none() && is_npm_global_install(cmd) { + result.hint = npm_eacces_hint(&result.stderr, cmd); + } + let success = result.success; + steps.push(result); + if !success { return Ok(reporter.failed(steps)); } - }; - - let mut result = run_install_command_with_retry("adapter", &planned, &reporter); - if !result.success && result.hint.is_none() && is_npm_global_install(cmd) { - result.hint = npm_eacces_hint(&result.stderr, cmd); - } - let success = result.success; - steps.push(result); - if !success { - return Ok(reporter.failed(steps)); } } } @@ -1006,8 +1033,8 @@ use install_report::InstallReporter; // ── managed Node/npm runtime ────────────────────────────────────────────────── mod managed_node; use managed_node::{ - ensure_managed_node_runtime_blocking, managed_node_runtime_supported, managed_npm_command, - npm_eacces_hint, resolve_adapter_path, + ensure_managed_node_runtime_blocking, install_bundled_codex_acp, + managed_node_runtime_supported, managed_npm_command, npm_eacces_hint, resolve_adapter_path, }; #[tauri::command] @@ -1039,23 +1066,7 @@ pub async fn discover_managed_agent_prereqs( #[tauri::command] pub async fn list_relay_agents(state: State<'_, AppState>) -> Result, String> { - // Query kind:10100 agent profile events from the relay. - let events = query_relay( - &state, - &[serde_json::json!({ - "kinds": [10100], - })], - ) - .await?; - - // The convert helper returns `{"agents": [...]}`. Extract and re-deserialize - // into the strongly-typed `Vec` the frontend expects. - let value = nostr_convert::agents_from_events(&events); - let agents = value - .get("agents") - .cloned() - .unwrap_or_else(|| serde_json::json!([])); - serde_json::from_value(agents).map_err(|e| format!("agent parse failed: {e}")) + relay_agent_directory::list_relay_agents(&state).await } #[cfg(test)] diff --git a/desktop/src-tauri/src/commands/agent_discovery/managed_node.rs b/desktop/src-tauri/src/commands/agent_discovery/managed_node.rs index fbfb068c0e..0f059f3032 100644 --- a/desktop/src-tauri/src/commands/agent_discovery/managed_node.rs +++ b/desktop/src-tauri/src/commands/agent_discovery/managed_node.rs @@ -7,6 +7,22 @@ use crate::managed_agents::{is_npm_global_install, InstallStepResult}; const MANAGED_NODE_VERSION: &str = "v24.18.0"; const MANAGED_NODE_MAX_BYTES: u64 = 90 * 1024 * 1024; +const BUNDLED_CODEX_ACP_MAX_BYTES: u64 = 700 * 1024 * 1024; + +#[cfg(all(target_os = "windows", target_arch = "x86_64"))] +const BUNDLED_CODEX_ACP_ARCHIVE: &str = "codex-acp/codex-acp-win-x64.zip"; +#[cfg(all(target_os = "windows", target_arch = "x86_64"))] +const BUNDLED_CODEX_ACP_MANIFEST: &str = "codex-acp/manifest-win-x64.json"; + +#[derive(Debug, serde::Deserialize)] +struct BundledCodexAcpManifest { + schema_version: u32, + platform: String, + node_version: String, + adapter_package: String, + adapter_version: String, + archive_sha256: String, +} #[derive(Debug, Clone, Copy)] struct ManagedNodeArtifact { @@ -262,6 +278,184 @@ pub(super) fn managed_node_runtime_supported() -> bool { MANAGED_NODE_ARTIFACT.is_some() && crate::managed_agents::buzz_managed_node_bin_dir().is_some() } +fn validate_bundled_codex_acp_manifest(manifest: &BundledCodexAcpManifest) -> Result<(), String> { + if manifest.schema_version != 1 { + return Err(format!( + "unsupported bundled Codex ACP manifest schema {}", + manifest.schema_version + )); + } + if manifest.platform != "win-x64" + || manifest.node_version != MANAGED_NODE_VERSION + || manifest.adapter_package != "@agentclientprotocol/codex-acp" + || manifest.adapter_version.trim().is_empty() + { + return Err("bundled Codex ACP manifest does not match this runtime".to_string()); + } + if manifest.archive_sha256.len() != 64 + || !manifest + .archive_sha256 + .bytes() + .all(|byte| byte.is_ascii_hexdigit()) + { + return Err("bundled Codex ACP manifest has an invalid archive hash".to_string()); + } + Ok(()) +} + +fn sha256_file(path: &std::path::Path, max_bytes: u64) -> Result { + let metadata = std::fs::metadata(path) + .map_err(|error| format!("read bundled Codex ACP archive metadata: {error}"))?; + if metadata.len() > max_bytes { + return Err(format!( + "bundled Codex ACP archive is too large: {} bytes", + metadata.len() + )); + } + + let mut file = std::fs::File::open(path) + .map_err(|error| format!("open bundled Codex ACP archive: {error}"))?; + let mut hasher = Sha256::new(); + let mut buffer = [0_u8; 64 * 1024]; + loop { + let read = file + .read(&mut buffer) + .map_err(|error| format!("read bundled Codex ACP archive: {error}"))?; + if read == 0 { + break; + } + hasher.update(&buffer[..read]); + } + Ok(hex::encode(hasher.finalize())) +} + +fn copy_tree(source: &std::path::Path, destination: &std::path::Path) -> Result<(), String> { + std::fs::create_dir_all(destination) + .map_err(|error| format!("create '{}': {error}", destination.display()))?; + for entry in std::fs::read_dir(source) + .map_err(|error| format!("read '{}': {error}", source.display()))? + { + let entry = entry.map_err(|error| format!("read bundle entry: {error}"))?; + let file_type = entry + .file_type() + .map_err(|error| format!("inspect '{}': {error}", entry.path().display()))?; + let target = destination.join(entry.file_name()); + if file_type.is_dir() { + copy_tree(&entry.path(), &target)?; + } else if file_type.is_file() { + std::fs::copy(entry.path(), &target) + .map_err(|error| format!("copy '{}': {error}", target.display()))?; + } else { + return Err(format!( + "bundled Codex ACP contains unsupported entry '{}'", + entry.path().display() + )); + } + } + Ok(()) +} + +#[cfg(all(target_os = "windows", target_arch = "x86_64"))] +pub(super) fn install_bundled_codex_acp(app: &tauri::AppHandle) -> Result, String> { + use tauri::Manager as _; + + let resource_dir = app + .path() + .resource_dir() + .map_err(|error| format!("resolve Buzz resource directory: {error}"))?; + let archive_path = resource_dir.join(BUNDLED_CODEX_ACP_ARCHIVE); + let manifest_path = resource_dir.join(BUNDLED_CODEX_ACP_MANIFEST); + if !archive_path.is_file() || !manifest_path.is_file() { + return Ok(None); + } + + let manifest: BundledCodexAcpManifest = serde_json::from_slice( + &std::fs::read(&manifest_path) + .map_err(|error| format!("read bundled Codex ACP manifest: {error}"))?, + ) + .map_err(|error| format!("parse bundled Codex ACP manifest: {error}"))?; + validate_bundled_codex_acp_manifest(&manifest)?; + let actual_hash = sha256_file(&archive_path, BUNDLED_CODEX_ACP_MAX_BYTES)?; + if !actual_hash.eq_ignore_ascii_case(&manifest.archive_sha256) { + return Err(format!( + "bundled Codex ACP hash mismatch: expected {}, got {actual_hash}", + manifest.archive_sha256 + )); + } + + let node_root = crate::managed_agents::buzz_managed_node_root() + .ok_or_else(|| "resolve Buzz private Node.js directory".to_string())?; + let node_destination = crate::managed_agents::buzz_managed_node_bin_dir() + .ok_or_else(|| "resolve Buzz private Node.js platform directory".to_string())?; + let npm_prefix = crate::managed_agents::buzz_managed_npm_prefix() + .ok_or_else(|| "resolve Buzz private Node tools directory".to_string())?; + let app_data_root = node_root + .parent() + .and_then(std::path::Path::parent) + .ok_or_else(|| "resolve Buzz app-data root".to_string())?; + let staging = app_data_root.join("codex-acp-offline.tmp"); + if staging.exists() { + std::fs::remove_dir_all(&staging) + .map_err(|error| format!("remove stale Codex ACP staging directory: {error}"))?; + } + std::fs::create_dir_all(&staging) + .map_err(|error| format!("create Codex ACP staging directory: {error}"))?; + + let install_result = (|| { + let file = std::fs::File::open(&archive_path) + .map_err(|error| format!("open bundled Codex ACP zip: {error}"))?; + let mut archive = zip::ZipArchive::new(file) + .map_err(|error| format!("read bundled Codex ACP zip: {error}"))?; + validate_managed_node_zip_entries(&archive)?; + extract_managed_node_zip(&mut archive, &staging)?; + + let staged_node = staging.join("node"); + let staged_tools = staging.join("node-tools"); + verify_node_tree(&staged_node)?; + if !staged_tools + .join("node_modules") + .join("@agentclientprotocol") + .join("codex-acp") + .join("dist") + .join("index.js") + .is_file() + { + return Err("bundled Codex ACP package is incomplete".to_string()); + } + + if !managed_node_runtime_ready() { + copy_tree(&staged_node, &node_destination)?; + } + copy_tree(&staged_tools, &npm_prefix)?; + + if !managed_node_runtime_ready() { + return Err("bundled Node.js runtime failed its version probe".to_string()); + } + let adapter = npm_prefix.join("codex-acp.cmd"); + let node_dir = node_destination.to_string_lossy(); + let inherited = std::env::var("PATH").unwrap_or_default(); + let probe_path = format!("{node_dir};{inherited}"); + let version = + crate::managed_agents::probe_codex_acp_version_with_path(&adapter, Some(&probe_path)) + .ok_or_else(|| "bundled Codex ACP failed its version probe".to_string())?; + if version < crate::managed_agents::MIN_CODEX_ACP_VERSION { + return Err(format!( + "bundled Codex ACP {}.{}.{} is below the supported version", + version.0, version.1, version.2 + )); + } + Ok(manifest.adapter_version.clone()) + })(); + + let _ = std::fs::remove_dir_all(&staging); + install_result.map(Some) +} + +#[cfg(not(all(target_os = "windows", target_arch = "x86_64")))] +pub(super) fn install_bundled_codex_acp(_app: &tauri::AppHandle) -> Result, String> { + Ok(None) +} + pub(super) fn ensure_managed_node_runtime_blocking() -> Result<(), Box> { if managed_node_runtime_ready() { return Ok(()); diff --git a/desktop/src-tauri/src/commands/agent_discovery/managed_node_tests.rs b/desktop/src-tauri/src/commands/agent_discovery/managed_node_tests.rs index a8e1d7f4c8..b28f8385af 100644 --- a/desktop/src-tauri/src/commands/agent_discovery/managed_node_tests.rs +++ b/desktop/src-tauri/src/commands/agent_discovery/managed_node_tests.rs @@ -52,6 +52,59 @@ fn test_shell_quote_escapes_single_quotes() { ); } +#[test] +fn test_bundled_codex_manifest_requires_expected_runtime() { + let valid = BundledCodexAcpManifest { + schema_version: 1, + platform: "win-x64".to_string(), + node_version: "v24.18.0".to_string(), + adapter_package: "@agentclientprotocol/codex-acp".to_string(), + adapter_version: "1.2.0".to_string(), + archive_sha256: "a".repeat(64), + }; + assert!(validate_bundled_codex_acp_manifest(&valid).is_ok()); + + let wrong_platform = BundledCodexAcpManifest { + platform: "win-arm64".to_string(), + ..valid + }; + assert!(validate_bundled_codex_acp_manifest(&wrong_platform).is_err()); +} + +#[test] +fn test_copy_tree_merges_without_removing_other_adapters() { + let source = tempfile::TempDir::new().unwrap(); + let destination = tempfile::TempDir::new().unwrap(); + let package = source + .path() + .join("node_modules") + .join("@agentclientprotocol") + .join("codex-acp"); + std::fs::create_dir_all(&package).unwrap(); + std::fs::write(package.join("package.json"), b"codex").unwrap(); + std::fs::write(source.path().join("codex-acp.cmd"), b"shim").unwrap(); + + let other = destination + .path() + .join("node_modules") + .join("@agentclientprotocol") + .join("claude-agent-acp"); + std::fs::create_dir_all(&other).unwrap(); + std::fs::write(other.join("package.json"), b"claude").unwrap(); + + copy_tree(source.path(), destination.path()).unwrap(); + + assert!(destination.path().join("codex-acp.cmd").is_file()); + assert!(other.join("package.json").is_file()); + assert!(destination + .path() + .join("node_modules") + .join("@agentclientprotocol") + .join("codex-acp") + .join("package.json") + .is_file()); +} + // ── zip validation tests ────────────────────────────────────────────────────── /// Build an in-memory zip archive with the supplied entry names and return diff --git a/desktop/src-tauri/src/commands/agent_discovery/relay_agent_directory.rs b/desktop/src-tauri/src/commands/agent_discovery/relay_agent_directory.rs new file mode 100644 index 0000000000..62b2b35a29 --- /dev/null +++ b/desktop/src-tauri/src/commands/agent_discovery/relay_agent_directory.rs @@ -0,0 +1,60 @@ +use crate::{ + app_state::AppState, managed_agents::RelayAgentInfo, nostr_convert, relay::query_relay, +}; + +pub(super) async fn list_relay_agents(state: &AppState) -> Result, String> { + // Self-authored directory profiles and owner-authored managed-agent + // definitions are both public discovery sources. + let events = query_relay( + state, + &[serde_json::json!({ + "kinds": [5, 10100, 30177, 13535], + })], + ) + .await?; + + // A 30177 event is trusted only after the target agent's kind:0 profile + // proves that the event author is its NIP-OA owner. + let mut target_pubkeys = nostr_convert::relay_agents::managed_agent_target_pubkeys(&events); + for event in events.iter().filter(|event| event.kind.as_u16() == 5) { + for tag in event.tags.iter() { + let values = tag.as_slice(); + if values.first().map(String::as_str) != Some("a") { continue; } + if let Some(agent) = values.get(1).and_then(|coordinate| coordinate.split(':').nth(2)) { + if agent.len() == 64 { target_pubkeys.push(agent.to_ascii_lowercase()); } + } + } + } + for event in events + .iter() + .filter(|event| event.kind.as_u16() as u32 == 13535) + { + for tag in event.tags.iter() { + let values = tag.as_slice(); + if values.first().map(String::as_str) == Some("p") { + if let Some(agent) = values.get(1).filter(|value| value.len() == 64) { + target_pubkeys.push(agent.to_ascii_lowercase()); + } + } + } + } + target_pubkeys.sort(); + target_pubkeys.dedup(); + let identity_profiles = if target_pubkeys.is_empty() { + Vec::new() + } else { + query_relay( + state, + &[serde_json::json!({ + "kinds": [0], + "authors": target_pubkeys, + })], + ) + .await? + }; + + Ok(nostr_convert::relay_agents::relay_agents_from_events( + &events, + &identity_profiles, + )) +} diff --git a/desktop/src-tauri/src/commands/agent_models.rs b/desktop/src-tauri/src/commands/agent_models.rs index 183f27dba1..3d267b874e 100644 --- a/desktop/src-tauri/src/commands/agent_models.rs +++ b/desktop/src-tauri/src/commands/agent_models.rs @@ -823,6 +823,9 @@ pub async fn update_managed_agent( if input.respond_to_allowlist.is_some() { record.respond_to_allowlist = prospective_allowlist; } + if let Some(value) = input.allow_non_owner_dm { + record.allow_non_owner_dm = value; + } record.updated_at = now_iso(); diff --git a/desktop/src-tauri/src/commands/agent_preview.rs b/desktop/src-tauri/src/commands/agent_preview.rs new file mode 100644 index 0000000000..1eb859c289 --- /dev/null +++ b/desktop/src-tauri/src/commands/agent_preview.rs @@ -0,0 +1,48 @@ +use base64::Engine; +use std::path::PathBuf; + +const MAX_PREVIEW_BYTES: u64 = 20 * 1024 * 1024; +const ALLOWED_IMAGE_MIMES: &[&str] = &["image/png", "image/jpeg", "image/gif", "image/webp"]; + +#[tauri::command] +pub async fn read_agent_preview_image(path: String) -> Result { + let requested = PathBuf::from(path); + if !requested.is_absolute() { + return Err("agent preview path must be absolute".into()); + } + + let canonical = tokio::fs::canonicalize(&requested) + .await + .map_err(|e| format!("cannot resolve agent preview image: {e}"))?; + let metadata = tokio::fs::metadata(&canonical) + .await + .map_err(|e| format!("cannot inspect agent preview image: {e}"))?; + if !metadata.is_file() { + return Err("agent preview path is not a file".into()); + } + if metadata.len() > MAX_PREVIEW_BYTES { + return Err("agent preview image exceeds 20 MiB".into()); + } + + let bytes = tokio::fs::read(&canonical) + .await + .map_err(|e| format!("cannot read agent preview image: {e}"))?; + let mime = infer::get(&bytes) + .map(|kind| kind.mime_type()) + .filter(|mime| ALLOWED_IMAGE_MIMES.contains(mime)) + .ok_or_else(|| "agent preview file is not a supported image".to_string())?; + let encoded = base64::engine::general_purpose::STANDARD.encode(bytes); + Ok(format!("data:{mime};base64,{encoded}")) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn allowed_types_are_raster_images_only() { + assert!(ALLOWED_IMAGE_MIMES.contains(&"image/png")); + assert!(!ALLOWED_IMAGE_MIMES.contains(&"image/svg+xml")); + assert!(!ALLOWED_IMAGE_MIMES.contains(&"video/mp4")); + } +} diff --git a/desktop/src-tauri/src/commands/agents.rs b/desktop/src-tauri/src/commands/agents.rs index 453bb81fb0..69e78e98f6 100644 --- a/desktop/src-tauri/src/commands/agents.rs +++ b/desktop/src-tauri/src/commands/agents.rs @@ -6,15 +6,17 @@ use super::managed_agent_definition::validate_create_definition; use crate::{ app_state::AppState, managed_agents::{ - build_managed_agent_summary, current_instance_id, discover_provider_candidates, - ensure_persona_is_active, find_managed_agent_mut, load_managed_agents, load_personas, - load_teams, managed_agent_avatar_url, normalize_agent_args, provider_deploy, - resolve_provider_binary, save_managed_agents, start_managed_agent_process, - stop_managed_agent_process, stop_managed_agent_workspace_pair, - sync_managed_agent_processes, try_regenerate_nest, validate_provider_config, BackendKind, - CreateManagedAgentRequest, CreateManagedAgentResponse, ManagedAgentRecord, - ManagedAgentSummary, RelayMeshConfig, DEFAULT_ACP_COMMAND, DEFAULT_AGENT_PARALLELISM, - DEFAULT_AGENT_TURN_TIMEOUT_SECONDS, + agent_readiness, build_managed_agent_summary, current_instance_id, + discover_provider_candidates, ensure_persona_is_active, find_managed_agent_mut, + known_acp_runtime, load_global_agent_config, load_managed_agents, load_personas, + load_teams, managed_agent_avatar_url, normalize_agent_args, prepare_codex_task_binding, + provider_deploy, record_agent_command, resolve_effective_agent_env, + resolve_provider_binary, save_agents_with_codex_task_binding, save_managed_agents, + start_managed_agent_process, stop_managed_agent_process, stop_managed_agent_workspace_pair, + sync_managed_agent_processes, try_regenerate_nest, validate_provider_config, + AgentReadiness, BackendKind, CreateManagedAgentRequest, CreateManagedAgentResponse, + ManagedAgentRecord, ManagedAgentSummary, RelayMeshConfig, DEFAULT_ACP_COMMAND, + DEFAULT_AGENT_PARALLELISM, DEFAULT_AGENT_TURN_TIMEOUT_SECONDS, }, relay::{relay_ws_url_with_override, sync_managed_agent_profile}, util::now_iso, @@ -54,7 +56,16 @@ pub(super) fn retain_managed_agent_pending( // Shared engine with the boot-time reconcile: projection content diff // (no republish for runtime-only churn) + monotonic created_at bump // past the retained head (NIP-AP step 3). - retain_agent_record(&conn, &scope.owner_keys, record).map(|_| ()) + let personas = load_personas(app).unwrap_or_default(); + let command = record_agent_command(record, &personas); + let metadata = known_acp_runtime(&command); + let global = load_global_agent_config(app).unwrap_or_default(); + let effective = resolve_effective_agent_env(record, &personas, metadata, &global); + let ready = record.backend != BackendKind::Local + || matches!(agent_readiness(&effective), AgentReadiness::Ready); + let directory_record = + crate::managed_agents::agent_events::directory_record_for_readiness(record, ready); + retain_agent_record(&conn, &scope.owner_keys, &directory_record).map(|_| ()) })(); if let Err(e) = result { eprintln!("buzz-desktop: agent-retain: {e}"); @@ -570,6 +581,9 @@ pub async fn create_managed_agent( state: State<'_, AppState>, ) -> Result { let name = input.name.trim().to_string(); + if name.is_empty() { + return Err("agent name is required".to_string()); + } let requested_persona_id = input .persona_id .as_deref() @@ -577,6 +591,7 @@ pub async fn create_managed_agent( .filter(|value| !value.is_empty()) .map(str::to_string); validate_create_definition(&name, requested_persona_id.as_deref(), &input)?; + let codex_task_binding = prepare_codex_task_binding(&input)?; if let Some(parallelism) = input.parallelism { if !(1..=32).contains(¶llelism) { return Err("parallelism must be between 1 and 32".to_string()); @@ -810,8 +825,14 @@ pub async fn create_managed_agent( let snapshot_source_version = persona_snapshot.as_ref().map(|s| s.source_version.clone()); let effective_provider = snapshot_provider .or_else(|| input.provider.as_deref().and_then(trim_to_optional_string)); - let mut effective_model = - snapshot_model.or_else(|| input.model.as_deref().and_then(trim_to_optional_string)); + // A task-bound identity resumes the task's own model and reasoning + // effort. It must not silently inherit the Buzz-wide default model. + let task_model = codex_task_binding + .as_ref() + .and_then(|binding| binding.model.clone()); + let mut effective_model = task_model + .or(snapshot_model) + .or_else(|| input.model.as_deref().and_then(trim_to_optional_string)); if effective_provider.as_deref() == Some(crate::managed_agents::RELAY_MESH_PROVIDER_ID) && effective_model.is_none() { @@ -891,6 +912,7 @@ pub async fn create_managed_agent( last_error_code: None, respond_to: minted.respond_to, respond_to_allowlist: minted.respond_to_allowlist.clone(), + allow_non_owner_dm: false, display_name: None, slug: None, runtime: None, @@ -916,8 +938,7 @@ pub async fn create_managed_agent( }; records.push(record); - - save_managed_agents(&app, &records)?; + save_agents_with_codex_task_binding(&app, &records, &pubkey, codex_task_binding.clone())?; let record = records .iter() @@ -1331,8 +1352,13 @@ pub async fn delete_managed_agent( return Err(format!("agent {pubkey} not found")); } save_managed_agents(&app, &records)?; - // Remove the agent's nsec from the keyring after the record is gone. - crate::managed_agents::delete_agent_key(&pubkey); + if let Err(error) = + crate::managed_agents::delete_codex_task_identity_state(&app, &pubkey) + { + eprintln!( + "buzz-desktop: failed to delete Codex task identity state for {pubkey}: {error}" + ); + } // Tombstone-after-validation: only reached past the deployed-remote // guard above and a confirmed removal — never orphan a live remote // deployment's relay record. Inside the lock, before the block closes diff --git a/desktop/src-tauri/src/commands/agents_deploy.rs b/desktop/src-tauri/src/commands/agents_deploy.rs index 47ee5f92d4..54930c9294 100644 --- a/desktop/src-tauri/src/commands/agents_deploy.rs +++ b/desktop/src-tauri/src/commands/agents_deploy.rs @@ -78,6 +78,29 @@ pub(super) fn build_launch_block( "BUZZ_ACP_AGENTS".into(), crate::managed_agents::acp_agents_value(&descriptor.command, record.parallelism), ); + let (respond_to, respond_to_allowlist) = crate::managed_agents::projected_access_with_policy( + record, + crate::managed_agents::owner_only_access_build(), + ); + policy_env.insert( + "BUZZ_ACP_RESPOND_TO".into(), + respond_to.as_str().to_string(), + ); + if respond_to == crate::managed_agents::RespondTo::Allowlist { + policy_env.insert( + "BUZZ_ACP_RESPOND_TO_ALLOWLIST".into(), + respond_to_allowlist.join(","), + ); + } + policy_env.insert( + "BUZZ_ACP_ALLOW_NON_OWNER_DM".into(), + if record.allow_non_owner_dm { + "true" + } else { + "false" + } + .into(), + ); if let Some(value) = effective_prompt { policy_env.insert("BUZZ_ACP_SYSTEM_PROMPT".into(), value.to_string()); diff --git a/desktop/src-tauri/src/commands/codex_tasks.rs b/desktop/src-tauri/src/commands/codex_tasks.rs new file mode 100644 index 0000000000..cfff6836df --- /dev/null +++ b/desktop/src-tauri/src/commands/codex_tasks.rs @@ -0,0 +1,50 @@ +use crate::managed_agents::{CodexSharedRuntimeStatus, CodexTaskHistory, CodexTaskSummary}; +use tauri::AppHandle; + +#[tauri::command] +pub async fn list_codex_tasks() -> Result, String> { + tokio::task::spawn_blocking(crate::managed_agents::list_codex_tasks) + .await + .map_err(|error| format!("spawn_blocking failed: {error}"))? +} + +#[tauri::command] +pub async fn get_codex_task_history( + app: AppHandle, + agent_pubkey: String, +) -> Result { + tokio::task::spawn_blocking(move || { + crate::managed_agents::get_codex_task_history(&app, &agent_pubkey) + }) + .await + .map_err(|error| format!("spawn_blocking failed: {error}"))? +} + +#[tauri::command] +pub async fn get_codex_shared_runtime_status( + app: AppHandle, +) -> Result { + crate::managed_agents::codex_shared_runtime_status(&app).await +} + +#[tauri::command] +pub async fn enable_codex_shared_runtime( + app: AppHandle, +) -> Result { + crate::managed_agents::enable_codex_shared_runtime(&app).await +} + +#[tauri::command] +pub async fn launch_codex_desktop_shared() -> Result<(), String> { + tokio::task::spawn_blocking(crate::managed_agents::launch_codex_desktop_shared) + .await + .map_err(|error| format!("spawn_blocking failed: {error}"))? +} + +#[tauri::command] +pub async fn take_over_codex_desktop_shared( + app: AppHandle, + confirmed: bool, +) -> Result { + crate::managed_agents::take_over_codex_desktop_shared(&app, confirmed).await +} diff --git a/desktop/src-tauri/src/commands/handoffs.rs b/desktop/src-tauri/src/commands/handoffs.rs new file mode 100644 index 0000000000..1d6747679e --- /dev/null +++ b/desktop/src-tauri/src/commands/handoffs.rs @@ -0,0 +1,129 @@ +//! GUI commands for encrypted Agent handoff records. + +use buzz_core_pkg::agent_handoff::{ + build_agent_handoff_event, decrypt_agent_handoff, AgentHandoffPayload, HANDOFF_VERSION, +}; +use buzz_core_pkg::kind::KIND_AGENT_HANDOFF; +use nostr::PublicKey; +use serde::{Deserialize, Serialize}; +use tauri::State; + +use crate::app_state::AppState; +use crate::relay::{query_relay, submit_signed_event_with_keys}; + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SendAgentHandoffRequest { + pub recipient_pubkey: String, + pub title: String, + pub summary: Option, + pub history: String, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct AgentHandoffSummary { + pub event_id: String, + pub sender_pubkey: String, + pub created_at: u64, + pub title: String, + pub summary: Option, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct AgentHandoffRecord { + pub event_id: String, + pub sender_pubkey: String, + pub created_at: u64, + pub title: String, + pub summary: Option, + pub history: String, +} + +#[tauri::command] +pub async fn send_agent_handoff( + request: SendAgentHandoffRequest, + state: State<'_, AppState>, +) -> Result { + let recipient = PublicKey::from_hex(&request.recipient_pubkey) + .map_err(|error| format!("recipient pubkey must be 64-hex: {error}"))?; + let keys = state.signing_keys()?; + if recipient == keys.public_key() { + return Err("recipient must be a different Agent".to_string()); + } + let payload = AgentHandoffPayload { + version: HANDOFF_VERSION, + title: request.title, + summary: request.summary, + history: request.history, + }; + let event = build_agent_handoff_event(&keys, &recipient, &payload) + .map_err(|error| format!("invalid handoff: {error}"))?; + let event_id = event.id.to_hex(); + submit_signed_event_with_keys(&event, &state, &keys, None) + .await + .map_err(|error| format!("failed to publish handoff: {error}"))?; + Ok(event_id) +} + +#[tauri::command] +pub async fn list_agent_handoffs( + limit: Option, + state: State<'_, AppState>, +) -> Result, String> { + let limit = limit.unwrap_or(50).clamp(1, 500); + let keys = state.signing_keys()?; + let filter = serde_json::json!({ + "kinds": [KIND_AGENT_HANDOFF], + "#p": [keys.public_key().to_hex()], + "limit": limit, + }); + let mut result = Vec::new(); + for event in query_relay(&state, &[filter]).await? { + if event.verify().is_err() { + continue; + } + let Ok(payload) = decrypt_agent_handoff(&keys, &event) else { + continue; + }; + result.push(AgentHandoffSummary { + event_id: event.id.to_hex(), + sender_pubkey: event.pubkey.to_hex(), + created_at: event.created_at.as_secs(), + title: payload.title, + summary: payload.summary, + }); + } + result.sort_by_key(|item| std::cmp::Reverse(item.created_at)); + Ok(result) +} + +#[tauri::command] +pub async fn get_agent_handoff( + event_id: String, + state: State<'_, AppState>, +) -> Result { + let keys = state.signing_keys()?; + let filter = serde_json::json!({ + "kinds": [KIND_AGENT_HANDOFF], + "ids": [event_id], + "#p": [keys.public_key().to_hex()], + "limit": 1, + }); + let event = query_relay(&state, &[filter]) + .await? + .into_iter() + .find(|event| event.verify().is_ok()) + .ok_or_else(|| "handoff not found or not addressed to this Agent".to_string())?; + let payload = decrypt_agent_handoff(&keys, &event) + .map_err(|error| format!("failed to decrypt handoff: {error}"))?; + Ok(AgentHandoffRecord { + event_id: event.id.to_hex(), + sender_pubkey: event.pubkey.to_hex(), + created_at: event.created_at.as_secs(), + title: payload.title, + summary: payload.summary, + history: payload.history, + }) +} diff --git a/desktop/src-tauri/src/commands/identity.rs b/desktop/src-tauri/src/commands/identity.rs index ec2357b85e..777947215f 100644 --- a/desktop/src-tauri/src/commands/identity.rs +++ b/desktop/src-tauri/src/commands/identity.rs @@ -92,6 +92,15 @@ pub fn get_relay_ws_url(state: State<'_, AppState>) -> String { relay_ws_url_with_override(&state) } +#[tauri::command] +pub fn get_relay_lan_ws_url(state: State<'_, AppState>) -> Result, String> { + state + .relay_lan_url_override + .lock() + .map(|url| url.clone()) + .map_err(|error| error.to_string()) +} + #[tauri::command] pub fn get_relay_http_url(state: State<'_, AppState>) -> String { relay_api_base_url_with_override(&state) diff --git a/desktop/src-tauri/src/commands/media.rs b/desktop/src-tauri/src/commands/media.rs index 8da845c07d..dcb985dc93 100644 --- a/desktop/src-tauri/src/commands/media.rs +++ b/desktop/src-tauri/src/commands/media.rs @@ -308,6 +308,47 @@ pub(crate) fn detect_and_validate_mime(body: &[u8]) -> Result { Ok(mime) } +fn text_mime_from_filename(filename: &str, body: &[u8]) -> Option<&'static str> { + if body.contains(&0) || std::str::from_utf8(body).is_err() { + return None; + } + let extension = std::path::Path::new(filename) + .extension()? + .to_str()? + .to_ascii_lowercase(); + match extension.as_str() { + "md" | "markdown" | "mdown" | "mkd" => Some("text/markdown"), + "txt" | "log" => Some("text/plain"), + "csv" => Some("text/csv"), + "json" | "jsonc" | "jsonl" => Some("application/json"), + "c" | "cc" | "cpp" | "cs" | "css" | "go" | "h" | "hpp" | "java" | "js" | "jsx" | "kt" + | "kts" | "lua" | "php" | "pl" | "ps1" | "py" | "rb" | "rs" | "sh" | "sql" | "swift" + | "toml" | "ts" | "tsx" | "xml" | "yaml" | "yml" => Some("text/plain"), + _ => None, + } +} + +fn detect_and_validate_upload_mime(body: &[u8], filename: Option<&str>) -> Result { + let mime = infer::get(body) + .map(|t| t.mime_type().to_string()) + .or_else(|| { + filename.and_then(|name| text_mime_from_filename(name, body).map(str::to_string)) + }) + .unwrap_or_else(|| "application/octet-stream".to_string()); + if BLOCKED_MIME.contains(&mime.as_str()) { + return Err(format!("unsupported file type: {mime}")); + } + Ok(mime) +} + +fn preserve_client_text_mime(descriptor: &mut BlobDescriptor, upload_mime: &str) { + if descriptor.mime_type == "application/octet-stream" + && (upload_mime.starts_with("text/") || upload_mime == "application/json") + { + descriptor.mime_type = upload_mime.to_string(); + } +} + /// Lifetime of a Blossom `t=get` read token. Ten minutes keeps a token alive /// across a video's range-request stream while staying well inside the /// server's `created_at` freshness window (3600s, matching upload). @@ -416,12 +457,13 @@ pub(crate) async fn upload_image_bytes( return Err("profile avatar must be an image".to_string()); } let body = sanitize_image_for_upload(body, &mime)?; - do_upload(body, &mime, state, None, None).await + do_upload(body, &mime, None, state, None, None).await } async fn do_upload( body: Vec, mime: &str, + filename: Option<&str>, state: &AppState, progress: Option<(tauri::AppHandle, String)>, cancellation: Option<&CancellationToken>, @@ -457,6 +499,7 @@ async fn do_upload( auth_header: &auth_header, mime, sha256: &sha256, + filename, body: body.clone(), progress: progress.as_ref(), cancellation, @@ -471,6 +514,7 @@ async fn do_upload( auth_header: &auth_header, mime, sha256: &sha256, + filename, body, progress: progress.as_ref(), cancellation, @@ -483,7 +527,9 @@ async fn do_upload( return Err(relay_error_message(resp).await); } - parse_json_response::(resp).await + let mut descriptor = parse_json_response::(resp).await?; + preserve_client_text_mime(&mut descriptor, mime); + Ok(descriptor) } // ── Commands ───────────────────────────────────────────────────────────────── @@ -519,9 +565,15 @@ pub async fn upload_media( let _ = std::fs::remove_file(&fd_path); } - let mime = detect_and_validate_mime(&body)?; + let filename = path + .file_name() + .and_then(|name| name.to_str()) + .map(sanitize_filename); + let mime = detect_and_validate_upload_mime(&body, filename.as_deref())?; let body = sanitize_image_for_upload(body, &mime)?; - do_upload(body, &mime, &state, None, None).await + let mut descriptor = do_upload(body, &mime, filename.as_deref(), &state, None, None).await?; + descriptor.filename = filename; + Ok(descriptor) } /// Read a picked path through the TOCTOU-safe pipeline (fd pin → sniff → @@ -591,7 +643,11 @@ async fn process_picked_path( .await .map_err(|e| format!("transcode task failed: {e}"))??; - let mime = detect_and_validate_mime(&body)?; + let filename = path + .file_name() + .and_then(|name| name.to_str()) + .map(sanitize_filename); + let mime = detect_and_validate_upload_mime(&body, filename.as_deref())?; let body = sanitize_image_for_upload(body, &mime)?; // Image-only surfaces (e.g. "Send feedback"): reject anything that didn't @@ -602,9 +658,9 @@ async fn process_picked_path( // Upload video first, then poster (best-effort). If poster upload fails, // the video descriptor is returned without an image field. - let mut descriptor = do_upload(body, &mime, state, progress, None).await?; + let mut descriptor = do_upload(body, &mime, filename.as_deref(), state, progress, None).await?; if let Some(poster) = poster_bytes { - match do_upload(poster, "image/jpeg", state, None, None).await { + match do_upload(poster, "image/jpeg", None, state, None, None).await { Ok(poster_desc) => descriptor.image = Some(poster_desc.url), Err(e) => eprintln!("buzz-desktop: poster upload failed (non-fatal): {e}"), } @@ -772,7 +828,8 @@ pub(super) async fn upload_media_bytes_inner( (data, None) }; - let mime = detect_and_validate_mime(&body)?; + let sanitized_filename = filename.as_deref().map(sanitize_filename); + let mime = detect_and_validate_upload_mime(&body, sanitized_filename.as_deref())?; let body = sanitize_image_for_upload(body, &mime)?; // Upload video first, then poster (best-effort). @@ -780,11 +837,19 @@ pub(super) async fn upload_media_bytes_inner( if cancellation.is_some_and(CancellationToken::is_cancelled) { return Err("upload cancelled".to_string()); } - let mut descriptor = do_upload(body, &mime, &state, progress, cancellation).await?; + let mut descriptor = do_upload( + body, + &mime, + sanitized_filename.as_deref(), + &state, + progress, + cancellation, + ) + .await?; emit_media_upload_phase(&app, progress_id.as_deref(), "finishing"); if let Some(poster) = poster_bytes { - match do_upload(poster, "image/jpeg", &state, None, cancellation).await { + match do_upload(poster, "image/jpeg", None, &state, None, cancellation).await { Ok(poster_desc) => descriptor.image = Some(poster_desc.url), Err(e) => eprintln!("buzz-desktop: poster upload failed (non-fatal): {e}"), } @@ -905,6 +970,32 @@ mod tests { assert!(detect_and_validate_mime(&elf).is_err()); } + #[test] + fn test_upload_mime_uses_filename_for_safe_text() { + assert_eq!( + detect_and_validate_upload_mime(b"# Notes\n", Some("notes.md")).unwrap(), + "text/markdown" + ); + assert_eq!( + detect_and_validate_upload_mime(br#"{"ok":true}"#, Some("result.json")).unwrap(), + "application/json" + ); + assert_eq!( + detect_and_validate_upload_mime(b"fn main() {}\n", Some("main.rs")).unwrap(), + "text/plain" + ); + } + + #[test] + fn test_upload_mime_does_not_trust_text_extension_for_binary() { + assert_eq!( + detect_and_validate_upload_mime(b"not utf-8: \xff", Some("fake.md")).unwrap(), + "application/octet-stream" + ); + let elf = [b"\x7fELF".as_slice(), &[0u8; 60]].concat(); + assert!(detect_and_validate_upload_mime(&elf, Some("fake.md")).is_err()); + } + #[test] fn test_blocked_mime_keeps_active_content_and_executables() { for kept in [ diff --git a/desktop/src-tauri/src/commands/media_download.rs b/desktop/src-tauri/src/commands/media_download.rs index 7bc94da25d..2ccdc66338 100644 --- a/desktop/src-tauri/src/commands/media_download.rs +++ b/desktop/src-tauri/src/commands/media_download.rs @@ -25,24 +25,19 @@ const DOWNLOAD_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(60) /// Validate that a URL is a legitimate relay media URL. /// -/// Ensures: -/// - URL scheme is `https` (or `http` for localhost dev) +/// - URL scheme is `https` or `http` /// - URL origin matches the relay base URL /// - URL path matches `/media/{hash}.{ext}` fn validate_download_url(url: &str, relay_base: &str) -> Result<(), String> { let parsed = url::Url::parse(url).map_err(|_| "invalid URL".to_string())?; let base = url::Url::parse(relay_base).map_err(|_| "invalid relay base URL".to_string())?; - // Scheme must be https (allow http for localhost dev servers). - match parsed.scheme() { - "https" => {} - "http" => { - let host = parsed.host_str().unwrap_or(""); - if host != "localhost" && host != "127.0.0.1" && host != "[::1]" { - return Err("download URL must use HTTPS".to_string()); - } - } - _ => return Err("download URL must use HTTPS".to_string()), + // The configured relay may be an HTTP-only private/LAN deployment. This is + // safe at the SSRF boundary because the exact origin check below prevents + // the caller from selecting any host other than the already configured + // relay; the native client also refuses redirects before attaching auth. + if !matches!(parsed.scheme(), "http" | "https") { + return Err("download URL must use HTTP or HTTPS".to_string()); } // Origin must match relay. @@ -834,14 +829,26 @@ mod tests { fn test_validate_download_url_non_https_scheme_rejected() { let result = validate_download_url("ftp://relay.example.com/media/abc.jpg", RELAY_BASE); assert!(result.is_err()); - assert!(result.unwrap_err().contains("HTTPS")); + assert!(result.unwrap_err().contains("HTTP or HTTPS")); + } + + #[test] + fn test_validate_download_url_http_private_relay_allowed() { + assert!(validate_download_url( + "http://192.168.50.20:4500/media/abc.jpg", + "http://192.168.50.20:4500", + ) + .is_ok()); } #[test] - fn test_validate_download_url_http_non_localhost_rejected() { - let result = validate_download_url("http://relay.example.com/media/abc.jpg", RELAY_BASE); + fn test_validate_download_url_http_still_requires_exact_relay_origin() { + let result = validate_download_url( + "http://192.168.50.21:4500/media/abc.jpg", + "http://192.168.50.20:4500", + ); assert!(result.is_err()); - assert!(result.unwrap_err().contains("HTTPS")); + assert!(result.unwrap_err().contains("relay origin")); } #[test] diff --git a/desktop/src-tauri/src/commands/media_upload_progress.rs b/desktop/src-tauri/src/commands/media_upload_progress.rs index 850afe1b12..12cbc5e355 100644 --- a/desktop/src-tauri/src/commands/media_upload_progress.rs +++ b/desktop/src-tauri/src/commands/media_upload_progress.rs @@ -1,3 +1,4 @@ +use base64::Engine; use std::{ collections::HashMap, sync::{LazyLock, Mutex}, @@ -42,6 +43,7 @@ pub(super) struct UploadAttempt<'a> { pub auth_header: &'a str, pub mime: &'a str, pub sha256: &'a str, + pub filename: Option<&'a str>, pub body: bytes::Bytes, pub progress: Option<&'a (tauri::AppHandle, String)>, pub cancellation: Option<&'a CancellationToken>, @@ -56,16 +58,23 @@ pub(super) async fn send_upload_attempt( auth_header, mime, sha256, + filename, body, progress, cancellation, } = attempt; - let req = state + let mut req = state .http_client .put(url) .header("Authorization", auth_header) .header("Content-Type", mime) .header("X-SHA-256", sha256); + if let Some(name) = filename { + req = req.header( + "X-Buzz-Filename", + base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(name), + ); + } let response = if let Some((app, progress_id)) = progress { let app = app.clone(); diff --git a/desktop/src-tauri/src/commands/message_send_diagnostics.rs b/desktop/src-tauri/src/commands/message_send_diagnostics.rs new file mode 100644 index 0000000000..cccf5e2064 --- /dev/null +++ b/desktop/src-tauri/src/commands/message_send_diagnostics.rs @@ -0,0 +1,255 @@ +use std::{ + fs::{self, OpenOptions}, + future::Future, + io::Write as _, + path::{Path, PathBuf}, + sync::Mutex, + time::{Instant, SystemTime, UNIX_EPOCH}, +}; + +use serde::{Deserialize, Serialize}; +use tauri::{AppHandle, Manager}; + +const MAX_LOG_SIZE: u64 = 2 * 1024 * 1024; +static MESSAGE_SEND_LOG_LOCK: Mutex<()> = Mutex::new(()); + +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct MessageSendDiagnosticEntry { + pub operation_id: String, + pub stage: String, + pub transport: String, + pub channel_id: Option, + pub event_id: Option, + pub elapsed_ms: Option, + pub wait_ms: Option, + pub gate_remaining_ms: Option, + pub connection_state: Option, + pub outcome: Option, +} + +#[derive(Debug, Serialize)] +struct StoredMessageSendDiagnostic<'a> { + timestamp_ms: u128, + #[serde(flatten)] + entry: &'a MessageSendDiagnosticEntry, +} + +fn message_send_log_path(app: &AppHandle) -> Result { + let dir = app + .path() + .app_data_dir() + .map_err(|error| format!("failed to resolve app data dir: {error}"))? + .join("diagnostics"); + fs::create_dir_all(&dir) + .map_err(|error| format!("failed to create diagnostics dir: {error}"))?; + Ok(dir.join("message-send.jsonl")) +} + +fn valid_token(value: &str, max_len: usize) -> bool { + !value.is_empty() + && value.len() <= max_len + && value + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || b"-_.:".contains(&byte)) +} + +fn validate_entry(entry: &MessageSendDiagnosticEntry) -> Result<(), String> { + for (label, value, max_len) in [ + ("operation_id", entry.operation_id.as_str(), 64), + ("stage", entry.stage.as_str(), 64), + ("transport", entry.transport.as_str(), 24), + ] { + if !valid_token(value, max_len) { + return Err(format!("invalid message-send diagnostic {label}")); + } + } + + for (label, value, max_len) in [ + ("channel_id", entry.channel_id.as_deref(), 64), + ("event_id", entry.event_id.as_deref(), 128), + ("connection_state", entry.connection_state.as_deref(), 32), + ("outcome", entry.outcome.as_deref(), 64), + ] { + if value.is_some_and(|value| !valid_token(value, max_len)) { + return Err(format!("invalid message-send diagnostic {label}")); + } + } + Ok(()) +} + +fn rotate_log(path: &Path) { + if fs::metadata(path).map_or(true, |metadata| metadata.len() <= MAX_LOG_SIZE) { + return; + } + let rotated = path.with_extension("jsonl.1"); + let _ = fs::remove_file(&rotated); + let _ = fs::rename(path, rotated); +} + +fn append_entry_to_path(path: &Path, entry: &MessageSendDiagnosticEntry) -> Result<(), String> { + validate_entry(entry)?; + let _guard = MESSAGE_SEND_LOG_LOCK + .lock() + .map_err(|error| format!("message-send log lock failed: {error}"))?; + rotate_log(path); + let mut file = OpenOptions::new() + .create(true) + .append(true) + .open(path) + .map_err(|error| format!("failed to open {}: {error}", path.display()))?; + let record = StoredMessageSendDiagnostic { + timestamp_ms: SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_millis(), + entry, + }; + serde_json::to_writer(&mut file, &record) + .map_err(|error| format!("failed to encode message-send diagnostic: {error}"))?; + writeln!(file).map_err(|error| format!("failed to write {}: {error}", path.display())) +} + +fn append_message_send_diagnostic_internal(app: &AppHandle, entry: MessageSendDiagnosticEntry) { + if let Ok(path) = message_send_log_path(app) { + let _ = append_entry_to_path(&path, &entry); + } +} + +fn elapsed_ms(started_at: Instant) -> u64 { + started_at.elapsed().as_millis().min(u128::from(u64::MAX)) as u64 +} + +pub(crate) struct MessageSendTrace { + app: AppHandle, + operation_id: Option, + channel_id: String, + started_at: Instant, +} + +impl MessageSendTrace { + pub fn new(app: AppHandle, operation_id: Option, channel_id: &str) -> Self { + Self { + app, + operation_id, + channel_id: channel_id.to_string(), + started_at: Instant::now(), + } + } + + fn mark( + &self, + stage: String, + wait_ms: Option, + gate_remaining_ms: Option, + outcome: Option<&str>, + ) { + let Some(operation_id) = &self.operation_id else { + return; + }; + append_message_send_diagnostic_internal( + &self.app, + MessageSendDiagnosticEntry { + operation_id: operation_id.clone(), + stage, + transport: "http".to_string(), + channel_id: Some(self.channel_id.clone()), + event_id: None, + elapsed_ms: Some(elapsed_ms(self.started_at)), + wait_ms, + gate_remaining_ms, + connection_state: None, + outcome: outcome.map(str::to_string), + }, + ); + } + + pub fn started(&self) { + self.mark("rust_command_started".to_string(), None, None, None); + } + + pub async fn measure( + &self, + stage: &str, + gate_remaining_ms: Option, + future: impl Future>, + ) -> Result { + self.mark(format!("{stage}_started"), None, gate_remaining_ms, None); + let started_at = Instant::now(); + match future.await { + Ok(value) => { + self.mark( + format!("{stage}_finished"), + Some(elapsed_ms(started_at)), + gate_remaining_ms, + Some("accepted"), + ); + Ok(value) + } + Err(error) => { + self.mark( + format!("{stage}_finished"), + Some(elapsed_ms(started_at)), + gate_remaining_ms, + Some("failed"), + ); + Err(error) + } + } + } +} + +#[tauri::command] +pub async fn append_message_send_diagnostic( + entry: MessageSendDiagnosticEntry, + app: AppHandle, +) -> Result<(), String> { + tokio::task::spawn_blocking(move || { + let path = message_send_log_path(&app)?; + append_entry_to_path(&path, &entry) + }) + .await + .map_err(|error| format!("message-send diagnostic task failed: {error}"))? +} + +#[cfg(test)] +mod tests { + use super::*; + + fn entry() -> MessageSendDiagnosticEntry { + MessageSendDiagnosticEntry { + operation_id: "019f-send-probe".into(), + stage: "relay_ok".into(), + transport: "websocket".into(), + channel_id: Some("15fed9f9-a324-5e47-917c-6f33546539b1".into()), + event_id: Some("ab".repeat(32)), + elapsed_ms: Some(42), + wait_ms: None, + gate_remaining_ms: None, + connection_state: Some("connected".into()), + outcome: Some("accepted".into()), + } + } + + #[test] + fn writes_structured_json_without_message_content() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("message-send.jsonl"); + append_entry_to_path(&path, &entry()).unwrap(); + let record: serde_json::Value = + serde_json::from_str(fs::read_to_string(path).unwrap().trim()).unwrap(); + assert_eq!(record["stage"], "relay_ok"); + assert_eq!(record["elapsedMs"], 42); + assert!(record.get("content").is_none()); + } + + #[test] + fn rejects_free_form_values_that_could_inject_log_lines() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("message-send.jsonl"); + let mut invalid = entry(); + invalid.outcome = Some("failed\nsecret".into()); + assert!(append_entry_to_path(&path, &invalid).is_err()); + assert!(!path.exists()); + } +} diff --git a/desktop/src-tauri/src/commands/messages.rs b/desktop/src-tauri/src/commands/messages.rs index 4f839638b9..2dc569275e 100644 --- a/desktop/src-tauri/src/commands/messages.rs +++ b/desktop/src-tauri/src/commands/messages.rs @@ -20,6 +20,8 @@ use crate::{ relay::{query_relay, submit_event, submit_event_with_keys}, }; +use super::message_send_diagnostics::MessageSendTrace; + // ── Reads (pure-nostr) ────────────────────────────────────────────────────── /// Timeline content kinds — the message/channel-event kinds that make up a @@ -489,8 +491,12 @@ pub async fn send_channel_message( sent_from_thread_tag: Option>, mention_pubkeys: Option>, kind: Option, + diagnostic_id: Option, state: State<'_, AppState>, + app: AppHandle, ) -> Result { + let trace = MessageSendTrace::new(app, diagnostic_id, &channel_id); + trace.started(); let channel_uuid = uuid::Uuid::parse_str(&channel_id) .map_err(|_| format!("invalid channel UUID: {channel_id}"))?; let mentions = mention_pubkeys.unwrap_or_default(); @@ -519,7 +525,9 @@ pub async fn send_channel_message( let parent_id = parent_event_id .as_deref() .ok_or("forum comment requires parent_event_id")?; - let thread_ref = resolve_thread_ref(parent_id, &state).await?; + let thread_ref = trace + .measure("thread_ref", None, resolve_thread_ref(parent_id, &state)) + .await?; resolved_root = Some(thread_ref.root_event_id.to_hex()); events::build_forum_comment( channel_uuid, @@ -533,7 +541,9 @@ pub async fn send_channel_message( _ => { let thread_ref = match parent_event_id.as_deref() { Some(pid) => { - let tr = resolve_thread_ref(pid, &state).await?; + let tr = trace + .measure("thread_ref", None, resolve_thread_ref(pid, &state)) + .await?; resolved_root = Some(tr.root_event_id.to_hex()); Some(tr) } @@ -554,7 +564,14 @@ pub async fn send_channel_message( } }; - let result = submit_event(builder, &state).await?; + let gate_remaining_ms = crate::relay_admission::rate_limit_remaining_ms(); + let result = trace + .measure( + "relay_submit", + Some(gate_remaining_ms), + submit_event(builder, &state), + ) + .await?; let depth = match (&parent_event_id, &resolved_root) { (None, _) => 0, diff --git a/desktop/src-tauri/src/commands/mod.rs b/desktop/src-tauri/src/commands/mod.rs index 5247371646..0a32c63f64 100644 --- a/desktop/src-tauri/src/commands/mod.rs +++ b/desktop/src-tauri/src/commands/mod.rs @@ -7,6 +7,7 @@ mod agent_metric_archive; mod agent_model_process; mod agent_models; mod agent_models_env; +mod agent_preview; mod agent_providers; mod agent_settings; mod agent_update_rollback; @@ -16,10 +17,12 @@ mod channel_templates; mod channel_window; mod channels; mod clipboard; +mod codex_tasks; mod dms; mod engrams; mod export_util; mod global_agent_config; +mod handoffs; mod identity; mod identity_archive; mod join_policy; @@ -38,6 +41,7 @@ mod media_upload_progress; pub(crate) mod mesh_llm; #[cfg(feature = "mesh-llm")] pub(crate) mod mesh_readiness; +mod message_send_diagnostics; mod messages; mod notifications; mod observer_archive; @@ -74,6 +78,7 @@ pub use agent_discovery::*; pub use agent_logs::*; pub use agent_metric_archive::*; pub use agent_models::*; +pub use agent_preview::*; pub use agent_providers::*; pub use agent_settings::*; pub use agents::*; @@ -82,9 +87,11 @@ pub use channel_templates::*; pub use channel_window::*; pub use channels::*; pub use clipboard::*; +pub use codex_tasks::*; pub use dms::*; pub use engrams::*; pub use global_agent_config::*; +pub use handoffs::*; pub use identity::*; pub use identity_archive::*; pub use join_policy::*; @@ -95,6 +102,7 @@ pub use media_download::*; pub use media_raw::*; #[cfg(feature = "mesh-llm")] pub use mesh_llm::*; +pub use message_send_diagnostics::*; pub use messages::*; pub use notifications::*; pub use observer_archive::*; diff --git a/desktop/src-tauri/src/commands/personas/inbound.rs b/desktop/src-tauri/src/commands/personas/inbound.rs index cbb2314353..a4de23966d 100644 --- a/desktop/src-tauri/src/commands/personas/inbound.rs +++ b/desktop/src-tauri/src/commands/personas/inbound.rs @@ -330,6 +330,12 @@ fn reconcile_inbound_tombstone( let mut agents = load_managed_agents(app)?; agents.retain(|record| record.pubkey != target_d_tag); save_managed_agents(app, &agents)?; + if let Err(error) = crate::managed_agents::remove_codex_task_binding(app, &target_d_tag) + { + eprintln!( + "buzz-desktop: inbound deletion: failed to remove Codex task binding for {target_d_tag}: {error}" + ); + } } _ => unreachable!("target kind gated above"), } diff --git a/desktop/src-tauri/src/commands/personas/mod.rs b/desktop/src-tauri/src/commands/personas/mod.rs index 0cd7ad0324..1818a8efb0 100644 --- a/desktop/src-tauri/src/commands/personas/mod.rs +++ b/desktop/src-tauri/src/commands/personas/mod.rs @@ -234,6 +234,11 @@ pub async fn delete_persona(id: String, app: AppHandle) -> Result<(), String> { // Side effects — strictly after records leave disk. for pk in &cascade { state.clear_agent_session_caches(pk); + if let Err(error) = crate::managed_agents::remove_codex_task_binding(&app, pk) { + eprintln!( + "buzz-desktop: delete_persona: failed to remove Codex task binding for {pk}: {error}" + ); + } // Remove nsec from keyring after the record is gone. delete_agent_key(pk); super::agents::tombstone_managed_agent_pending(&app, &state, pk); diff --git a/desktop/src-tauri/src/commands/personas/snapshot/import.rs b/desktop/src-tauri/src/commands/personas/snapshot/import.rs index d7f0323304..83053833b6 100644 --- a/desktop/src-tauri/src/commands/personas/snapshot/import.rs +++ b/desktop/src-tauri/src/commands/personas/snapshot/import.rs @@ -642,6 +642,7 @@ pub async fn confirm_agent_snapshot_import( // are always consistent at mint time. respond_to: minted.respond_to, respond_to_allowlist: minted.respond_to_allowlist.clone(), + allow_non_owner_dm: false, is_builtin: false, is_active: true, shared: false, diff --git a/desktop/src-tauri/src/commands/personas/update.rs b/desktop/src-tauri/src/commands/personas/update.rs index b3830e62b5..d826df21a2 100644 --- a/desktop/src-tauri/src/commands/personas/update.rs +++ b/desktop/src-tauri/src/commands/personas/update.rs @@ -111,6 +111,11 @@ pub(super) async fn update_persona_with( // Track what changed so we can propagate to linked agent records. let avatar_changed = persona.avatar_url != avatar_url; let name_changed = persona.display_name != display_name; + let old_behavior = ( + persona.respond_to.clone(), + persona.respond_to_allowlist.clone(), + persona.parallelism, + ); let old_display_name = persona.display_name.clone(); persona.display_name = display_name; @@ -130,6 +135,12 @@ pub(super) async fn update_persona_with( persona.env_vars = env_vars; } apply_persona_behavior(persona, input.behavior)?; + let behavior_changed = old_behavior + != ( + persona.respond_to.clone(), + persona.respond_to_allowlist.clone(), + persona.parallelism, + ); persona.updated_at = now_iso(); let result = persona.clone(); @@ -140,7 +151,10 @@ pub(super) async fn update_persona_with( // If the avatar or display_name changed, propagate to linked agent // records and collect relay profile sync params for the async phase. - let sync_params: ProfileSyncParams = if avatar_changed || name_changed { + let sync_params: ProfileSyncParams = if avatar_changed + || name_changed + || behavior_changed + { let mut records = load_managed_agents(&app)?; let mut params: ProfileSyncParams = Vec::new(); let mut agents_modified = false; @@ -167,6 +181,30 @@ pub(super) async fn update_persona_with( } let mut record_changed = renamed.contains(&record.pubkey); + if behavior_changed { + let next_mode = result + .respond_to + .as_deref() + .map(crate::managed_agents::RespondTo::parse_wire) + .transpose()?; + let next_mode = next_mode.unwrap_or_default(); + let next_allowlist = + if next_mode == crate::managed_agents::RespondTo::Allowlist { + crate::managed_agents::validate_respond_to_allowlist( + &result.respond_to_allowlist, + )? + } else { + Vec::new() + }; + if record.respond_to != next_mode + || record.respond_to_allowlist != next_allowlist + { + record.respond_to = next_mode; + record.respond_to_allowlist = next_allowlist; + record_changed = true; + } + } + if avatar_changed { // Update the persisted avatar so reconciliation on next // start agrees with what we're about to publish. @@ -211,7 +249,10 @@ pub(super) async fn update_persona_with( // the stale name→pubkey binding until the next boot reconcile. // Avatar-only edits are excluded — the avatar is not in the // projection, so retaining would be a guaranteed no-op. - for record in records.iter().filter(|r| renamed.contains(&r.pubkey)) { + for record in records.iter().filter(|r| { + renamed.contains(&r.pubkey) + || (behavior_changed && r.persona_id.as_deref() == Some(&result.id)) + }) { crate::commands::agents::retain_managed_agent_pending(&app, &state, record); } } diff --git a/desktop/src-tauri/src/commands/team_snapshot.rs b/desktop/src-tauri/src/commands/team_snapshot.rs index 97cd11933d..0336b76272 100644 --- a/desktop/src-tauri/src/commands/team_snapshot.rs +++ b/desktop/src-tauri/src/commands/team_snapshot.rs @@ -599,6 +599,7 @@ pub async fn confirm_team_snapshot_import( .unwrap_or_default() }, respond_to_allowlist: definition.respond_to_allowlist.clone(), + allow_non_owner_dm: false, is_builtin: false, is_active: true, shared: false, diff --git a/desktop/src-tauri/src/commands/workspace.rs b/desktop/src-tauri/src/commands/workspace.rs index aa88bfe39a..4ed3ff0ea3 100644 --- a/desktop/src-tauri/src/commands/workspace.rs +++ b/desktop/src-tauri/src/commands/workspace.rs @@ -126,6 +126,7 @@ pub async fn validate_repos_dir(dir: String) -> Result<(), String> { #[tauri::command] pub async fn apply_workspace( relay_url: String, + lan_relay_url: Option, nsec: Option, repos_dir: Option, agent_managed_profiles: Option, @@ -136,6 +137,15 @@ pub async fn apply_workspace( let state = app.state::(); // ── Validate before mutating ────────────────────────────────────────── + let lan_relay_url = + match crate::native_websocket::normalize_lan_relay_url(lan_relay_url.as_deref()) { + Ok(value) => value, + Err(error) => { + eprintln!("buzz-desktop: ignoring invalid Campus / LAN relay URL: {error}"); + let _ = app.emit("lan-relay-error", &error); + None + } + }; let parsed_keys = match nsec.as_deref().map(str::trim).filter(|s| !s.is_empty()) { Some(nsec_trimmed) => { Some(Keys::parse(nsec_trimmed).map_err(|e| format!("invalid nsec: {e}"))?) @@ -168,6 +178,13 @@ pub async fn apply_workspace( let mut override_guard = state.relay_url_override.lock().map_err(|e| e.to_string())?; *override_guard = Some(relay_url); } + { + let mut override_guard = state + .relay_lan_url_override + .lock() + .map_err(|e| e.to_string())?; + *override_guard = lan_relay_url; + } // Reset the Rust-side admission gate when switching workspace/community, // matching `resetRateLimitGate()` on the TS side (useCommunityInit.ts:38). crate::relay_admission::reset_gate_for_workspace_change(); diff --git a/desktop/src-tauri/src/deep_link.rs b/desktop/src-tauri/src/deep_link.rs index ffe951dc36..26a513b2ee 100644 --- a/desktop/src-tauri/src/deep_link.rs +++ b/desktop/src-tauri/src/deep_link.rs @@ -291,7 +291,15 @@ fn parse_nostr_bind_deep_link(url: &Url) -> Result) -> bool { + scheme == "buzz" || build_scheme.is_some_and(|configured| configured == scheme) +} + +fn is_supported_deep_link_scheme(scheme: &str) -> bool { + deep_link_scheme_is_supported(scheme, option_env!("BUZZ_DESKTOP_BUILD_DEEP_LINK_SCHEME")) +} + +/// Handle an incoming Buzz deep link URL. /// /// Currently supports: /// - `buzz://connect?relay=` — emits `deep-link-connect` to the frontend @@ -304,7 +312,7 @@ pub(crate) fn handle_deep_link_url(app: &tauri::AppHandle, url_str: &str) { } }; - if url.scheme() != "buzz" { + if !is_supported_deep_link_scheme(url.scheme()) { eprintln!("buzz-desktop: ignoring unsupported deep link scheme: {url_str}"); return; } @@ -389,10 +397,24 @@ mod tests { use url::Url; use super::{ - parse_add_community_deep_link, parse_join_deep_link, parse_message_deep_link, - parse_nostr_bind_deep_link, PendingCommunityDeepLink, PendingCommunityDeepLinks, + deep_link_scheme_is_supported, parse_add_community_deep_link, parse_join_deep_link, + parse_message_deep_link, parse_nostr_bind_deep_link, PendingCommunityDeepLink, + PendingCommunityDeepLinks, }; + #[test] + fn deep_link_scheme_supports_upstream_and_configured_builds() { + assert!(deep_link_scheme_is_supported("buzz", None)); + assert!(deep_link_scheme_is_supported( + "buzz-codex-lab", + Some("buzz-codex-lab") + )); + assert!(!deep_link_scheme_is_supported( + "buzz-other", + Some("buzz-codex-lab") + )); + } + fn pending(id: &str, relay_url: &str, code: Option<&str>) -> PendingCommunityDeepLink { PendingCommunityDeepLink { id: id.to_owned(), diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index 7aa954ce8e..ae377af9ae 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -40,7 +40,7 @@ mod templates; mod terminal_runtime; #[cfg_attr(not(test), allow(dead_code))] mod terminal_transport; -#[cfg(target_os = "macos")] +#[cfg(any(target_os = "macos", target_os = "windows"))] mod tray_menu; mod util; #[cfg(target_os = "linux")] @@ -81,7 +81,7 @@ use std::sync::{atomic::AtomicBool, atomic::Ordering, Arc}; use tauri::Listener; use tauri::{Emitter, Manager, RunEvent, WindowEvent}; use tauri_plugin_window_state::StateFlags; -#[cfg(target_os = "macos")] +#[cfg(any(target_os = "macos", target_os = "windows"))] use tray_menu::show_main_window; #[cfg_attr(mobile, tauri::mobile_entry_point)] pub fn run() { @@ -89,7 +89,6 @@ pub fn run() { // tokio's default 2 MiB worker stacks — a stack-guard SIGABRT, not a // panic. Upstream mesh-llm and mesh-console both run on 8 MiB worker // stacks for this reason; give Tauri's command runtime the same headroom - // before anything else touches tauri::async_runtime. #[cfg(feature = "mesh-llm")] match tokio::runtime::Builder::new_multi_thread() .enable_all() @@ -114,9 +113,12 @@ pub fn run() { } let builder = tauri::Builder::default() .plugin(tauri_plugin_single_instance::init(|app, argv, _cwd| { - // Focus the existing window when a duplicate instance launches. - if let Some(w) = app.get_webview_window("main") { - let _ = w.set_focus(); + // Restore the existing window when a duplicate instance launches. + #[cfg(any(target_os = "macos", target_os = "windows"))] + show_main_window(app); + #[cfg(not(any(target_os = "macos", target_os = "windows")))] + if let Some(window) = app.get_webview_window("main") { + let _ = window.set_focus(); } // Forward any deep link URLs from the duplicate launch. for arg in &argv { @@ -309,9 +311,10 @@ pub fn run() { .manage(terminal_runtime::TerminalSessions::default()) .setup(move |app| { let app_handle = app.handle().clone(); + #[cfg(any(target_os = "macos", target_os = "windows"))] + tray_menu::init(&app_handle)?; #[cfg(target_os = "macos")] { - tray_menu::init(&app_handle)?; macos_notifications::init(&app_handle)?; } @@ -454,6 +457,7 @@ pub fn run() { eprintln!("buzz-desktop: failed to create nest: {error}"); } + tauri::async_runtime::spawn(managed_agents::restore_codex_runtime(app_handle.clone())); // Resolve the REPOS symlink from the persisted repos_dir BEFORE // agents are restored below, and decide whether restore is safe. // The frontend's apply_workspace runs only after React mounts — @@ -684,6 +688,7 @@ pub fn run() { sign_nostr_identity_binding, sign_out, decrypt_observer_event, + read_agent_preview_image, build_observer_control_event, create_auth_event, nip44_encrypt_to_self, @@ -710,6 +715,7 @@ pub fn run() { set_canvas, get_feed, search_messages, + append_message_send_diagnostic, send_channel_message, send_managed_agent_channel_message, has_managed_agent_channel_message_marker, @@ -757,6 +763,15 @@ pub fn run() { resolve_oa_owner, list_relay_agents, list_managed_agents, + list_codex_tasks, + get_codex_task_history, + send_agent_handoff, + list_agent_handoffs, + get_agent_handoff, + get_codex_shared_runtime_status, + enable_codex_shared_runtime, + launch_codex_desktop_shared, + take_over_codex_desktop_shared, list_managed_agent_runtimes, start_managed_agent_runtime, stop_managed_agent_runtime, @@ -882,6 +897,7 @@ pub fn run() { start_identity_recovery_pairing, confirm_pairing_sas, cancel_pairing, + get_relay_lan_ws_url, apply_workspace, validate_repos_dir, get_active_workspace, @@ -906,13 +922,13 @@ pub fn run() { archive::get_agent_usage_series, is_auto_update_supported, set_window_vibrancy, - #[cfg(target_os = "macos")] + #[cfg(any(target_os = "macos", target_os = "windows"))] tray_menu::clear_tray_agent_activity, - #[cfg(target_os = "macos")] + #[cfg(any(target_os = "macos", target_os = "windows"))] tray_menu::requeue_tray_actions, - #[cfg(target_os = "macos")] + #[cfg(any(target_os = "macos", target_os = "windows"))] tray_menu::take_tray_actions, - #[cfg(target_os = "macos")] + #[cfg(any(target_os = "macos", target_os = "windows"))] tray_menu::update_tray_agent_activity, ]) .build(tauri::generate_context!()) @@ -927,7 +943,7 @@ pub fn run() { app.run(move |app_handle, event| match event { #[cfg(target_os = "macos")] RunEvent::Reopen { .. } => show_main_window(app_handle), - #[cfg(target_os = "macos")] + #[cfg(any(target_os = "macos", target_os = "windows"))] RunEvent::WindowEvent { label, event: WindowEvent::CloseRequested { api, .. }, diff --git a/desktop/src-tauri/src/managed_agents/agent_events.rs b/desktop/src-tauri/src/managed_agents/agent_events.rs index 416b0c76c9..87f3bcced4 100644 --- a/desktop/src-tauri/src/managed_agents/agent_events.rs +++ b/desktop/src-tauri/src/managed_agents/agent_events.rs @@ -106,6 +106,58 @@ pub fn agent_event_content(record: &ManagedAgentRecord) -> ManagedAgentEventCont } } +/// Build the record projection that is safe to advertise in the community +/// agent directory. A setup-mode agent cannot execute instructions, so +/// publishing its stored `anyone`/allowlist policy makes other clients offer +/// an @mention target that will never answer. Keep the local policy intact for +/// when configuration becomes ready, but advertise owner-only until then. +pub(crate) fn directory_record_for_readiness( + record: &ManagedAgentRecord, + ready: bool, +) -> ManagedAgentRecord { + let mut projected = record.clone(); + (projected.respond_to, projected.respond_to_allowlist) = directory_access_for_readiness( + record.respond_to, + record.respond_to_allowlist.clone(), + ready, + ); + projected +} + +fn directory_access_for_readiness( + mode: RespondTo, + allowlist: Vec, + ready: bool, +) -> (RespondTo, Vec) { + if ready { + (mode, allowlist) + } else { + (RespondTo::OwnerOnly, Vec::new()) + } +} + +#[cfg(test)] +mod readiness_projection_tests { + use super::*; + + #[test] + fn not_ready_agent_is_advertised_owner_only_without_mutating_local_policy() { + let local_allowlist = vec!["a".repeat(64)]; + let (mode, allowlist) = + directory_access_for_readiness(RespondTo::Anyone, local_allowlist.clone(), false); + + assert_eq!(mode, RespondTo::OwnerOnly); + assert!(allowlist.is_empty()); + assert_eq!(local_allowlist, vec!["a".repeat(64)]); + } + + #[test] + fn ready_agent_keeps_public_access_policy() { + let (mode, _) = directory_access_for_readiness(RespondTo::Anyone, Vec::new(), true); + assert_eq!(mode, RespondTo::Anyone); + } +} + /// Build a kind:30177 event from a `ManagedAgentRecord`. /// /// Returns an unsigned `EventBuilder` — the caller signs and submits. The diff --git a/desktop/src-tauri/src/managed_agents/codex_desktop.rs b/desktop/src-tauri/src/managed_agents/codex_desktop.rs new file mode 100644 index 0000000000..4cc021b91d --- /dev/null +++ b/desktop/src-tauri/src/managed_agents/codex_desktop.rs @@ -0,0 +1,1310 @@ +use std::{ + collections::HashSet, + fs, + io::{Read, Seek, SeekFrom}, + path::{Path, PathBuf}, + process::Command, + sync::OnceLock, + time::Duration, +}; + +use futures_util::{SinkExt, StreamExt}; +use serde::{Deserialize, Serialize}; +use tauri::AppHandle; +use tokio_tungstenite::{connect_async, tungstenite::Message}; + +use super::codex_tasks::codex_shared_app_server_url; +use super::{atomic_write_json_restricted, managed_agents_base_dir}; + +const SHARED_RUNTIME_CONFIG_VERSION: u32 = 1; +const SHARED_RUNTIME_COMMAND_ENV: &str = "BUZZ_CODEX_APP_SERVER_COMMAND"; +const SHARED_RUNTIME_ERROR_TAIL_BYTES: u64 = 4096; +const CODEX_CODE_MODE_HOST_FLAG: &str = "features.code_mode_host=true"; +#[cfg(windows)] +const WINDOWS_CODEX_SHARED_RUNTIME_LAUNCHER_SCRIPT: &str = r#" +param([Parameter(Mandatory=$true)][string]$ConfigPath) +$ErrorActionPreference='Stop' +$config=Get-Content -LiteralPath $ConfigPath -Raw -Encoding UTF8 | ConvertFrom-Json +try { + Start-Process ` + -FilePath ([string]$config.executable) ` + -WorkingDirectory ([string]$config.working_directory) ` + -ArgumentList @('-c','features.code_mode_host=true','app-server','--listen',([string]$config.url)) ` + -WindowStyle Hidden ` + -RedirectStandardOutput ([string]$config.stdout_log) ` + -RedirectStandardError ([string]$config.stderr_log) | Out-Null +} catch { + $message='buzz shared runtime launcher failed: ' + $_.Exception.Message + [Environment]::NewLine + [IO.File]::AppendAllText([string]$config.stderr_log,$message,[Text.Encoding]::UTF8) + exit 1 +} +"#; +#[cfg(windows)] +const WINDOWS_CODEX_SHARED_RUNTIME_WMI_SCRIPT: &str = r#" +$ErrorActionPreference='Stop' +$startup=New-CimInstance -ClassName Win32_ProcessStartup -ClientOnly -Property @{ShowWindow=[uint16]0} +$result=Invoke-CimMethod -ClassName Win32_Process -MethodName Create -Arguments @{ + CommandLine=$env:BUZZ_CODEX_SHARED_RUNTIME_COMMAND_LINE + ProcessStartupInformation=$startup +} +if ($result.ReturnValue -ne 0) { + throw "Win32_Process.Create returned $($result.ReturnValue)" +} +$result.ProcessId +"#; +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +struct CodexSharedRuntimeConfig { + version: u32, + enabled: bool, +} + +impl Default for CodexSharedRuntimeConfig { + fn default() -> Self { + Self { + version: SHARED_RUNTIME_CONFIG_VERSION, + enabled: false, + } + } +} + +#[derive(Debug, Clone, Serialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum CodexSharedRuntimeState { + SetupRequired, + Ready, + Unavailable, +} + +#[derive(Debug, Clone, Serialize, PartialEq, Eq)] +pub struct CodexSharedRuntimeStatus { + pub enabled: bool, + pub state: CodexSharedRuntimeState, + pub url: String, + pub detail: Option, + pub desktop_process_ids: Vec, + pub private_app_server_process_ids: Vec, + pub desktop_detection_error: Option, +} + +#[derive(Debug, Clone, Deserialize, PartialEq, Eq)] +struct WindowsProcessInfo { + process_id: u32, + parent_process_id: u32, + executable_path: String, + command_line: String, +} + +#[derive(Debug, Default, Deserialize, PartialEq, Eq)] +struct WindowsProcessSnapshot { + #[serde(default)] + desktop_executable_paths: Vec, + #[serde(default)] + private_app_server_executable_paths: Vec, + #[serde(default)] + processes: Vec, +} + +#[derive(Debug, Default, Clone, PartialEq, Eq)] +struct CodexDesktopProcessSnapshot { + desktop_processes: Vec, + private_app_server_processes: Vec, +} + +const WINDOWS_CODEX_PROCESS_SNAPSHOT_SCRIPT: &str = r#" +$ErrorActionPreference='Stop' +$desktopPaths=@() +$backendPaths=@() +$packages=@(Get-AppxPackage | Where-Object { $_.Name -in @('OpenAI.Codex','OpenAI.CodexBeta') }) +foreach ($package in $packages) { + $manifest=Get-AppxPackageManifest -Package $package + foreach ($application in @($manifest.Package.Applications.Application)) { + $relative=[string]$application.Executable + if (-not [string]::IsNullOrWhiteSpace($relative)) { + $desktopPaths += [IO.Path]::GetFullPath((Join-Path $package.InstallLocation $relative)) + } + } + $backendPaths += [IO.Path]::GetFullPath((Join-Path $package.InstallLocation 'app\resources\codex.exe')) +} +$processes=@(Get-CimInstance Win32_Process | ForEach-Object { + [pscustomobject]@{ + process_id=[uint32]$_.ProcessId + parent_process_id=[uint32]$_.ParentProcessId + executable_path=if ($_.ExecutablePath) { [string]$_.ExecutablePath } else { '' } + command_line=if ($_.CommandLine) { [string]$_.CommandLine } else { '' } + } +}) +[pscustomobject]@{ + desktop_executable_paths=@($desktopPaths) + private_app_server_executable_paths=@($backendPaths) + processes=@($processes) +} | ConvertTo-Json -Depth 4 -Compress +"#; + +const WINDOWS_TERMINATE_VERIFIED_PROCESS_SCRIPT: &str = r#" +$ErrorActionPreference='Stop' +$pidValue=[uint32]$env:BUZZ_CODEX_TARGET_PID +$expected=[IO.Path]::GetFullPath($env:BUZZ_CODEX_TARGET_EXE).TrimEnd('\').ToLowerInvariant() +$process=Get-CimInstance Win32_Process -Filter "ProcessId = $pidValue" +if (-not $process) { exit 0 } +$actual=if ($process.ExecutablePath) { [IO.Path]::GetFullPath([string]$process.ExecutablePath).TrimEnd('\').ToLowerInvariant() } else { '' } +if ($actual -ne $expected) { throw "PID $pidValue no longer matches the verified Codex package path" } +$arguments=@('/PID',[string]$pidValue,'/F') +if ($env:BUZZ_CODEX_TARGET_TREE -eq '1') { $arguments += '/T' } +& taskkill.exe @arguments | Out-Null +if ($LASTEXITCODE -ne 0) { throw "taskkill exited with $LASTEXITCODE" } +"#; + +fn shared_runtime_config_path(app: &AppHandle) -> Result { + Ok(managed_agents_base_dir(app)?.join("codex-shared-runtime.json")) +} + +fn load_shared_runtime_config(app: &AppHandle) -> Result { + let path = shared_runtime_config_path(app)?; + if !path.exists() { + return Ok(CodexSharedRuntimeConfig::default()); + } + let bytes = + fs::read(&path).map_err(|error| format!("failed to read {}: {error}", path.display()))?; + serde_json::from_slice(&bytes) + .map_err(|error| format!("failed to parse {}: {error}", path.display())) +} + +fn save_shared_runtime_config( + app: &AppHandle, + config: &CodexSharedRuntimeConfig, +) -> Result<(), String> { + let path = shared_runtime_config_path(app)?; + let payload = serde_json::to_vec_pretty(config) + .map_err(|error| format!("failed to serialize Codex shared runtime: {error}"))?; + atomic_write_json_restricted(&path, &payload) +} + +fn normalize_windows_executable_path(path: &str) -> String { + path.trim() + .trim_end_matches(['\\', '/']) + .replace('/', "\\") + .to_ascii_lowercase() +} + +fn command_has_argument(command_line: &str, expected: &str) -> bool { + command_line + .split_whitespace() + .map(|argument| argument.trim_matches('"')) + .any(|argument| argument.eq_ignore_ascii_case(expected)) +} + +fn command_listens_on(command_line: &str, expected_url: &str) -> bool { + let mut arguments = command_line + .split_whitespace() + .map(|argument| argument.trim_matches('"')); + while let Some(argument) = arguments.next() { + if argument.eq_ignore_ascii_case("--listen") { + return arguments + .next() + .is_some_and(|url| url.eq_ignore_ascii_case(expected_url)); + } + if let Some(url) = argument.strip_prefix("--listen=") { + return url.eq_ignore_ascii_case(expected_url); + } + } + false +} + +fn classify_windows_process_snapshot( + snapshot: WindowsProcessSnapshot, + shared_url: &str, +) -> CodexDesktopProcessSnapshot { + let desktop_paths = snapshot + .desktop_executable_paths + .iter() + .map(|path| normalize_windows_executable_path(path)) + .collect::>(); + let backend_paths = snapshot + .private_app_server_executable_paths + .iter() + .map(|path| normalize_windows_executable_path(path)) + .collect::>(); + + let mut classified = CodexDesktopProcessSnapshot::default(); + for process in snapshot.processes { + let path = normalize_windows_executable_path(&process.executable_path); + if desktop_paths.contains(&path) { + classified.desktop_processes.push(process.clone()); + } + if backend_paths.contains(&path) + && command_has_argument(&process.command_line, "app-server") + && !command_listens_on(&process.command_line, shared_url) + { + classified.private_app_server_processes.push(process); + } + } + classified + .desktop_processes + .sort_by_key(|process| process.process_id); + classified + .private_app_server_processes + .sort_by_key(|process| process.process_id); + classified +} + +fn parse_windows_process_snapshot( + output: &str, + shared_url: &str, +) -> Result { + let raw = serde_json::from_str::(output.trim()) + .map_err(|error| format!("failed to parse the Codex Desktop process snapshot: {error}"))?; + Ok(classify_windows_process_snapshot(raw, shared_url)) +} + +fn desktop_process_tree_roots(snapshot: &CodexDesktopProcessSnapshot) -> Vec { + let desktop_ids = snapshot + .desktop_processes + .iter() + .map(|process| process.process_id) + .collect::>(); + snapshot + .desktop_processes + .iter() + .filter(|process| !desktop_ids.contains(&process.parent_process_id)) + .cloned() + .collect() +} + +fn ensure_ordinary_desktop_launch_allowed( + snapshot: &CodexDesktopProcessSnapshot, +) -> Result<(), String> { + if snapshot.private_app_server_processes.is_empty() { + return Ok(()); + } + Err( + "Codex Desktop is still running outside the shared runtime. Use Take over Codex Desktop to review the interruption warning and reconnect it safely." + .to_string(), + ) +} + +fn require_takeover_confirmation(confirmed: bool) -> Result<(), String> { + if confirmed { + Ok(()) + } else { + Err("Codex Desktop takeover requires explicit confirmation".to_string()) + } +} + +fn ensure_post_launch_snapshot(snapshot: &CodexDesktopProcessSnapshot) -> Result<(), String> { + if snapshot.private_app_server_processes.is_empty() { + Ok(()) + } else { + Err( + "Codex Desktop started another private app-server. It was closed to protect the shared task runtime; fully quit Desktop and try again." + .to_string(), + ) + } +} + +#[cfg(windows)] +fn snapshot_codex_desktop_processes( + shared_url: &str, +) -> Result { + use std::os::windows::process::CommandExt; + + let output = Command::new("powershell.exe") + .args([ + "-NoProfile", + "-NonInteractive", + "-Command", + WINDOWS_CODEX_PROCESS_SNAPSHOT_SCRIPT, + ]) + .creation_flags(0x0800_0000) + .output() + .map_err(|error| format!("failed to inspect Codex Desktop processes: {error}"))?; + if !output.status.success() { + let detail = String::from_utf8_lossy(&output.stderr).trim().to_string(); + return Err(if detail.is_empty() { + "Windows could not inspect Codex Desktop processes".to_string() + } else { + detail + }); + } + parse_windows_process_snapshot(&String::from_utf8_lossy(&output.stdout), shared_url) +} + +#[cfg(not(windows))] +fn snapshot_codex_desktop_processes( + _shared_url: &str, +) -> Result { + Ok(CodexDesktopProcessSnapshot::default()) +} + +async fn snapshot_codex_desktop_processes_async( + shared_url: &str, +) -> Result { + let shared_url = shared_url.to_string(); + tokio::task::spawn_blocking(move || snapshot_codex_desktop_processes(&shared_url)) + .await + .map_err(|error| format!("Codex Desktop process inspection failed: {error}"))? +} + +async fn attach_desktop_process_status( + mut status: CodexSharedRuntimeStatus, +) -> CodexSharedRuntimeStatus { + match snapshot_codex_desktop_processes_async(&status.url).await { + Ok(snapshot) => { + status.desktop_process_ids = snapshot + .desktop_processes + .iter() + .map(|process| process.process_id) + .collect(); + status.private_app_server_process_ids = snapshot + .private_app_server_processes + .iter() + .map(|process| process.process_id) + .collect(); + } + Err(error) => status.desktop_detection_error = Some(error), + } + status +} + +async fn probe_codex_shared_runtime(url: &str) -> Result<(), String> { + let (mut socket, _) = tokio::time::timeout(Duration::from_secs(2), connect_async(url)) + .await + .map_err(|_| format!("timed out connecting to {url}"))? + .map_err(|error| format!("could not connect to {url}: {error}"))?; + let initialize = serde_json::json!({ + "id": 1, + "method": "initialize", + "params": { + "clientInfo": { + "name": "buzz_shared_runtime_probe", + "title": "Buzz shared runtime probe", + "version": env!("CARGO_PKG_VERSION") + }, + "capabilities": { "experimentalApi": true } + } + }); + socket + .send(Message::Text(initialize.to_string().into())) + .await + .map_err(|error| format!("failed to initialize {url}: {error}"))?; + + let initialized = tokio::time::timeout(Duration::from_secs(2), async { + while let Some(message) = socket.next().await { + let message = message.map_err(|error| error.to_string())?; + let Message::Text(text) = message else { + continue; + }; + let payload: serde_json::Value = + serde_json::from_str(text.as_str()).map_err(|error| error.to_string())?; + if payload.get("id").and_then(serde_json::Value::as_u64) == Some(1) { + if let Some(error) = payload.get("error") { + return Err(format!("initialize was rejected: {error}")); + } + return payload + .get("result") + .is_some() + .then_some(()) + .ok_or_else(|| "initialize response had no result".to_string()); + } + } + Err("connection closed before initialize completed".to_string()) + }) + .await + .map_err(|_| format!("timed out initializing {url}"))??; + let _ = socket.close(None).await; + Ok(initialized) +} + +fn read_shared_runtime_log_tail(path: &Path) -> Option { + let mut file = fs::File::open(path).ok()?; + let length = file.metadata().ok()?.len(); + let start = length.saturating_sub(SHARED_RUNTIME_ERROR_TAIL_BYTES); + file.seek(SeekFrom::Start(start)).ok()?; + let mut bytes = Vec::new(); + file.read_to_end(&mut bytes).ok()?; + if start > 0 { + if let Some(first_newline) = bytes.iter().position(|byte| *byte == b'\n') { + bytes.drain(..=first_newline); + } + } + let tail = String::from_utf8_lossy(&bytes).trim().to_string(); + (!tail.is_empty()).then_some(tail) +} + +fn append_shared_runtime_log_detail(error: String, log_path: &Path) -> String { + let Some(tail) = read_shared_runtime_log_tail(log_path) else { + return error; + }; + format!( + "{error}\n\nCodex runtime log ({}):\n{tail}", + log_path.display() + ) +} + +fn shared_runtime_failure_detail(app: &AppHandle, error: String) -> String { + let Ok(base_dir) = managed_agents_base_dir(app) else { + return error; + }; + append_shared_runtime_log_detail( + error, + &base_dir + .join("logs") + .join("codex-shared-runtime.stderr.log"), + ) +} + +pub async fn codex_shared_runtime_status( + app: &AppHandle, +) -> Result { + let config = load_shared_runtime_config(app)?; + let url = codex_shared_app_server_url()?; + if !config.enabled { + return Ok(attach_desktop_process_status(CodexSharedRuntimeStatus { + enabled: false, + state: CodexSharedRuntimeState::SetupRequired, + url, + detail: None, + desktop_process_ids: Vec::new(), + private_app_server_process_ids: Vec::new(), + desktop_detection_error: None, + }) + .await); + } + let status = match probe_codex_shared_runtime(&url).await { + Ok(()) => CodexSharedRuntimeStatus { + enabled: true, + state: CodexSharedRuntimeState::Ready, + url, + detail: None, + desktop_process_ids: Vec::new(), + private_app_server_process_ids: Vec::new(), + desktop_detection_error: None, + }, + Err(error) => CodexSharedRuntimeStatus { + enabled: true, + state: CodexSharedRuntimeState::Unavailable, + url, + detail: Some(shared_runtime_failure_detail(app, error)), + desktop_process_ids: Vec::new(), + private_app_server_process_ids: Vec::new(), + desktop_detection_error: None, + }, + }; + Ok(attach_desktop_process_status(status).await) +} + +#[cfg(windows)] +fn is_usable_codex_app_server_executable(path: &Path) -> bool { + path.is_file() + && path + .parent() + .map(|parent| parent.join("codex-code-mode-host.exe").is_file()) + .unwrap_or(false) +} + +#[cfg(not(windows))] +fn is_usable_codex_app_server_executable(path: &Path) -> bool { + path.is_file() + && path + .parent() + .map(|parent| parent.join("codex-code-mode-host").is_file()) + .unwrap_or(false) +} + +#[cfg(target_os = "macos")] +fn macos_codex_app_server_candidates() -> Vec { + let mut candidates = Vec::new(); + candidates.push(PathBuf::from( + "/Applications/ChatGPT.app/Contents/Resources/codex", + )); + candidates.push(PathBuf::from( + "/Applications/Codex.app/Contents/Resources/codex", + )); + if let Some(home) = dirs::home_dir() { + candidates.push( + home.join("Applications") + .join("ChatGPT.app") + .join("Contents") + .join("Resources") + .join("codex"), + ); + candidates.push( + home.join("Applications") + .join("Codex.app") + .join("Contents") + .join("Resources") + .join("codex"), + ); + candidates.push(home.join(".cargo").join("bin").join("codex")); + candidates.push(home.join(".local").join("bin").join("codex")); + candidates.push(home.join(".codex").join("bin").join("codex")); + } + candidates.push(PathBuf::from("/opt/homebrew/bin/codex")); + candidates.push(PathBuf::from("/usr/local/bin/codex")); + candidates +} + +fn path_codex_app_server_candidates(executable: &str) -> Vec { + std::env::var_os("PATH") + .map(|path| { + std::env::split_paths(&path) + .map(|directory| directory.join(executable)) + .collect() + }) + .unwrap_or_default() +} + +#[cfg(windows)] +fn windows_appx_codex_candidates() -> Vec { + // Codex installed from the Microsoft Store keeps its runtime under the + // package install directory rather than %LOCALAPPDATA%\\OpenAI\\Codex\\bin. + // Query AppX instead of guessing the versioned WindowsApps directory. + let script = r#" +$ErrorActionPreference='SilentlyContinue' +Get-AppxPackage -Name OpenAI.Codex,OpenAI.CodexBeta | + ForEach-Object { Join-Path $_.InstallLocation 'app\resources\codex.exe' } +"#; + Command::new("powershell.exe") + .args([ + "-NoLogo", + "-NoProfile", + "-NonInteractive", + "-Command", + script, + ]) + .output() + .ok() + .map(|output| { + String::from_utf8_lossy(&output.stdout) + .lines() + .map(str::trim) + .filter(|path| !path.is_empty()) + .map(PathBuf::from) + .collect() + }) + .unwrap_or_default() +} + +fn codex_app_server_candidates(executable: &str) -> Vec { + let mut seen = HashSet::new(); + let mut candidates = Vec::new(); + + #[cfg(windows)] + candidates.extend(windows_appx_codex_candidates()); + + #[cfg(target_os = "macos")] + candidates.extend(macos_codex_app_server_candidates()); + + candidates.extend(path_codex_app_server_candidates(executable)); + candidates + .into_iter() + .filter(|path| seen.insert(path.clone())) + .collect() +} + +fn find_codex_app_server_executable() -> Result { + if let Some(path) = std::env::var_os(SHARED_RUNTIME_COMMAND_ENV) { + let path = PathBuf::from(path); + if is_usable_codex_app_server_executable(&path) { + return Ok(path); + } + return Err(format!( + "{SHARED_RUNTIME_COMMAND_ENV} does not point to a complete Codex runtime: {}", + path.display() + )); + } + + #[cfg(windows)] + { + // Codex Desktop materializes an executable runtime bundle here. Requiring + // the matching sidecar avoids selecting a partial update while it is + // still being installed. + if let Some(local_data) = dirs::data_local_dir() { + let bin_dir = local_data.join("OpenAI").join("Codex").join("bin"); + let mut candidates = fs::read_dir(&bin_dir) + .ok() + .into_iter() + .flatten() + .filter_map(Result::ok) + .map(|entry| entry.path().join("codex.exe")) + .filter(|path| is_usable_codex_app_server_executable(path)) + .collect::>(); + candidates.sort_by_key(|path| { + path.metadata() + .and_then(|metadata| metadata.modified()) + .ok() + }); + if let Some(path) = candidates.pop() { + return Ok(path); + } + } + } + + let executable = if cfg!(windows) { "codex.exe" } else { "codex" }; + if let Some(path) = codex_app_server_candidates(executable) + .into_iter() + .find(|candidate| is_usable_codex_app_server_executable(candidate)) + { + return Ok(path); + } + + Err( + "A complete Codex runtime was not found. Open Codex Desktop normally once to finish runtime setup, then retry." + .to_string(), + ) +} + +fn codex_shared_runtime_args(url: &str) -> Vec { + vec![ + "-c".to_string(), + CODEX_CODE_MODE_HOST_FLAG.to_string(), + "app-server".to_string(), + "--listen".to_string(), + url.to_string(), + ] +} + +fn spawn_codex_shared_runtime(app: &AppHandle, url: &str) -> Result<(), String> { + let executable = find_codex_app_server_executable()?; + + #[cfg(windows)] + { + use std::os::windows::process::CommandExt; + + let base_dir = managed_agents_base_dir(app)?; + let logs_dir = base_dir.join("logs"); + fs::create_dir_all(&logs_dir) + .map_err(|error| format!("failed to create {}: {error}", logs_dir.display()))?; + let launcher_path = base_dir.join("codex-shared-runtime-launcher.ps1"); + fs::write(&launcher_path, WINDOWS_CODEX_SHARED_RUNTIME_LAUNCHER_SCRIPT) + .map_err(|error| format!("failed to write {}: {error}", launcher_path.display()))?; + let stdout_log = logs_dir.join("codex-shared-runtime.stdout.log"); + let stderr_log = logs_dir.join("codex-shared-runtime.stderr.log"); + fs::write(&stdout_log, []) + .map_err(|error| format!("failed to reset {}: {error}", stdout_log.display()))?; + fs::write(&stderr_log, []) + .map_err(|error| format!("failed to reset {}: {error}", stderr_log.display()))?; + let launcher_config_path = base_dir.join("codex-shared-runtime-launcher.json"); + let working_directory = executable + .parent() + .map(Path::to_path_buf) + .unwrap_or_else(|| PathBuf::from(".")); + let launcher_config = serde_json::to_vec_pretty(&serde_json::json!({ + "executable": executable, + "working_directory": working_directory, + "url": url, + "stdout_log": stdout_log, + "stderr_log": stderr_log, + })) + .map_err(|error| format!("failed to serialize Codex runtime launcher: {error}"))?; + fs::write(&launcher_config_path, launcher_config).map_err(|error| { + format!( + "failed to write {}: {error}", + launcher_config_path.display() + ) + })?; + + // WMI owns the transient launcher, so the shared backend survives Buzz + // updates. Both the launcher and Codex run without console windows. + let command_line = format!( + "powershell.exe -NoLogo -NoProfile -NonInteractive -ExecutionPolicy Bypass -WindowStyle Hidden -File \"{}\" -ConfigPath \"{}\"", + launcher_path.display(), + launcher_config_path.display() + ); + let output = Command::new("powershell.exe") + .args([ + "-NoLogo", + "-NoProfile", + "-NonInteractive", + "-Command", + WINDOWS_CODEX_SHARED_RUNTIME_WMI_SCRIPT, + ]) + .env("BUZZ_CODEX_SHARED_RUNTIME_COMMAND_LINE", command_line) + .creation_flags(0x0800_0000) + .output() + .map_err(|error| format!("failed to request Codex runtime start: {error}"))?; + if !output.status.success() { + let detail = String::from_utf8_lossy(&output.stderr).trim().to_string(); + return Err(if detail.is_empty() { + "Windows could not start the Codex shared runtime".to_string() + } else { + detail + }); + } + Ok(()) + } + + #[cfg(not(windows))] + { + use std::process::Stdio; + + let logs_dir = managed_agents_base_dir(app)?.join("logs"); + fs::create_dir_all(&logs_dir) + .map_err(|error| format!("failed to create {}: {error}", logs_dir.display()))?; + let stdout = fs::OpenOptions::new() + .create(true) + .append(true) + .open(logs_dir.join("codex-shared-runtime.stdout.log")) + .map_err(|error| format!("failed to open Codex runtime log: {error}"))?; + let stderr = fs::OpenOptions::new() + .create(true) + .append(true) + .open(logs_dir.join("codex-shared-runtime.stderr.log")) + .map_err(|error| format!("failed to open Codex runtime error log: {error}"))?; + let mut command = Command::new(&executable); + command + .args(codex_shared_runtime_args(url)) + .stdin(Stdio::null()) + .stdout(Stdio::from(stdout)) + .stderr(Stdio::from(stderr)); + command + .spawn() + .map_err(|error| format!("failed to start {}: {error}", executable.display()))?; + Ok(()) + } +} + +pub async fn enable_codex_shared_runtime( + app: &AppHandle, +) -> Result { + static START_LOCK: OnceLock> = OnceLock::new(); + let _guard = START_LOCK + .get_or_init(|| tokio::sync::Mutex::new(())) + .lock() + .await; + save_shared_runtime_config( + app, + &CodexSharedRuntimeConfig { + version: SHARED_RUNTIME_CONFIG_VERSION, + enabled: true, + }, + )?; + let url = codex_shared_app_server_url()?; + if probe_codex_shared_runtime(&url).await.is_err() { + spawn_codex_shared_runtime(app, &url)?; + let mut last_error = None; + for _ in 0..50 { + match probe_codex_shared_runtime(&url).await { + Ok(()) => { + last_error = None; + break; + } + Err(error) => last_error = Some(error), + } + tokio::time::sleep(Duration::from_millis(200)).await; + } + if let Some(error) = last_error { + return Ok(attach_desktop_process_status(CodexSharedRuntimeStatus { + enabled: true, + state: CodexSharedRuntimeState::Unavailable, + url, + detail: Some(shared_runtime_failure_detail(app, error)), + desktop_process_ids: Vec::new(), + private_app_server_process_ids: Vec::new(), + desktop_detection_error: None, + }) + .await); + } + } + Ok(attach_desktop_process_status(CodexSharedRuntimeStatus { + enabled: true, + state: CodexSharedRuntimeState::Ready, + url, + detail: None, + desktop_process_ids: Vec::new(), + private_app_server_process_ids: Vec::new(), + desktop_detection_error: None, + }) + .await) +} + +pub async fn restore_codex_runtime(app: AppHandle) { + if load_shared_runtime_config(&app) + .map(|config| config.enabled) + .unwrap_or(false) + { + // Codex Desktop may still be updating/materializing its runtime bundle + // during Buzz startup. Retry briefly so a transient missing executable + // does not permanently leave the shared runtime unavailable until the + // user manually opens the setup panel. + let mut last_error = None; + for attempt in 0..15 { + match enable_codex_shared_runtime(&app).await { + Ok(status) if status.state == CodexSharedRuntimeState::Ready => return, + Ok(status) => { + last_error = status.detail; + } + Err(error) => last_error = Some(error), + } + if attempt < 14 { + tokio::time::sleep(Duration::from_secs(2)).await; + } + } + if let Some(error) = last_error { + eprintln!( + "buzz-desktop: failed to restore Codex shared runtime after retries: {error}" + ); + } + } +} + +#[cfg(windows)] +fn launch_codex_desktop_shared_unchecked(url: &str) -> Result { + use std::os::windows::process::CommandExt; + + const SCRIPT: &str = r#" +$ErrorActionPreference='Stop' +$env:CODEX_APP_SERVER_WS_URL=$env:BUZZ_CODEX_DESKTOP_SHARED_URL +$package=Get-AppxPackage | Where-Object { $_.Name -in @('OpenAI.Codex','OpenAI.CodexBeta') } | Sort-Object @{Expression={if ($_.Name -eq 'OpenAI.Codex') {0} else {1}};Ascending=$true},@{Expression={$_.Version};Descending=$true} | Select-Object -First 1 +if (-not $package) { throw 'Codex Desktop is not installed' } +$application=@((Get-AppxPackageManifest -Package $package).Package.Applications.Application)[0] +$exe=[IO.Path]::GetFullPath((Join-Path $package.InstallLocation ([string]$application.Executable))) +$process=Start-Process -FilePath $exe -PassThru +[pscustomobject]@{ + process_id=[uint32]$process.Id + parent_process_id=0 + executable_path=$exe + command_line='' +} | ConvertTo-Json -Compress +"#; + let output = Command::new("powershell.exe") + .args(["-NoProfile", "-NonInteractive", "-Command", SCRIPT]) + .env("BUZZ_CODEX_DESKTOP_SHARED_URL", url) + .creation_flags(0x0800_0000) + .output() + .map_err(|error| format!("failed to launch Codex Desktop: {error}"))?; + if !output.status.success() { + let detail = String::from_utf8_lossy(&output.stderr).trim().to_string(); + return Err(if detail.is_empty() { + "Windows could not launch Codex Desktop".to_string() + } else { + detail + }); + } + serde_json::from_slice(&output.stdout) + .map_err(|error| format!("could not read the launched Codex Desktop process: {error}")) +} + +#[cfg(windows)] +fn terminate_verified_windows_process( + process: &WindowsProcessInfo, + include_tree: bool, +) -> Result<(), String> { + use std::os::windows::process::CommandExt; + + let output = Command::new("powershell.exe") + .args([ + "-NoProfile", + "-NonInteractive", + "-Command", + WINDOWS_TERMINATE_VERIFIED_PROCESS_SCRIPT, + ]) + .env("BUZZ_CODEX_TARGET_PID", process.process_id.to_string()) + .env("BUZZ_CODEX_TARGET_EXE", &process.executable_path) + .env( + "BUZZ_CODEX_TARGET_TREE", + if include_tree { "1" } else { "0" }, + ) + .creation_flags(0x0800_0000) + .output() + .map_err(|error| format!("failed to close Codex Desktop: {error}"))?; + if output.status.success() { + Ok(()) + } else { + let detail = String::from_utf8_lossy(&output.stderr).trim().to_string(); + Err(if detail.is_empty() { + format!( + "Windows could not close Codex Desktop PID {}", + process.process_id + ) + } else { + detail + }) + } +} + +#[cfg(windows)] +pub fn launch_codex_desktop_shared() -> Result<(), String> { + let url = codex_shared_app_server_url()?; + let snapshot = snapshot_codex_desktop_processes(&url)?; + ensure_ordinary_desktop_launch_allowed(&snapshot)?; + launch_codex_desktop_shared_unchecked(&url).map(|_| ()) +} + +#[cfg(not(windows))] +pub fn launch_codex_desktop_shared() -> Result<(), String> { + Err("Automatic Codex Desktop relaunch is currently available on Windows only.".to_string()) +} + +/// Close a conflicting packaged Codex Desktop runtime and reconnect Desktop to +/// Buzz's long-lived shared app-server after explicit user confirmation. +pub async fn take_over_codex_desktop_shared( + app: &AppHandle, + confirmed: bool, +) -> Result { + require_takeover_confirmation(confirmed)?; + + #[cfg(not(windows))] + { + let _ = app; + return Err( + "Automatic Codex Desktop takeover is currently available on Windows only.".to_string(), + ); + } + + #[cfg(windows)] + { + let url = codex_shared_app_server_url()?; + probe_codex_shared_runtime(&url).await.map_err(|error| { + format!( + "The shared Codex runtime is not ready at {url}: {error}. Start it before taking over Desktop." + ) + })?; + let initial = snapshot_codex_desktop_processes_async(&url).await?; + if initial.private_app_server_processes.is_empty() { + return codex_shared_runtime_status(app).await; + } + + let roots = desktop_process_tree_roots(&initial); + tokio::task::spawn_blocking(move || { + for process in &roots { + terminate_verified_windows_process(process, true)?; + } + Ok::<(), String>(()) + }) + .await + .map_err(|error| format!("Codex Desktop close task failed: {error}"))??; + + let remaining = snapshot_codex_desktop_processes_async(&url).await?; + let orphan_backends = remaining.private_app_server_processes.clone(); + tokio::task::spawn_blocking(move || { + for process in &orphan_backends { + terminate_verified_windows_process(process, false)?; + } + Ok::<(), String>(()) + }) + .await + .map_err(|error| format!("Codex private backend close task failed: {error}"))??; + + let original_target_ids = initial + .desktop_processes + .iter() + .chain(initial.private_app_server_processes.iter()) + .map(|process| process.process_id) + .collect::>(); + let mut targets_still_running = true; + for _ in 0..50 { + let snapshot = snapshot_codex_desktop_processes_async(&url).await?; + targets_still_running = snapshot + .desktop_processes + .iter() + .chain(snapshot.private_app_server_processes.iter()) + .any(|process| original_target_ids.contains(&process.process_id)); + if !targets_still_running { + break; + } + tokio::time::sleep(Duration::from_millis(200)).await; + } + if targets_still_running { + return Err( + "Codex Desktop did not fully exit within 10 seconds. Close it manually, then try again." + .to_string(), + ); + } + + probe_codex_shared_runtime(&url).await.map_err(|error| { + format!( + "The shared Codex runtime was lost while Desktop closed: {error}. Start it again before reconnecting Desktop." + ) + })?; + + let launch_url = url.clone(); + let launched = + tokio::task::spawn_blocking(move || launch_codex_desktop_shared_unchecked(&launch_url)) + .await + .map_err(|error| format!("Codex Desktop launch task failed: {error}"))??; + + let mut stable_desktop_checks = 0u8; + for _ in 0..50 { + let snapshot = match snapshot_codex_desktop_processes_async(&url).await { + Ok(snapshot) => snapshot, + Err(error) => { + let cleanup = launched.clone(); + let _ = tokio::task::spawn_blocking(move || { + terminate_verified_windows_process(&cleanup, true) + }) + .await; + return Err(format!( + "Codex Desktop reopened, but Buzz could not verify its runtime: {error}" + )); + } + }; + if let Err(error) = ensure_post_launch_snapshot(&snapshot) { + let roots = desktop_process_tree_roots(&snapshot); + let private_backends = snapshot.private_app_server_processes.clone(); + let _ = tokio::task::spawn_blocking(move || { + for process in &roots { + let _ = terminate_verified_windows_process(process, true); + } + for process in &private_backends { + let _ = terminate_verified_windows_process(process, false); + } + }) + .await; + return Err(error); + } + if snapshot.desktop_processes.is_empty() { + stable_desktop_checks = 0; + } else { + stable_desktop_checks += 1; + // A private backend is normally spawned shortly after the + // Electron process. Observe three clean seconds before + // claiming that Desktop stayed on the shared runtime. + if stable_desktop_checks >= 15 { + return codex_shared_runtime_status(app).await; + } + } + tokio::time::sleep(Duration::from_millis(200)).await; + } + + let cleanup = launched; + let _ = + tokio::task::spawn_blocking(move || terminate_verified_windows_process(&cleanup, true)) + .await; + Err( + "Codex Desktop did not remain open after reconnecting. Buzz closed the launch attempt; try again after checking the Desktop installation." + .to_string(), + ) + } +} + +#[cfg(test)] +mod tests { + use super::super::codex_tasks::DEFAULT_CODEX_SHARED_APP_SERVER_URL; + use super::*; + + #[cfg(windows)] + #[test] + fn windows_shared_runtime_requires_matching_code_mode_host() { + let dir = tempfile::tempdir().unwrap(); + let codex = dir.path().join("codex.exe"); + fs::write(&codex, []).unwrap(); + + assert!(!is_usable_codex_app_server_executable(&codex)); + + fs::write(dir.path().join("codex-code-mode-host.exe"), []).unwrap(); + assert!(is_usable_codex_app_server_executable(&codex)); + } + + #[cfg(not(windows))] + #[test] + fn unix_shared_runtime_requires_matching_code_mode_host() { + let dir = tempfile::tempdir().unwrap(); + let codex = dir.path().join("codex"); + fs::write(&codex, []).unwrap(); + + assert!(!is_usable_codex_app_server_executable(&codex)); + + fs::write(dir.path().join("codex-code-mode-host"), []).unwrap(); + assert!(is_usable_codex_app_server_executable(&codex)); + } + + #[test] + fn shared_runtime_launch_args_enable_code_mode_host() { + assert_eq!( + codex_shared_runtime_args(DEFAULT_CODEX_SHARED_APP_SERVER_URL), + vec![ + "-c", + CODEX_CODE_MODE_HOST_FLAG, + "app-server", + "--listen", + DEFAULT_CODEX_SHARED_APP_SERVER_URL, + ] + ); + #[cfg(windows)] + assert!(WINDOWS_CODEX_SHARED_RUNTIME_LAUNCHER_SCRIPT.contains(CODEX_CODE_MODE_HOST_FLAG)); + } + + #[cfg(target_os = "macos")] + #[test] + fn macos_candidates_include_desktop_and_homebrew_runtime_locations() { + let candidates = macos_codex_app_server_candidates() + .into_iter() + .map(|path| path.to_string_lossy().into_owned()) + .collect::>(); + + assert!(candidates + .iter() + .any(|path| path == "/Applications/ChatGPT.app/Contents/Resources/codex")); + assert!(candidates + .iter() + .any(|path| path == "/opt/homebrew/bin/codex")); + assert!(candidates.iter().any(|path| path == "/usr/local/bin/codex")); + } + + #[test] + fn parses_zero_one_and_multiple_windows_processes() { + let empty = r#"{ + "desktop_executable_paths": [], + "private_app_server_executable_paths": [], + "processes": [] + }"#; + assert_eq!( + parse_windows_process_snapshot(empty, DEFAULT_CODEX_SHARED_APP_SERVER_URL).unwrap(), + CodexDesktopProcessSnapshot::default() + ); + + let populated = r#"{ + "desktop_executable_paths": ["C:\\Program Files\\WindowsApps\\OpenAI.Codex_1\\app\\ChatGPT.exe"], + "private_app_server_executable_paths": ["C:\\Program Files\\WindowsApps\\OpenAI.Codex_1\\app\\resources\\codex.exe"], + "processes": [ + {"process_id":10,"parent_process_id":1,"executable_path":"C:\\Program Files\\WindowsApps\\OpenAI.Codex_1\\app\\ChatGPT.exe","command_line":"ChatGPT.exe"}, + {"process_id":11,"parent_process_id":10,"executable_path":"C:\\Program Files\\WindowsApps\\OpenAI.Codex_1\\app\\ChatGPT.exe","command_line":"ChatGPT.exe --type=renderer"}, + {"process_id":12,"parent_process_id":10,"executable_path":"C:\\Program Files\\WindowsApps\\OpenAI.Codex_1\\app\\resources\\codex.exe","command_line":"codex.exe app-server"} + ] + }"#; + let snapshot = + parse_windows_process_snapshot(populated, DEFAULT_CODEX_SHARED_APP_SERVER_URL).unwrap(); + assert_eq!( + snapshot + .desktop_processes + .iter() + .map(|process| process.process_id) + .collect::>(), + vec![10, 11] + ); + assert_eq!( + snapshot + .private_app_server_processes + .iter() + .map(|process| process.process_id) + .collect::>(), + vec![12] + ); + assert_eq!( + desktop_process_tree_roots(&snapshot) + .iter() + .map(|process| process.process_id) + .collect::>(), + vec![10] + ); + } + + #[test] + fn distinguishes_local_shared_runtime_from_packaged_private_backend() { + let raw = WindowsProcessSnapshot { + desktop_executable_paths: vec![ + r"C:\Program Files\WindowsApps\OpenAI.Codex_1\app\ChatGPT.exe".to_string(), + ], + private_app_server_executable_paths: vec![ + r"C:\Program Files\WindowsApps\OpenAI.Codex_1\app\resources\codex.exe".to_string(), + ], + processes: vec![ + WindowsProcessInfo { + process_id: 20, + parent_process_id: 1, + executable_path: + r"C:\Users\tester\AppData\Local\OpenAI\Codex\bin\abc\codex.exe".to_string(), + command_line: format!( + "codex.exe app-server --listen {}", + DEFAULT_CODEX_SHARED_APP_SERVER_URL + ), + }, + WindowsProcessInfo { + process_id: 21, + parent_process_id: 30, + executable_path: + r"C:\Program Files\WindowsApps\OpenAI.Codex_1\app\resources\codex.exe" + .to_string(), + command_line: "codex.exe app-server --analytics-default-enabled".to_string(), + }, + WindowsProcessInfo { + process_id: 22, + parent_process_id: 30, + executable_path: + r"C:\Program Files\WindowsApps\OpenAI.Codex_1\app\resources\codex.exe" + .to_string(), + command_line: format!( + "codex.exe app-server --listen \"{}\"", + DEFAULT_CODEX_SHARED_APP_SERVER_URL + ), + }, + ], + }; + let snapshot = classify_windows_process_snapshot(raw, DEFAULT_CODEX_SHARED_APP_SERVER_URL); + assert_eq!( + snapshot + .private_app_server_processes + .iter() + .map(|process| process.process_id) + .collect::>(), + vec![21] + ); + assert!(!snapshot + .private_app_server_processes + .iter() + .any(|process| process.process_id == 20)); + } + + #[test] + fn ordinary_launch_and_post_launch_verification_refuse_private_backends() { + let conflict = CodexDesktopProcessSnapshot { + desktop_processes: Vec::new(), + private_app_server_processes: vec![WindowsProcessInfo { + process_id: 42, + parent_process_id: 1, + executable_path: + r"C:\Program Files\WindowsApps\OpenAI.Codex_1\app\resources\codex.exe" + .to_string(), + command_line: "codex.exe app-server".to_string(), + }], + }; + assert!(ensure_ordinary_desktop_launch_allowed(&conflict).is_err()); + assert!(ensure_post_launch_snapshot(&conflict).is_err()); + assert!( + ensure_ordinary_desktop_launch_allowed(&CodexDesktopProcessSnapshot::default()).is_ok() + ); + assert!(ensure_post_launch_snapshot(&CodexDesktopProcessSnapshot::default()).is_ok()); + } + + #[cfg(windows)] + #[test] + fn windows_shared_runtime_launch_is_hidden_and_logged() { + assert!(WINDOWS_CODEX_SHARED_RUNTIME_WMI_SCRIPT.contains("ShowWindow")); + assert!(WINDOWS_CODEX_SHARED_RUNTIME_LAUNCHER_SCRIPT.contains("-WindowStyle Hidden")); + assert!(WINDOWS_CODEX_SHARED_RUNTIME_LAUNCHER_SCRIPT.contains("-WorkingDirectory")); + assert!(WINDOWS_CODEX_SHARED_RUNTIME_LAUNCHER_SCRIPT.contains("-RedirectStandardOutput")); + assert!(WINDOWS_CODEX_SHARED_RUNTIME_LAUNCHER_SCRIPT.contains("-RedirectStandardError")); + } + + #[test] + fn unavailable_status_includes_a_bounded_runtime_log_tail() { + let dir = tempfile::tempdir().unwrap(); + let log_path = dir.path().join("codex-shared-runtime.stderr.log"); + fs::write( + &log_path, + format!( + "{}\ncurrent startup failure", + "old diagnostics ".repeat(400) + ), + ) + .unwrap(); + + let detail = append_shared_runtime_log_detail("runtime unavailable".to_string(), &log_path); + + assert!(detail.contains("runtime unavailable")); + assert!(detail.contains("current startup failure")); + assert!(!detail.contains("old diagnostics")); + assert!(detail.contains(log_path.to_string_lossy().as_ref())); + } + + #[test] + fn takeover_requires_confirmation_and_termination_rechecks_exact_paths() { + assert!(require_takeover_confirmation(false).is_err()); + assert!(require_takeover_confirmation(true).is_ok()); + assert!(WINDOWS_TERMINATE_VERIFIED_PROCESS_SCRIPT.contains("ExecutablePath")); + assert!(WINDOWS_TERMINATE_VERIFIED_PROCESS_SCRIPT.contains("BUZZ_CODEX_TARGET_EXE")); + assert!(WINDOWS_TERMINATE_VERIFIED_PROCESS_SCRIPT.contains("/PID")); + assert!(!WINDOWS_TERMINATE_VERIFIED_PROCESS_SCRIPT.contains("$_.Name")); + assert!(!WINDOWS_TERMINATE_VERIFIED_PROCESS_SCRIPT.contains("51919")); + } +} diff --git a/desktop/src-tauri/src/managed_agents/codex_tasks.rs b/desktop/src-tauri/src/managed_agents/codex_tasks.rs new file mode 100644 index 0000000000..1fc8233cf0 --- /dev/null +++ b/desktop/src-tauri/src/managed_agents/codex_tasks.rs @@ -0,0 +1,993 @@ +use std::{ + collections::{HashMap, HashSet, VecDeque}, + fs::{self, File}, + io::{BufRead, BufReader, Read, Seek, SeekFrom}, + net::{TcpStream, ToSocketAddrs}, + path::{Path, PathBuf}, + process::Command, + sync::{Mutex, OnceLock}, + time::Duration, + time::SystemTime, +}; + +use serde::{Deserialize, Serialize}; +use tauri::AppHandle; +use uuid::Uuid; + +use super::{ + atomic_write_json_restricted, managed_agents_base_dir, BackendKind, CreateManagedAgentRequest, + ManagedAgentRecord, +}; + +const STORE_VERSION: u32 = 4; +const MAX_TASKS: usize = 250; +const MODEL_SCAN_BYTES: u64 = 1024 * 1024; +const MODEL_SCAN_CHUNK_BYTES: u64 = 64 * 1024; +const MAX_HISTORY_MESSAGES: usize = 200; +const MAX_HISTORY_MESSAGE_CHARS: usize = 20_000; +pub const DEFAULT_CODEX_SHARED_APP_SERVER_URL: &str = "ws://127.0.0.1:51919"; +const SHARED_RUNTIME_URL_ENV: &str = "BUZZ_CODEX_SHARED_APP_SERVER_URL"; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct CodexTaskBinding { + pub task_id: String, + pub thread_name: String, + pub workspace: String, + pub updated_at: String, + #[serde(default)] + pub model: Option, + /// When set, codex-acp connects to this long-lived app-server instead of + /// spawning a private Codex process for the Buzz agent. + #[serde(default)] + pub app_server_url: Option, +} +#[derive(Debug, Clone, Serialize, PartialEq, Eq)] +pub struct CodexTaskSummary { + pub id: String, + pub thread_name: String, + pub workspace: String, + pub updated_at: String, + pub archived: bool, + pub model: Option, +} + +#[derive(Debug, Clone, Serialize, PartialEq, Eq)] +pub struct CodexTaskHistoryMessage { + pub id: String, + pub role: String, + pub content: String, + pub timestamp: Option, +} + +#[derive(Debug, Clone, Serialize, PartialEq, Eq)] +pub struct CodexTaskHistory { + pub task_id: String, + pub thread_name: String, + pub messages: Vec, + pub truncated: bool, +} + +#[derive(Debug, Default, Serialize, Deserialize)] +struct CodexTaskBindingStore { + version: u32, + bindings: HashMap, +} + +#[derive(Debug, Deserialize)] +struct SessionIndexEntry { + id: String, + thread_name: String, + updated_at: String, +} + +#[derive(Debug)] +struct SessionLocation { + workspace: String, + archived: bool, + path: PathBuf, +} + +#[derive(Clone)] +struct CachedTaskModel { + len: u64, + modified: Option, + model: Option, +} + +fn task_model_cache() -> &'static Mutex> { + static CACHE: OnceLock>> = OnceLock::new(); + CACHE.get_or_init(|| Mutex::new(HashMap::new())) +} + +fn codex_home_dir() -> Result { + if let Some(path) = std::env::var_os("CODEX_HOME") { + let path = PathBuf::from(path); + if path.is_dir() { + return Ok(path); + } + } + + dirs::home_dir() + .map(|home| home.join(".codex")) + .filter(|path| path.is_dir()) + .ok_or_else(|| "Codex home directory was not found".to_string()) +} + +fn binding_store_path(app: &AppHandle) -> Result { + Ok(managed_agents_base_dir(app)?.join("codex-task-bindings.json")) +} + +pub fn codex_shared_app_server_url() -> Result { + let configured = std::env::var(SHARED_RUNTIME_URL_ENV).ok(); + normalize_app_server_url( + configured + .as_deref() + .or(Some(DEFAULT_CODEX_SHARED_APP_SERVER_URL)), + )? + .ok_or_else(|| "Codex shared app-server URL is not configured".to_string()) +} + +fn load_binding_store(app: &AppHandle) -> Result { + let path = binding_store_path(app)?; + if !path.exists() { + return Ok(CodexTaskBindingStore { + version: STORE_VERSION, + bindings: HashMap::new(), + }); + } + + let bytes = + fs::read(&path).map_err(|error| format!("failed to read {}: {error}", path.display()))?; + let mut store: CodexTaskBindingStore = serde_json::from_slice(&bytes) + .map_err(|error| format!("failed to parse {}: {error}", path.display()))?; + if store.version < STORE_VERSION { + let shared_url = codex_shared_app_server_url()?; + if let Ok(tasks) = list_codex_tasks() { + let models = tasks + .into_iter() + .map(|task| (task.id, task.model)) + .collect::>(); + for binding in store.bindings.values_mut() { + if binding.model.is_none() { + binding.model = models.get(&binding.task_id).cloned().flatten(); + } + } + } + for binding in store.bindings.values_mut() { + binding.app_server_url = Some(shared_url.clone()); + } + store.version = STORE_VERSION; + save_binding_store(app, &store)?; + } + Ok(store) +} + +fn save_binding_store(app: &AppHandle, store: &CodexTaskBindingStore) -> Result<(), String> { + let path = binding_store_path(app)?; + let payload = serde_json::to_vec_pretty(store) + .map_err(|error| format!("failed to serialize Codex task bindings: {error}"))?; + atomic_write_json_restricted(&path, &payload) +} + +pub fn load_codex_task_binding( + app: &AppHandle, + agent_pubkey: &str, +) -> Result, String> { + Ok(load_binding_store(app)?.bindings.get(agent_pubkey).cloned()) +} + +pub fn save_codex_task_binding( + app: &AppHandle, + agent_pubkey: &str, + binding: CodexTaskBinding, +) -> Result<(), String> { + let mut store = load_binding_store(app)?; + let active_agent_pubkeys = super::load_managed_agents(app)? + .into_iter() + .map(|record| record.pubkey) + .collect::>(); + prune_stale_codex_task_bindings(&mut store, &active_agent_pubkeys); + if let Some((existing_pubkey, _)) = store + .bindings + .iter() + .find(|(pubkey, existing)| *pubkey != agent_pubkey && existing.task_id == binding.task_id) + { + return Err(format!( + "Codex task {} is already bound to agent {}", + binding.task_id, existing_pubkey + )); + } + store.version = STORE_VERSION; + store.bindings.insert(agent_pubkey.to_string(), binding); + save_binding_store(app, &store) +} + +fn prune_stale_codex_task_bindings( + store: &mut CodexTaskBindingStore, + active_agent_pubkeys: &HashSet, +) -> bool { + let original_len = store.bindings.len(); + store + .bindings + .retain(|pubkey, _| active_agent_pubkeys.contains(pubkey)); + store.bindings.len() != original_len +} + +pub fn remove_codex_task_binding(app: &AppHandle, agent_pubkey: &str) -> Result<(), String> { + let mut store = load_binding_store(app)?; + if store.bindings.remove(agent_pubkey).is_some() { + save_binding_store(app, &store)?; + } + Ok(()) +} + +pub fn binding_for_task_id(task_id: &str) -> Result { + let normalized = Uuid::parse_str(task_id.trim()) + .map_err(|_| "Codex task ID must be a UUID".to_string())? + .to_string(); + let task = list_codex_tasks()? + .into_iter() + .find(|task| task.id == normalized) + .ok_or_else(|| format!("Codex task {normalized} was not found on this computer"))?; + let workspace = PathBuf::from(&task.workspace); + if !workspace.is_dir() { + return Err(format!( + "Codex task workspace no longer exists: {}", + workspace.display() + )); + } + + Ok(CodexTaskBinding { + task_id: task.id, + thread_name: task.thread_name, + workspace: task.workspace, + updated_at: task.updated_at, + model: task.model, + app_server_url: None, + }) +} + +fn normalize_app_server_url(value: Option<&str>) -> Result, String> { + let Some(value) = value.map(str::trim).filter(|value| !value.is_empty()) else { + return Ok(None); + }; + let parsed = + url::Url::parse(value).map_err(|error| format!("invalid Codex app-server URL: {error}"))?; + if !matches!(parsed.scheme(), "ws" | "wss") { + return Err("Codex app-server URL must use ws:// or wss://".to_string()); + } + if parsed.host_str().is_none() { + return Err("Codex app-server URL must include a host".to_string()); + } + if !parsed.username().is_empty() || parsed.password().is_some() { + return Err("Codex app-server URL cannot include credentials".to_string()); + } + Ok(Some(parsed.to_string().trim_end_matches('/').to_string())) +} + +fn resolve_codex_task_app_server_url(requested: Option<&str>) -> Result { + let requested = normalize_app_server_url(requested)?; + let shared_url = codex_shared_app_server_url()?; + if requested.as_deref().is_some_and(|url| url != shared_url) { + return Err(format!( + "Codex task agents use the computer shared runtime at {shared_url}; per-agent app-server URLs are not supported" + )); + } + Ok(shared_url) +} + +pub fn prepare_codex_task_binding( + input: &CreateManagedAgentRequest, +) -> Result, String> { + let requested_url = normalize_app_server_url(input.codex_app_server_url.as_deref())?; + let mut binding = input + .codex_task_id + .as_deref() + .map(binding_for_task_id) + .transpose()?; + if let Some(binding) = binding.as_mut() { + binding.app_server_url = Some(resolve_codex_task_app_server_url( + input.codex_app_server_url.as_deref(), + )?); + if input.backend != BackendKind::Local { + return Err("Codex tasks can only be bound to local agents".to_string()); + } + if input + .parallelism + .is_some_and(|parallelism| parallelism != 1) + { + return Err("Codex task-bound agents require parallelism 1".to_string()); + } + } else if requested_url.is_some() { + return Err("A shared Codex app-server requires a Codex task binding".to_string()); + } + Ok(binding) +} + +pub fn save_agents_with_codex_task_binding( + app: &AppHandle, + records: &[ManagedAgentRecord], + agent_pubkey: &str, + binding: Option, +) -> Result<(), String> { + if let Some(binding) = binding { + save_codex_task_binding(app, agent_pubkey, binding)?; + } + if let Err(error) = super::save_managed_agents(app, records) { + let _ = remove_codex_task_binding(app, agent_pubkey); + return Err(error); + } + Ok(()) +} + +pub fn delete_codex_task_identity_state(app: &AppHandle, agent_pubkey: &str) -> Result<(), String> { + remove_codex_task_binding(app, agent_pubkey)?; + super::delete_agent_key(agent_pubkey); + Ok(()) +} + +pub fn task_binding_for_spawn( + app: &AppHandle, + record: &ManagedAgentRecord, +) -> Result, String> { + let binding = load_codex_task_binding(app, &record.pubkey)?; + if let Some(binding) = &binding { + if record.backend != BackendKind::Local { + return Err("Codex task-bound agents can only run on this computer".to_string()); + } + if !Path::new(&binding.workspace).is_dir() { + return Err(format!( + "Codex task workspace no longer exists: {}", + binding.workspace + )); + } + let url = binding.app_server_url.as_deref().ok_or_else(|| { + "This Codex task binding predates shared runtime setup. Reopen Buzz to migrate it." + .to_string() + })?; + ensure_codex_shared_runtime_reachable(url)?; + } + Ok(binding) +} + +pub fn configure_task_bound_command( + command: &mut Command, + binding: Option<&CodexTaskBinding>, + lazy: bool, +) { + if let Some(binding) = binding { + command.current_dir(&binding.workspace); + command.env("BUZZ_ACP_CODEX_TASK_ID", &binding.task_id); + command.env("BUZZ_ACP_CODEX_TASK_WORKSPACE", &binding.workspace); + } else { + if let Some(home) = super::default_agent_workdir() { + command.current_dir(home); + } + command.env_remove("BUZZ_ACP_CODEX_TASK_ID"); + command.env_remove("BUZZ_ACP_CODEX_TASK_WORKSPACE"); + } + command.env( + "BUZZ_ACP_LAZY_POOL", + if lazy && binding.is_none() { + "true" + } else { + "false" + }, + ); +} + +pub fn configure_shared_app_server( + command: &mut Command, + binding: Option<&CodexTaskBinding>, + proxy_executable: &Path, +) { + if let Some(binding) = binding { + let url = binding + .app_server_url + .clone() + .or_else(|| codex_shared_app_server_url().ok()) + .unwrap_or_else(|| DEFAULT_CODEX_SHARED_APP_SERVER_URL.to_string()); + command.env("CODEX_PATH", proxy_executable); + command.env("CODEX_SHARED_APP_SERVER_URL", url); + } else { + command.env_remove("CODEX_SHARED_APP_SERVER_URL"); + } +} + +pub fn task_bound_worker_count( + effective_command: &str, + parallelism: u32, + binding: Option<&CodexTaskBinding>, +) -> String { + if binding.is_some() { + "1".to_string() + } else { + super::acp_agents_value(effective_command, parallelism) + } +} + +fn ensure_codex_shared_runtime_reachable(url: &str) -> Result<(), String> { + let parsed = url::Url::parse(url) + .map_err(|error| format!("invalid Codex shared runtime URL: {error}"))?; + let host = parsed + .host_str() + .ok_or_else(|| "Codex shared runtime URL has no host".to_string())?; + let port = parsed + .port_or_known_default() + .ok_or_else(|| "Codex shared runtime URL has no port".to_string())?; + let addresses = (host, port) + .to_socket_addrs() + .map_err(|error| format!("could not resolve Codex shared runtime: {error}"))?; + for address in addresses { + if TcpStream::connect_timeout(&address, Duration::from_millis(750)).is_ok() { + return Ok(()); + } + } + Err(format!( + "Codex shared runtime is unavailable at {url}. Open Agent settings and start the shared runtime, then retry." + )) +} + +pub fn list_codex_tasks() -> Result, String> { + let codex_home = codex_home_dir()?; + let index_path = codex_home.join("session_index.jsonl"); + let index_file = File::open(&index_path) + .map_err(|error| format!("failed to read {}: {error}", index_path.display()))?; + // Renames append another entry for the same task. Keep the last one so the + // picker cannot show duplicate identities with stale titles. + let mut entries_by_id = HashMap::new(); + for entry in BufReader::new(index_file) + .lines() + .map_while(Result::ok) + .filter_map(|line| serde_json::from_str::(&line).ok()) + { + let Ok(id) = Uuid::parse_str(&entry.id) else { + continue; + }; + entries_by_id.insert(id.to_string(), entry); + } + + let mut locations = HashMap::new(); + collect_session_locations(&codex_home.join("sessions"), false, &mut locations); + collect_session_locations(&codex_home.join("archived_sessions"), true, &mut locations); + + let mut tasks = entries_by_id + .into_iter() + .filter_map(|(normalized, entry)| { + let location = locations.get(&normalized)?; + Some(( + CodexTaskSummary { + id: normalized, + thread_name: entry.thread_name, + workspace: location.workspace.clone(), + updated_at: entry.updated_at, + archived: location.archived, + model: None, + }, + location.path.clone(), + )) + }) + .collect::>(); + tasks.sort_by(|(left, _), (right, _)| right.updated_at.cmp(&left.updated_at)); + tasks.truncate(MAX_TASKS); + Ok(tasks + .into_iter() + .map(|(mut task, path)| { + task.model = read_latest_codex_model(&path); + task + }) + .collect()) +} + +pub fn get_codex_task_history( + app: &AppHandle, + agent_pubkey: &str, +) -> Result { + let binding = load_codex_task_binding(app, agent_pubkey)? + .ok_or_else(|| "This agent is not bound to a Codex task".to_string())?; + let codex_home = codex_home_dir()?; + let mut locations = HashMap::new(); + collect_session_locations(&codex_home.join("sessions"), false, &mut locations); + collect_session_locations(&codex_home.join("archived_sessions"), true, &mut locations); + let location = locations.get(&binding.task_id).ok_or_else(|| { + format!( + "Codex task {} was not found on this computer", + binding.task_id + ) + })?; + let (messages, truncated) = read_codex_task_history(&location.path)?; + Ok(CodexTaskHistory { + task_id: binding.task_id, + thread_name: binding.thread_name, + messages, + truncated, + }) +} + +fn read_codex_task_history(path: &Path) -> Result<(Vec, bool), String> { + let file = + File::open(path).map_err(|error| format!("failed to read {}: {error}", path.display()))?; + let mut messages = VecDeque::with_capacity(MAX_HISTORY_MESSAGES); + let mut truncated = false; + for (line_index, line) in BufReader::new(file) + .lines() + .map_while(Result::ok) + .enumerate() + { + let Ok(value) = serde_json::from_str::(&line) else { + continue; + }; + let Some(message) = parse_codex_history_message(&value, line_index) else { + continue; + }; + if messages.len() == MAX_HISTORY_MESSAGES { + messages.pop_front(); + truncated = true; + } + messages.push_back(message); + } + Ok((messages.into_iter().collect(), truncated)) +} + +fn parse_codex_history_message( + value: &serde_json::Value, + line_index: usize, +) -> Option { + let timestamp = value + .get("timestamp") + .and_then(serde_json::Value::as_str) + .map(str::to_string); + let payload = value.get("payload")?; + let (role, content) = match ( + value.get("type").and_then(serde_json::Value::as_str), + payload.get("type").and_then(serde_json::Value::as_str), + ) { + (Some("event_msg"), Some("user_message")) => { + ("user", payload.get("message")?.as_str()?.to_string()) + } + (Some("response_item"), Some("message")) + if payload.get("role").and_then(serde_json::Value::as_str) == Some("assistant") + && payload.get("phase").and_then(serde_json::Value::as_str) + == Some("final_answer") => + { + let content = payload + .get("content")? + .as_array()? + .iter() + .filter(|item| { + item.get("type").and_then(serde_json::Value::as_str) == Some("output_text") + }) + .filter_map(|item| item.get("text").and_then(serde_json::Value::as_str)) + .collect::>() + .join("\n"); + ("assistant", content) + } + _ => return None, + }; + let content = content.trim(); + if content.is_empty() { + return None; + } + Some(CodexTaskHistoryMessage { + id: payload + .get("id") + .or_else(|| payload.get("client_id")) + .and_then(serde_json::Value::as_str) + .map(str::to_string) + .unwrap_or_else(|| format!("line-{line_index}")), + role: role.to_string(), + content: truncate_history_content(content), + timestamp, + }) +} + +fn truncate_history_content(content: &str) -> String { + if content.chars().count() <= MAX_HISTORY_MESSAGE_CHARS { + return content.to_string(); + } + let mut truncated = content + .chars() + .take(MAX_HISTORY_MESSAGE_CHARS) + .collect::(); + truncated.push_str("\n\n... [message truncated]"); + truncated +} + +fn collect_session_locations( + root: &Path, + archived: bool, + locations: &mut HashMap, +) { + let Ok(entries) = fs::read_dir(root) else { + return; + }; + for entry in entries.flatten() { + let path = entry.path(); + if path.is_dir() { + collect_session_locations(&path, archived, locations); + continue; + } + if path.extension().and_then(|value| value.to_str()) != Some("jsonl") { + continue; + } + let Some((task_id, workspace)) = read_session_meta(&path) else { + continue; + }; + locations.insert( + task_id, + SessionLocation { + workspace, + archived, + path, + }, + ); + } +} + +fn read_latest_codex_model(path: &Path) -> Option { + let metadata = fs::metadata(path).ok()?; + let len = metadata.len(); + let modified = metadata.modified().ok(); + if let Ok(cache) = task_model_cache().lock() { + if let Some(cached) = cache.get(path) { + if cached.len == len && cached.modified == modified { + return cached.model.clone(); + } + } + } + + let mut file = File::open(path).ok()?; + let model = scan_latest_codex_model(&mut file, len); + + if let Ok(mut cache) = task_model_cache().lock() { + cache.insert( + path.to_path_buf(), + CachedTaskModel { + len, + modified, + model: model.clone(), + }, + ); + } + model +} + +fn scan_latest_codex_model(file: &mut File, len: u64) -> Option { + let min_offset = len.saturating_sub(MODEL_SCAN_BYTES); + let mut end = len; + let mut leading_fragment = Vec::new(); + while end > min_offset { + let start = end.saturating_sub(MODEL_SCAN_CHUNK_BYTES).max(min_offset); + let mut bytes = vec![0; (end - start) as usize]; + file.seek(SeekFrom::Start(start)).ok()?; + file.read_exact(&mut bytes).ok()?; + bytes.extend_from_slice(&leading_fragment); + + if let Some(first_newline) = bytes.iter().position(|byte| *byte == b'\n') { + if let Some(model) = bytes[first_newline + 1..] + .split(|byte| *byte == b'\n') + .rev() + .find_map(parse_codex_model_line) + { + return Some(model); + } + leading_fragment.clear(); + leading_fragment.extend_from_slice(&bytes[..first_newline]); + } else { + leading_fragment = bytes; + } + end = start; + } + parse_codex_model_line(&leading_fragment) +} + +fn parse_codex_model_line(line: &[u8]) -> Option { + let value = serde_json::from_slice::(line).ok()?; + if value.get("type").and_then(serde_json::Value::as_str) != Some("turn_context") { + return None; + } + let payload = value.get("payload")?; + let model = payload + .get("model") + .and_then(serde_json::Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty())?; + let effort = payload + .get("effort") + .and_then(serde_json::Value::as_str) + .or_else(|| { + payload + .pointer("/collaboration_mode/settings/reasoning_effort") + .and_then(serde_json::Value::as_str) + }) + .map(str::trim) + .filter(|value| !value.is_empty()); + Some(match effort { + Some(effort) if !(model.contains('[') && model.ends_with(']')) => { + format!("{model}[{effort}]") + } + _ => model.to_string(), + }) +} + +fn read_session_meta(path: &Path) -> Option<(String, String)> { + let file = File::open(path).ok()?; + let mut lines = BufReader::new(file).lines(); + let line = lines.next()?.ok()?; + let value: serde_json::Value = serde_json::from_str(&line).ok()?; + if value.get("type")?.as_str()? != "session_meta" { + return None; + } + let payload = value.get("payload")?; + let task_id = Uuid::parse_str(payload.get("id")?.as_str()?) + .ok()? + .to_string(); + let workspace = payload.get("cwd")?.as_str()?.to_string(); + Some((task_id, workspace)) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::fs::OpenOptions; + use std::io::Write as _; + + #[test] + fn reads_codex_session_metadata() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("rollout.jsonl"); + let mut file = File::create(&path).unwrap(); + writeln!( + file, + r#"{{"type":"session_meta","payload":{{"id":"019eca9a-beb9-7902-8ce6-527b2ba56020","cwd":"C:\\repo"}}}}"# + ) + .unwrap(); + + assert_eq!( + read_session_meta(&path), + Some(( + "019eca9a-beb9-7902-8ce6-527b2ba56020".to_string(), + r"C:\repo".to_string(), + )) + ); + } + + #[test] + fn reads_latest_model_and_reasoning_effort() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("rollout.jsonl"); + let mut file = File::create(&path).unwrap(); + writeln!( + file, + r#"{{"type":"turn_context","payload":{{"model":"gpt-5","effort":"high"}}}}"# + ) + .unwrap(); + writeln!( + file, + r#"{{"type":"turn_context","payload":{{"model":"gpt-5.5","collaboration_mode":{{"settings":{{"reasoning_effort":"xhigh"}}}}}}}}"# + ) + .unwrap(); + + assert_eq!( + read_latest_codex_model(&path).as_deref(), + Some("gpt-5.5[xhigh]") + ); + } + + #[test] + fn scans_additional_chunks_when_latest_context_is_outside_initial_tail() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("rollout.jsonl"); + let mut file = File::create(&path).unwrap(); + writeln!( + file, + r#"{{"type":"turn_context","payload":{{"model":"gpt-5.5","effort":"high"}}}}"# + ) + .unwrap(); + file.write_all(&vec![b'x'; MODEL_SCAN_CHUNK_BYTES as usize + 1]) + .unwrap(); + drop(file); + + assert_eq!( + read_latest_codex_model(&path).as_deref(), + Some("gpt-5.5[high]") + ); + } + + #[test] + fn invalidates_cached_model_when_session_grows() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("rollout.jsonl"); + fs::write( + &path, + r#"{"type":"turn_context","payload":{"model":"gpt-5","effort":"medium"}} +"#, + ) + .unwrap(); + assert_eq!( + read_latest_codex_model(&path).as_deref(), + Some("gpt-5[medium]") + ); + + let mut file = OpenOptions::new().append(true).open(&path).unwrap(); + writeln!( + file, + r#"{{"type":"turn_context","payload":{{"model":"gpt-5.5","effort":"xhigh"}}}}"# + ) + .unwrap(); + drop(file); + + assert_eq!( + read_latest_codex_model(&path).as_deref(), + Some("gpt-5.5[xhigh]") + ); + } + + #[test] + fn reads_only_user_messages_and_final_answers_from_codex_history() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("rollout.jsonl"); + let mut file = File::create(&path).unwrap(); + writeln!( + file, + r#"{{"timestamp":"2026-08-19T01:00:00Z","type":"event_msg","payload":{{"type":"user_message","message":"Hello"}}}}"# + ) + .unwrap(); + writeln!( + file, + r#"{{"timestamp":"2026-08-19T01:00:01Z","type":"event_msg","payload":{{"type":"agent_reasoning","text":"hidden"}}}}"# + ) + .unwrap(); + writeln!( + file, + r#"{{"timestamp":"2026-08-19T01:00:02Z","type":"response_item","payload":{{"type":"message","role":"assistant","phase":"commentary","content":[{{"type":"output_text","text":"working"}}]}}}}"# + ) + .unwrap(); + writeln!( + file, + r#"{{"timestamp":"2026-08-19T01:00:03Z","type":"response_item","payload":{{"type":"message","role":"assistant","phase":"final_answer","content":[{{"type":"output_text","text":"Done"}}]}}}}"# + ) + .unwrap(); + + let (messages, truncated) = read_codex_task_history(&path).unwrap(); + assert!(!truncated); + assert_eq!( + messages, + vec![ + CodexTaskHistoryMessage { + id: "line-0".to_string(), + role: "user".to_string(), + content: "Hello".to_string(), + timestamp: Some("2026-08-19T01:00:00Z".to_string()), + }, + CodexTaskHistoryMessage { + id: "line-3".to_string(), + role: "assistant".to_string(), + content: "Done".to_string(), + timestamp: Some("2026-08-19T01:00:03Z".to_string()), + }, + ] + ); + } + + #[test] + fn codex_history_keeps_only_the_latest_message_limit() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("rollout.jsonl"); + let mut file = File::create(&path).unwrap(); + for index in 0..=MAX_HISTORY_MESSAGES { + writeln!( + file, + r#"{{"type":"event_msg","payload":{{"type":"user_message","message":"message-{index}"}}}}"# + ) + .unwrap(); + } + + let (messages, truncated) = read_codex_task_history(&path).unwrap(); + assert!(truncated); + assert_eq!(messages.len(), MAX_HISTORY_MESSAGES); + assert_eq!( + messages.first().map(|message| message.content.as_str()), + Some("message-1") + ); + } + + #[test] + fn validates_shared_app_server_urls() { + assert_eq!( + normalize_app_server_url(Some(" ws://127.0.0.1:51919/ ")).unwrap(), + Some("ws://127.0.0.1:51919".to_string()) + ); + assert!(normalize_app_server_url(Some("http://127.0.0.1:51919")).is_err()); + assert!(normalize_app_server_url(Some("ws://user@127.0.0.1:51919")).is_err()); + } + + #[test] + fn shared_runtime_has_one_computer_level_default() { + assert_eq!( + normalize_app_server_url(Some(DEFAULT_CODEX_SHARED_APP_SERVER_URL)).unwrap(), + Some(DEFAULT_CODEX_SHARED_APP_SERVER_URL.to_string()) + ); + assert_eq!( + resolve_codex_task_app_server_url(None).unwrap(), + DEFAULT_CODEX_SHARED_APP_SERVER_URL + ); + assert!(resolve_codex_task_app_server_url(Some("ws://127.0.0.1:59999")).is_err()); + } + + #[test] + fn stale_agent_bindings_are_pruned_before_rebinding() { + let binding = CodexTaskBinding { + task_id: "019febeb-ae12-71d3-88c4-25c04a461042".to_string(), + thread_name: "Deleted task agent".to_string(), + workspace: r"C:\repo".to_string(), + updated_at: "2026-08-11T00:00:00Z".to_string(), + model: None, + app_server_url: Some(DEFAULT_CODEX_SHARED_APP_SERVER_URL.to_string()), + }; + let mut store = CodexTaskBindingStore { + version: STORE_VERSION, + bindings: HashMap::from([ + ("active-agent".to_string(), binding.clone()), + ("deleted-agent".to_string(), binding), + ]), + }; + let active = HashSet::from(["active-agent".to_string()]); + + assert!(prune_stale_codex_task_bindings(&mut store, &active)); + assert!(store.bindings.contains_key("active-agent")); + assert!(!store.bindings.contains_key("deleted-agent")); + assert!(!prune_stale_codex_task_bindings(&mut store, &active)); + } + + #[test] + fn configures_shared_app_server_proxy_environment() { + let binding = CodexTaskBinding { + task_id: "019eca9a-beb9-7902-8ce6-527b2ba56020".to_string(), + thread_name: "Shared task".to_string(), + workspace: r"C:\repo".to_string(), + updated_at: "2026-08-11T00:00:00Z".to_string(), + model: Some("gpt-5.5[xhigh]".to_string()), + app_server_url: Some("ws://127.0.0.1:51919".to_string()), + }; + let mut command = Command::new("buzz-acp"); + + configure_shared_app_server( + &mut command, + Some(&binding), + Path::new(r"C:\Buzz\buzz-acp.exe"), + ); + + let env = command + .get_envs() + .map(|(key, value)| { + ( + key.to_string_lossy().into_owned(), + value.map(|value| value.to_string_lossy().into_owned()), + ) + }) + .collect::>(); + assert_eq!( + env.get("CODEX_SHARED_APP_SERVER_URL"), + Some(&Some("ws://127.0.0.1:51919".to_string())) + ); + assert_eq!( + env.get("CODEX_PATH"), + Some(&Some(r"C:\Buzz\buzz-acp.exe".to_string())) + ); + } + + #[test] + fn ordinary_agent_keeps_inherited_codex_path() { + let mut command = Command::new("buzz-acp"); + + configure_shared_app_server(&mut command, None, Path::new(r"C:\Buzz\buzz-acp.exe")); + + let env = command + .get_envs() + .map(|(key, value)| (key.to_string_lossy().into_owned(), value)) + .collect::>(); + assert!(!env.contains_key("CODEX_PATH")); + assert_eq!(env.get("CODEX_SHARED_APP_SERVER_URL"), Some(&None)); + } +} diff --git a/desktop/src-tauri/src/managed_agents/discovery.rs b/desktop/src-tauri/src/managed_agents/discovery.rs index bc0e3a6cda..d23b3ae1a5 100644 --- a/desktop/src-tauri/src/managed_agents/discovery.rs +++ b/desktop/src-tauri/src/managed_agents/discovery.rs @@ -218,6 +218,7 @@ pub(crate) fn known_skill_dirs() -> impl Iterator { KNOWN_ACP_RUNTIMES.iter().filter_map(|p| p.skill_dir) } +#[cfg(debug_assertions)] fn workspace_root_dir() -> PathBuf { PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../..") } @@ -278,6 +279,18 @@ pub(crate) fn known_acp_runtime(command: &str) -> Option<&'static KnownAcpRuntim }) } +/// Resolve the MCP sidecar for an effective agent command. +/// +/// Catalog metadata remains authoritative for known runtimes. A custom command +/// can still retain the instance's configured sidecar, which is useful for +/// wrapper binaries that preserve an existing runtime's ACP contract. +pub(crate) fn resolve_effective_mcp_command(command: &str, configured: &str) -> String { + known_acp_runtime(command) + .and_then(|runtime| runtime.mcp_command) + .map(str::to_string) + .unwrap_or_else(|| configured.trim().to_string()) +} + pub(crate) fn known_acp_runtime_exact(id: &str) -> Option<&'static KnownAcpRuntime> { KNOWN_ACP_RUNTIMES.iter().find(|p| p.id == id) } @@ -481,17 +494,28 @@ fn profile_target_dirs(root: &Path) -> [PathBuf; 2] { } } -fn command_search_dirs() -> Vec { - let mut dirs = profile_target_dirs(&workspace_root_dir()).to_vec(); - if let Ok(current_dir) = std::env::current_dir() { - dirs.extend(profile_target_dirs(¤t_dir)); +fn command_search_dirs_for( + workspace_root: Option<&Path>, + current_dir: Option<&Path>, + executable_parent: Option<&Path>, + prefer_workspace_profiles: bool, +) -> Vec { + let mut dirs = Vec::new(); + if !prefer_workspace_profiles { + dirs.extend(executable_parent.map(Path::to_path_buf)); + } + + if let Some(workspace_root) = workspace_root { + dirs.extend(profile_target_dirs(workspace_root)); + } + if let Some(current_dir) = current_dir { + dirs.extend(profile_target_dirs(current_dir)); + } + + if prefer_workspace_profiles { + dirs.extend(executable_parent.map(Path::to_path_buf)); } - dirs.extend( - std::env::current_exe() - .ok() - .and_then(|path| path.parent().map(Path::to_path_buf)), - ); dirs.into_iter().fold(Vec::new(), |mut unique, dir| { if !unique.contains(&dir) { unique.push(dir); @@ -500,6 +524,25 @@ fn command_search_dirs() -> Vec { }) } +fn command_search_dirs() -> Vec { + let current_dir = std::env::current_dir().ok(); + let executable_parent = std::env::current_exe() + .ok() + .and_then(|path| path.parent().map(Path::to_path_buf)); + + #[cfg(debug_assertions)] + let workspace_root = Some(workspace_root_dir()); + #[cfg(not(debug_assertions))] + let workspace_root: Option = None; + + command_search_dirs_for( + workspace_root.as_deref(), + current_dir.as_deref(), + executable_parent.as_deref(), + cfg!(debug_assertions), + ) +} + fn is_executable_file(path: &Path) -> bool { let Ok(metadata) = path.metadata() else { return false; @@ -686,17 +729,14 @@ fn resolve_command_uncached(command: &str) -> Option { return Some(managed); } - for candidate in path_candidates_from_env(command) { - if is_executable_file(&candidate) { - return Some(candidate); - } - } - - // On Windows, also scan PATH for .cmd/.bat shims (npm globals). + // Preserve PATH directory precedence on Windows. Checking every `.exe` + // first can incorrectly select a later, inaccessible WindowsApps alias + // ahead of an earlier npm `.cmd` shim for the same command. #[cfg(windows)] { - for basename in command_basenames(command).iter().skip(1) { - for candidate in path_candidates_from_env_raw(basename) { + for dir in path_dirs_from_env() { + for basename in &basenames { + let candidate = dir.join(basename); if candidate.is_file() { return Some(candidate); } @@ -704,6 +744,13 @@ fn resolve_command_uncached(command: &str) -> Option { } } + #[cfg(not(windows))] + for candidate in path_candidates_from_env(command) { + if is_executable_file(&candidate) { + return Some(candidate); + } + } + if let Some(path) = find_via_login_shell(command) { return Some(path); } @@ -734,26 +781,17 @@ fn resolve_command_uncached(command: &str) -> Option { None } +#[cfg(not(windows))] fn path_candidates_from_env(command: &str) -> Vec { - std::env::var_os("PATH") - .map(|paths| { - std::env::split_paths(&paths) - .map(|dir| dir.join(executable_basename(command))) - .collect::>() - }) - .unwrap_or_default() + path_dirs_from_env() + .into_iter() + .map(|dir| dir.join(executable_basename(command))) + .collect() } -/// Like `path_candidates_from_env` but joins `basename` as-is (no `.exe` suffix). -/// Used for `.cmd`/`.bat` shim resolution on Windows. -#[cfg(windows)] -fn path_candidates_from_env_raw(basename: &str) -> Vec { +fn path_dirs_from_env() -> Vec { std::env::var_os("PATH") - .map(|paths| { - std::env::split_paths(&paths) - .map(|dir| dir.join(basename)) - .collect::>() - }) + .map(|paths| std::env::split_paths(&paths).collect()) .unwrap_or_default() } @@ -1010,8 +1048,7 @@ fn probe_auth_status(binary_path: &Path, probe_args: &[&str]) -> AuthStatus { let augmented_path = cli_probe::augmented_path(); - let mut command = std::process::Command::new(binary_path); - command.args(&probe_args[1..]); + let mut command = cli_probe::probe_command(binary_path, &probe_args[1..]); if let Some(ref path) = augmented_path { command.env("PATH", path); } diff --git a/desktop/src-tauri/src/managed_agents/discovery/tests.rs b/desktop/src-tauri/src/managed_agents/discovery/tests.rs index 6fe6a77521..283ff65129 100644 --- a/desktop/src-tauri/src/managed_agents/discovery/tests.rs +++ b/desktop/src-tauri/src/managed_agents/discovery/tests.rs @@ -7,8 +7,8 @@ use super::{ effective_agent_command, find_nvm_default_bin, find_via_login_shell, is_login_shell_path_uninit, is_safe_nvm_tag, managed_agent_avatar_url, normalize_agent_args, parse_semver_tag, probe_codex_acp_version, record_agent_command, refresh_login_shell_path, - try_record_agent_command, BUZZ_AGENT_AVATAR_URL, CLAUDE_CODE_AVATAR_URL, CODEX_AVATAR_URL, - GOOSE_AVATAR_URL, + resolve_effective_mcp_command, try_record_agent_command, BUZZ_AGENT_AVATAR_URL, + CLAUDE_CODE_AVATAR_URL, CODEX_AVATAR_URL, GOOSE_AVATAR_URL, }; use crate::managed_agents::AcpAvailabilityStatus; @@ -39,6 +39,23 @@ fn resolves_known_avatar_for_command_paths_and_aliases() { ); } +#[test] +fn effective_mcp_command_prefers_catalog_for_known_runtime() { + assert_eq!( + resolve_effective_mcp_command("codex-acp", "stale-sidecar"), + "buzz-dev-mcp" + ); +} + +#[test] +fn effective_mcp_command_preserves_configured_sidecar_for_custom_wrapper() { + assert_eq!( + resolve_effective_mcp_command(r"C:\Tools\codex-acp-buzz.exe", "buzz-dev-mcp"), + "buzz-dev-mcp" + ); + assert_eq!(resolve_effective_mcp_command("custom-acp", " "), ""); +} + #[test] fn returns_none_for_unknown_commands() { assert!(managed_agent_avatar_url("custom-agent").is_none()); @@ -1329,6 +1346,28 @@ fn test_cmd_shim_resolves_from_path() { ); } +#[cfg(windows)] +#[test] +fn test_earlier_cmd_shim_precedes_later_exe() { + let _guard = crate::managed_agents::lock_path_mutex(); + let earlier = tempfile::tempdir().expect("earlier tempdir"); + let later = tempfile::tempdir().expect("later tempdir"); + let shim = earlier.path().join("test-path-order.cmd"); + let exe = later.path().join("test-path-order.exe"); + std::fs::write(&shim, "@echo off\r\n").expect("write shim"); + std::fs::write(&exe, []).expect("write exe"); + + let old_path = std::env::var_os("PATH").unwrap_or_default(); + let mut new_path = vec![earlier.path().to_path_buf(), later.path().to_path_buf()]; + new_path.extend(std::env::split_paths(&old_path)); + std::env::set_var("PATH", std::env::join_paths(new_path).expect("join PATH")); + + let result = super::resolve_command_uncached("test-path-order"); + std::env::set_var("PATH", &old_path); + + assert_eq!(result.as_deref(), Some(shim.as_path())); +} + // ── Phase A: no-shell-resolved error on Windows ──────────────────────────── /// When all resolution sources are empty AND the registry is disabled, diff --git a/desktop/src-tauri/src/managed_agents/discovery/tests/managed_path_resolution.rs b/desktop/src-tauri/src/managed_agents/discovery/tests/managed_path_resolution.rs index 0795bb2345..bdb5a89336 100644 --- a/desktop/src-tauri/src/managed_agents/discovery/tests/managed_path_resolution.rs +++ b/desktop/src-tauri/src/managed_agents/discovery/tests/managed_path_resolution.rs @@ -1,5 +1,31 @@ use crate::managed_agents::discovery::{clear_resolve_cache, resolve_command}; +#[test] +fn release_command_search_prefers_installed_sidecars() { + use std::path::Path; + + let workspace = Path::new("workspace"); + let current_dir = Path::new("current"); + let executable_parent = Path::new("installed"); + + let dirs = super::super::command_search_dirs_for( + Some(workspace), + Some(current_dir), + Some(executable_parent), + false, + ); + + assert_eq!( + dirs.first().map(|path| path.as_path()), + Some(executable_parent) + ); + assert!( + dirs.iter().position(|path| path == executable_parent) + < dirs.iter().position(|path| path.starts_with(workspace)), + "release builds must prefer the installed sidecar over stale workspace artifacts: {dirs:?}" + ); +} + /// The legacy Goose Windows installer wrote `%USERPROFILE%\goose\goose.exe`, /// a directory on no standard PATH. `resolve_command_uncached` finds binaries /// outside PATH only by scanning `common_binary_paths()`, so that directory diff --git a/desktop/src-tauri/src/managed_agents/effective_config/mod.rs b/desktop/src-tauri/src/managed_agents/effective_config/mod.rs index e079c76a1d..7882c04a4d 100644 --- a/desktop/src-tauri/src/managed_agents/effective_config/mod.rs +++ b/desktop/src-tauri/src/managed_agents/effective_config/mod.rs @@ -15,6 +15,7 @@ pub enum ConfigSource { Definition, Global, InstanceLegacy, + CodexTask, } #[derive(Debug, Clone, PartialEq, Eq)] diff --git a/desktop/src-tauri/src/managed_agents/mod.rs b/desktop/src-tauri/src/managed_agents/mod.rs index c6ccd3709c..af3faddf73 100644 --- a/desktop/src-tauri/src/managed_agents/mod.rs +++ b/desktop/src-tauri/src/managed_agents/mod.rs @@ -9,6 +9,8 @@ pub(crate) use agent_env::{ baked_build_env, build_buzz_agent_provider_defaults, discovery_env_with_baked_floor, }; mod backend; +mod codex_desktop; +mod codex_tasks; pub(crate) mod config_bridge; pub(crate) mod custom_harnesses; mod definition_validation; @@ -52,6 +54,8 @@ pub(crate) fn lock_path_mutex() -> std::sync::MutexGuard<'static, ()> { } pub use backend::*; +pub use codex_desktop::*; +pub use codex_tasks::*; pub(crate) use definition_validation::{ validate_agent_definition_text, validate_managed_agent_definition_text, }; diff --git a/desktop/src-tauri/src/managed_agents/nest.rs b/desktop/src-tauri/src/managed_agents/nest.rs index a57676f0a9..d1d85f281f 100644 --- a/desktop/src-tauri/src/managed_agents/nest.rs +++ b/desktop/src-tauri/src/managed_agents/nest.rs @@ -50,7 +50,7 @@ const NEST_AGENTS_VERSION: u32 = 4; /// Template content version for SKILL.md. /// Bump this when changing `nest_skill.md` to trigger refresh on existing installs. -const NEST_SKILL_VERSION: u32 = 5; +const NEST_SKILL_VERSION: u32 = 6; const BEGIN_MARKER: &str = ""; diff --git a/desktop/src-tauri/src/managed_agents/nest_skill.md b/desktop/src-tauri/src/managed_agents/nest_skill.md index 79a5ea301d..8676139e31 100644 --- a/desktop/src-tauri/src/managed_agents/nest_skill.md +++ b/desktop/src-tauri/src/managed_agents/nest_skill.md @@ -62,7 +62,7 @@ Output varies by command group — `--help` shows flags but not response shapes. | `canvas get` | raw markdown string or `null` — NOT a JSON envelope | | `social *`, `repos get/list` | raw Nostr event JSON INCLUDING `sig` — different contract than read commands above | | `repos protect list` | `{repo_id, protections: [{ref, rules}], unknown_rules, validation_error}` | -| `upload file` | pretty-printed multi-line `BlobDescriptor`: `{url, sha256, size, type, uploaded}` | +| `upload file` | pretty-printed multi-line `BlobDescriptor`: `{url, sha256, size, type, uploaded, filename, attachment_markdown, delivery_hint}` | | `mem get` | raw bytes to stdout, no trailing newline | | `mem hash` | SHA-256 hex string | | `mem set/patch/rm` | nothing to stdout; progress to stderr | @@ -72,6 +72,17 @@ Output varies by command group — `--help` shows flags but not response shapes. **Errors** go to stderr as `{"error": "", "message": ""}`. Exit codes: 0 = success, 1 = input/not-found, 2 = relay/network, 3 = auth, 4 = other, 5 = write conflict (value superseded). +## File Delivery + +To send a file to the user in the current channel, use the message command in one step: + +```bash +buzz messages send --channel \ + --content "Attached file" --file +``` + +`buzz upload file` only stores a blob; it does not deliver the file to the channel. Do not send its bare URL as a substitute for an attachment. Do not ZIP a file or rename its extension to work around delivery. The relay may use a content-addressed `.bin` URL for an unrecognized file, but the original filename and MIME type are carried in the message's `imeta` metadata, so the URL suffix does not determine how Buzz presents the attachment. For Markdown, text, code, JSON, CSV, PDF, and other user-requested files, keep the original filename and send them directly with `messages send --file`. + ## Compact Format `--format compact` is a global flag — position it before the subcommand: diff --git a/desktop/src-tauri/src/managed_agents/readiness/cli_probe.rs b/desktop/src-tauri/src/managed_agents/readiness/cli_probe.rs index 513da4e2a8..be335f404f 100644 --- a/desktop/src-tauri/src/managed_agents/readiness/cli_probe.rs +++ b/desktop/src-tauri/src/managed_agents/readiness/cli_probe.rs @@ -1,6 +1,6 @@ -use std::path::Path; +use std::{path::Path, process::Command}; -use crate::managed_agents::runtime::build_augmented_path; +use crate::managed_agents::runtime::{build_augmented_path, is_batch_shim}; /// Build the augmented PATH for CLI probes and other native child processes /// (auth commands, `buzz-acp models` discovery), including nvm's default @@ -58,8 +58,7 @@ pub(crate) fn login_probe( probe_args: &[&str], augmented_path: Option<&str>, ) -> ProbeOutcome { - let mut command = std::process::Command::new(binary_path); - command.args(&probe_args[1..]); + let mut command = probe_command(binary_path, &probe_args[1..]); if let Some(path) = augmented_path { command.env("PATH", path); } @@ -72,6 +71,20 @@ pub(crate) fn login_probe( } } +pub(crate) fn probe_command(binary_path: &Path, args: &[&str]) -> Command { + #[cfg(windows)] + if is_batch_shim(binary_path) { + let mut command = + Command::new(std::env::var_os("ComSpec").unwrap_or_else(|| "cmd.exe".into())); + command.args(["/D", "/S", "/C"]).arg(binary_path).args(args); + return command; + } + + let mut command = Command::new(binary_path); + command.args(args); + command +} + /// Classify collected probe output into a `ProbeOutcome`. /// /// Shared between `login_probe` (which has the full `Output`) and the @@ -100,6 +113,19 @@ pub(crate) fn classify_probe_output(stderr_bytes: &[u8], exit_success: bool) -> mod tests { use super::{ProbeOutcome, CONFIG_PARSE_SIGNALS}; + #[cfg(windows)] + #[test] + fn login_probe_runs_batch_shim_through_cmd() { + let temp = tempfile::tempdir().expect("temp dir"); + let script_path = temp.path().join("fake-codex.cmd"); + std::fs::write(&script_path, "@echo off\r\nexit /b 0\r\n").expect("write shim"); + + assert_eq!( + super::login_probe(&script_path, &["fake-codex", "login", "status"], None,), + ProbeOutcome::LoggedIn, + ); + } + #[cfg(unix)] #[test] fn login_probe_uses_augmented_path_for_env_shebang_interpreter() { diff --git a/desktop/src-tauri/src/managed_agents/reconcile.rs b/desktop/src-tauri/src/managed_agents/reconcile.rs index 90f05c5750..b937e0ad01 100644 --- a/desktop/src-tauri/src/managed_agents/reconcile.rs +++ b/desktop/src-tauri/src/managed_agents/reconcile.rs @@ -21,7 +21,7 @@ use std::path::Path; use super::{ - agent_events::build_agent_event, + agent_events::{build_agent_event, directory_record_for_readiness}, persona_events::monotonic_created_at, retention::{get_retained_event, open_retention_db, retain_event, RetainedEvent}, ManagedAgentRecord, @@ -41,7 +41,9 @@ pub(crate) fn reconcile_agents_to_events( return; }; - match reconcile_agents_in_dir_at(&base_dir, keys, db_path) { + let personas = super::load_personas(app).unwrap_or_default(); + let global = super::load_global_agent_config(app).unwrap_or_default(); + match reconcile_agents_in_dir_at(&base_dir, keys, db_path, Some((&personas, &global))) { Ok(0) => {} Ok(reconciled) => { eprintln!( @@ -67,13 +69,14 @@ pub(crate) fn reconcile_agents_to_events( /// Returns the number of agents (re)written to the retention store. #[cfg(test)] pub(crate) fn reconcile_agents_in_dir(base_dir: &Path, keys: &nostr::Keys) -> Result { - reconcile_agents_in_dir_at(base_dir, keys, &base_dir.join("retention.db")) + reconcile_agents_in_dir_at(base_dir, keys, &base_dir.join("retention.db"), None) } fn reconcile_agents_in_dir_at( base_dir: &Path, keys: &nostr::Keys, db_path: &Path, + readiness_inputs: Option<(&[super::AgentDefinition], &super::GlobalAgentConfig)>, ) -> Result { let store_path = base_dir.join("managed-agents.json"); if !store_path.exists() { @@ -104,7 +107,20 @@ fn reconcile_agents_in_dir_at( continue; } - if retain_agent_record(&conn, keys, record)? { + let projected = if let Some((personas, global)) = readiness_inputs { + let command = super::record_agent_command(record, personas); + let metadata = super::known_acp_runtime(&command); + let effective = super::resolve_effective_agent_env(record, personas, metadata, global); + let ready = record.backend != super::BackendKind::Local + || matches!( + super::agent_readiness(&effective), + super::AgentReadiness::Ready + ); + directory_record_for_readiness(record, ready) + } else { + record.clone() + }; + if retain_agent_record(&conn, keys, &projected)? { reconciled += 1; } } diff --git a/desktop/src-tauri/src/managed_agents/reserved_env_keys.rs b/desktop/src-tauri/src/managed_agents/reserved_env_keys.rs index afaaa2b4eb..5415d900da 100644 --- a/desktop/src-tauri/src/managed_agents/reserved_env_keys.rs +++ b/desktop/src-tauri/src/managed_agents/reserved_env_keys.rs @@ -41,6 +41,10 @@ pub(crate) const RESERVED_ENV_KEYS: &[&str] = &[ "BUZZ_ACP_AGENT_COMMAND", "BUZZ_ACP_AGENT_ARGS", "BUZZ_ACP_MCP_COMMAND", + // Local Codex task identity comes from the Desktop-only binding store. It + // must never be redirected through persona or agent environment input. + "BUZZ_ACP_CODEX_TASK_ID", + "BUZZ_ACP_CODEX_TASK_WORKSPACE", // Control-plane parallelism: the Desktop resolves the effective // worker-pool size (applying any per-harness cap) and writes it into // launch.policy_env. A user-supplied BUZZ_ACP_AGENTS would bypass the @@ -51,6 +55,7 @@ pub(crate) const RESERVED_ENV_KEYS: &[&str] = &[ // gate diverge from the saved/UI-visible settings. "BUZZ_ACP_RESPOND_TO", "BUZZ_ACP_RESPOND_TO_ALLOWLIST", + "BUZZ_ACP_ALLOW_NON_OWNER_DM", "BUZZ_ACP_ALLOWED_RESPOND_TO", "BUZZ_ACP_AGENT_OWNER", // Stable agent identity used for git attribution and private-conversation diff --git a/desktop/src-tauri/src/managed_agents/runtime.rs b/desktop/src-tauri/src/managed_agents/runtime.rs index b1c342e995..351296659d 100644 --- a/desktop/src-tauri/src/managed_agents/runtime.rs +++ b/desktop/src-tauri/src/managed_agents/runtime.rs @@ -8,15 +8,17 @@ use crate::{ managed_agents::{ append_log_marker, known_acp_runtime, login_shell_path, managed_agent_log_path, missing_command_message, normalize_agent_args, open_log_file, resolve_command, - spawn_key_refusal, KnownAcpRuntime, ManagedAgentPairRuntime, ManagedAgentRecord, - ManagedAgentRuntimeKey, ManagedAgentSummary, + resolve_effective_mcp_command, spawn_key_refusal, KnownAcpRuntime, ManagedAgentPairRuntime, + ManagedAgentRecord, ManagedAgentRuntimeKey, ManagedAgentSummary, DEFAULT_ACP_COMMAND, }, util::now_iso, }; mod path; pub(in crate::managed_agents) use path::build_augmented_path; -pub(crate) use path::{compose_path_entries, should_skip_claude_executable, should_use_inherited}; +pub(crate) use path::{ + compose_path_entries, is_batch_shim, should_skip_claude_executable, should_use_inherited, +}; pub(crate) use super::access_policy::{build_respond_to_env_with_policy, RespondToEnv}; @@ -26,6 +28,27 @@ pub(crate) use metadata::{ DISPLAY_NAME_ENV_VAR, SESSION_TITLE_ENV_VAR, }; +fn bundled_harness_candidate() -> Option { + let exe = std::env::current_exe().ok()?; + let dir = exe.parent()?; + let name = if cfg!(windows) { + "buzz-acp.exe" + } else { + DEFAULT_ACP_COMMAND + }; + let candidate = dir.join(name); + candidate.is_file().then_some(candidate) +} + +fn resolve_acp_harness_command(command: &str) -> Option { + if command.trim() == DEFAULT_ACP_COMMAND { + if let Some(candidate) = bundled_harness_candidate() { + return Some(candidate); + } + } + resolve_command(command) +} + mod stop; pub(crate) use stop::managed_agent_runtime_keys; pub use stop::{stop_managed_agent_process, stop_managed_agent_workspace_pair}; @@ -197,32 +220,42 @@ pub fn build_managed_agent_summary( let global_for_summary = crate::managed_agents::load_global_agent_config(app).unwrap_or_default(); + let codex_task_binding = super::load_codex_task_binding(app, &record.pubkey)?; let effective_cfg = crate::managed_agents::effective_config::resolve_effective_config( record, personas, &global_for_summary, ); - let (effective_model, effective_provider, effective_prompt, model_source) = match effective_cfg - { - crate::managed_agents::effective_config::EffectiveConfigResult::Resolved(cfg) => { - let source = cfg.model.source.clone(); - ( - cfg.model.value, - cfg.provider.value, - cfg.system_prompt.value, - Some(source), - ) - } - crate::managed_agents::effective_config::EffectiveConfigResult::OrphanedInstance { - record_pubkey, - missing_persona_id, - } => { - eprintln!( + let (mut effective_model, effective_provider, effective_prompt, mut model_source) = + match effective_cfg { + crate::managed_agents::effective_config::EffectiveConfigResult::Resolved(cfg) => { + let source = cfg.model.source.clone(); + ( + cfg.model.value, + cfg.provider.value, + cfg.system_prompt.value, + Some(source), + ) + } + crate::managed_agents::effective_config::EffectiveConfigResult::OrphanedInstance { + record_pubkey, + missing_persona_id, + } => { + eprintln!( "orphaned agent instance: pubkey={record_pubkey}, missing_persona_id={missing_persona_id}" ); - (None, None, None, None) - } - }; + (None, None, None, None) + } + }; + if let Some(task_model) = codex_task_binding + .as_ref() + .and_then(|binding| binding.model.as_deref()) + .map(str::trim) + .filter(|model| !model.is_empty()) + { + effective_model = Some(task_model.to_string()); + model_source = Some(crate::managed_agents::effective_config::ConfigSource::CodexTask); + } // Restart badge: the running process stamped the effective spawn config // it was launched with; recompute a prospective one from current disk @@ -291,14 +324,13 @@ pub fn build_managed_agent_summary( env: Default::default(), } }); - let effective_mcp_command = known_acp_runtime(&descriptor.command) - .and_then(|r| r.mcp_command) - .unwrap_or("") - .to_string(); + let effective_mcp_command = + resolve_effective_mcp_command(&descriptor.command, &record.mcp_command); Ok(ManagedAgentSummary { pubkey: record.pubkey.clone(), name: record.name.clone(), + codex_task_binding, persona_id: record.persona_id.clone(), runtime: record.runtime.clone(), team_id: record.team_id.clone(), @@ -338,6 +370,7 @@ pub fn build_managed_agent_summary( log_path, respond_to: record.respond_to, respond_to_allowlist: record.respond_to_allowlist.clone(), + allow_non_owner_dm: record.allow_non_owner_dm, }) } @@ -414,9 +447,9 @@ pub fn spawn_agent_child( let runtime_key = ManagedAgentRuntimeKey::new(record.pubkey.clone(), relay_url)?; // Resolve the effective harness (agent command) from the linked persona, so // persona harness edits propagate on the next spawn; an explicit per-agent - // override wins. `agent_args` and `mcp_command` are pure derivations of the - // command, so we recompute them from the effective value rather than the - // frozen record snapshot. Mirrors the model resolution below. + // override wins. `agent_args` is derived from that effective command. MCP + // metadata is catalog-derived for known runtimes and falls back to the + // configured value for explicit custom wrappers. let personas = super::load_personas(app).unwrap_or_default(); let teams = super::load_teams(app).unwrap_or_default(); // Load global config once; used for runtime_metadata_env_vars (model/provider fallback) @@ -434,7 +467,7 @@ pub fn spawn_agent_child( // inherits it — no caller can bypass this by reaching `spawn_agent_child` // directly. Checked before any side effect (log marker, log file, process // spawn) so a refused spawn leaves no trace. - let effective_cfg = crate::managed_agents::effective_config::resolve_effective_config( + let mut effective_cfg = crate::managed_agents::effective_config::resolve_effective_config( record, &personas, &global, ) .require_resolved()?; @@ -457,6 +490,18 @@ pub fn spawn_agent_child( })?; let effective_command = &descriptor.command; let agent_args = &descriptor.args; + let codex_task_binding = super::task_binding_for_spawn(app, record)?; + if let Some(task_model) = codex_task_binding + .as_ref() + .and_then(|binding| binding.model.as_deref()) + .map(str::trim) + .filter(|model| !model.is_empty()) + { + effective_cfg.model = crate::managed_agents::effective_config::ResolvedField { + value: Some(task_model.to_string()), + source: crate::managed_agents::effective_config::ConfigSource::CodexTask, + }; + } let log_path = super::managed_agent_runtime_log_path(app, &runtime_key)?; append_log_marker( @@ -473,15 +518,14 @@ pub fn spawn_agent_child( let stderr = stdout .try_clone() .map_err(|error| format!("failed to clone log handle: {error}"))?; - let resolved_acp_command = resolve_command(&record.acp_command) + let resolved_acp_command = resolve_acp_harness_command(&record.acp_command) .ok_or_else(|| missing_command_message(&record.acp_command, "ACP harness command"))?; - let effective_mcp_command = known_acp_runtime(effective_command) - .and_then(|r| r.mcp_command) - .unwrap_or(""); + let effective_mcp_command = + resolve_effective_mcp_command(effective_command, &record.mcp_command); let resolved_mcp_command: Option = if effective_mcp_command.is_empty() { None } else { - match resolve_command(effective_mcp_command) { + match resolve_command(&effective_mcp_command) { Some(path) => Some(path), None => { eprintln!( @@ -518,9 +562,12 @@ pub fn spawn_agent_child( ); let mut command = std::process::Command::new(&resolved_acp_command); - if let Some(home) = super::default_agent_workdir() { - command.current_dir(home); - } + super::configure_task_bound_command(&mut command, codex_task_binding.as_ref(), lazy); + super::configure_shared_app_server( + &mut command, + codex_task_binding.as_ref(), + &resolved_acp_command, + ); command.stdin(std::process::Stdio::null()); command.stdout(std::process::Stdio::from(stdout)); command.stderr(std::process::Stdio::from(stderr)); @@ -530,8 +577,10 @@ pub fn spawn_agent_child( command.env("RUST_LOG", child_rust_log_filter()); command.env("BUZZ_PRIVATE_KEY", &record.private_key_nsec); command.env("BUZZ_RELAY_URL", &effective_relay_url); - command.env("BUZZ_ACP_LAZY_POOL", if lazy { "true" } else { "false" }); - command.env("BUZZ_ACP_IDLE_POOL_SLEEP", idle_pool_sleep_env(lazy)); + command.env( + "BUZZ_ACP_IDLE_POOL_SLEEP", + idle_pool_sleep_env(lazy && codex_task_binding.is_none()), + ); command.env("BUZZ_ACP_AGENT_COMMAND", &resolved_agent_command); command.env("BUZZ_ACP_AGENT_ARGS", agent_args.join(",")); match &resolved_mcp_command { @@ -675,7 +724,11 @@ pub fn spawn_agent_child( if let Some(max_dur) = record.max_turn_duration_seconds { command.env("BUZZ_ACP_MAX_TURN_DURATION", max_dur.to_string()); } - let acp_n = super::acp_agents_value(effective_command, record.parallelism); + let acp_n = super::task_bound_worker_count( + effective_command, + record.parallelism, + codex_task_binding.as_ref(), + ); command.env("BUZZ_ACP_AGENTS", acp_n); command.env("BUZZ_ACP_MULTIPLE_EVENT_HANDLING", "steer"); command.env("BUZZ_ACP_DEDUP", "queue"); @@ -773,6 +826,14 @@ pub fn spawn_agent_child( for key in &gate_remove { command.env_remove(key); } + command.env( + "BUZZ_ACP_ALLOW_NON_OWNER_DM", + if record.allow_non_owner_dm { + "true" + } else { + "false" + }, + ); command.env("BUZZ_ACP_RELAY_OBSERVER", "true"); diff --git a/desktop/src-tauri/src/managed_agents/spawn_snapshot.rs b/desktop/src-tauri/src/managed_agents/spawn_snapshot.rs index ba2129c984..481aa09cd4 100644 --- a/desktop/src-tauri/src/managed_agents/spawn_snapshot.rs +++ b/desktop/src-tauri/src/managed_agents/spawn_snapshot.rs @@ -32,9 +32,10 @@ use serde::Serialize; use super::{ effective_config::{resolve_effective_config, EffectiveConfigResult}, - known_acp_runtime, normalize_agent_args, + normalize_agent_args, persona_events::preview_prospective_persona_snapshot, readiness::EffectiveHarnessDescriptor, + resolve_effective_mcp_command, runtime::{resolve_session_title, SESSION_TITLE_ENV_VAR}, types::{AgentDefinition, ManagedAgentRecord, TeamRecord}, GlobalAgentConfig, @@ -97,7 +98,7 @@ pub(crate) struct SpawnConfigSnapshot { /// The effective agent command the harness drives. pub command: String, pub args: Vec, - /// Catalog-derived from `command`; `""` when the runtime has none. + /// Catalog-derived for known runtimes, with configured fallback for custom wrappers. pub mcp_command: String, /// Fully layered process env: baked floor -> runtime metadata -> /// definition -> global -> persona -> agent. @@ -141,10 +142,7 @@ impl SpawnConfigSnapshot { acp_command: record.acp_command.clone(), command: descriptor.command.clone(), args: descriptor.args.clone(), - mcp_command: known_acp_runtime(&descriptor.command) - .and_then(|runtime| runtime.mcp_command) - .unwrap_or("") - .to_string(), + mcp_command: resolve_effective_mcp_command(&descriptor.command, &record.mcp_command), env: descriptor.env.clone(), relay_url: relay_url.to_string(), team_instructions: team_instructions.map(str::to_string), diff --git a/desktop/src-tauri/src/managed_agents/storage.rs b/desktop/src-tauri/src/managed_agents/storage.rs index 652bb9b9ea..7d92fef7fd 100644 --- a/desktop/src-tauri/src/managed_agents/storage.rs +++ b/desktop/src-tauri/src/managed_agents/storage.rs @@ -263,6 +263,16 @@ pub fn load_managed_agents(app: &AppHandle) -> Result, S let mut records = load_agent_store(app)?; records.retain(|record| !record.pubkey.is_empty()); hydrate_keys(&mut records); + // Older task identities predate model capture. Hydrate them from the + // local-only binding store so task-owned model/effort still beats the + // workspace-wide Buzz default on summaries and spawn. + for record in &mut records { + if record.model.is_none() { + if let Ok(Some(binding)) = super::load_codex_task_binding(app, &record.pubkey) { + record.model = binding.model; + } + } + } Ok(records) } @@ -900,6 +910,13 @@ pub fn meaningful_agent_error_from_log(path: &Path) -> Option { code: Some(-32002), }); } + if let Some(detail) = line.strip_prefix("Error: failed to load identity-bound Codex task:") + { + return Some(AgentLogError { + message: format!("Codex task load failed: {}", detail.trim()), + code: Some(-32004), + }); + } None }) } diff --git a/desktop/src-tauri/src/managed_agents/storage_tests.rs b/desktop/src-tauri/src/managed_agents/storage_tests.rs index 9943c6b3ac..40e4edf9e1 100644 --- a/desktop/src-tauri/src/managed_agents/storage_tests.rs +++ b/desktop/src-tauri/src/managed_agents/storage_tests.rs @@ -387,6 +387,19 @@ fn meaningful_agent_error_from_log_does_not_promote_midline_auth_text() { assert!(super::meaningful_agent_error_from_log(file.path()).is_none()); } +#[test] +fn meaningful_agent_error_from_log_promotes_codex_task_load_timeout() { + let file = write_log( + "noise\nError: failed to load identity-bound Codex task: Request timeout — agent did not respond within 60s\n", + ); + let result = super::meaningful_agent_error_from_log(file.path()).unwrap(); + assert_eq!( + result.message, + "Codex task load failed: Request timeout — agent did not respond within 60s" + ); + assert_eq!(result.code, Some(-32004)); +} + #[test] fn strips_ansi_from_typical_tracing_line() { let input = "\x1b[2m2026-05-27T15:16:32\x1b[0m \x1b[32m INFO\x1b[0m \x1b[2mbuzz_acp\x1b[0m\x1b[2m:\x1b[0m starting"; diff --git a/desktop/src-tauri/src/managed_agents/types.rs b/desktop/src-tauri/src/managed_agents/types.rs index e5be105fed..9139aceaeb 100644 --- a/desktop/src-tauri/src/managed_agents/types.rs +++ b/desktop/src-tauri/src/managed_agents/types.rs @@ -138,6 +138,7 @@ impl AgentDefinition { last_error_code: None, respond_to: RespondTo::default(), respond_to_allowlist: Vec::new(), + allow_non_owner_dm: false, display_name: Some(self.display_name), slug: Some(self.id), runtime: self.runtime, @@ -197,6 +198,10 @@ impl ManagedAgentRecord { pub struct RelayAgentInfo { pub pubkey: String, pub name: String, + #[serde(default)] + pub owner_pubkey: Option, + #[serde(default)] + pub deleted: bool, pub agent_type: String, pub channels: Vec, #[serde(default)] @@ -255,10 +260,9 @@ pub struct ManagedAgentRecord { #[serde(default)] pub agent_command_override: Option, pub agent_args: Vec, - /// Create-time snapshot of the catalog MCP command. Never read at spawn — - /// the effective MCP command is always re-derived from the runtime catalog - /// (`known_acp_runtime`) — and no longer written by updates. Kept for - /// serde compatibility with existing stores. + /// Create-time snapshot of the catalog MCP command. Known runtimes are + /// re-derived from the runtime catalog; custom wrapper commands use this as + /// a fallback so they can preserve the wrapped runtime's MCP sidecar. pub mcp_command: String, /// Deprecated: `BUZZ_ACP_TURN_TIMEOUT` is ignored by the harness and the /// desktop no longer emits or edits it. Kept for serde compatibility with @@ -352,6 +356,10 @@ pub struct ManagedAgentRecord { /// Preserved across mode toggles so users don't lose state. #[serde(default)] pub respond_to_allowlist: Vec, + /// Allow non-owner authors to trigger this agent in direct messages. + /// Defaults to false; public-channel access is controlled by `respond_to`. + #[serde(default)] + pub allow_non_owner_dm: bool, /// Optional display name distinct from the unique `name` handle. Absorbed /// from `AgentDefinition.display_name` (unified agent model, Phase 1A). #[serde(default, skip_serializing_if = "Option::is_none")] @@ -494,6 +502,8 @@ pub struct ManagedAgentProcess { pub struct ManagedAgentSummary { pub pubkey: String, pub name: String, + /// Desktop-local binding. Never published in the relay agent record. + pub codex_task_binding: Option, pub persona_id: Option, /// The record's harness/runtime id (mirror of `ManagedAgentRecord.runtime`). /// Lets the UI count agents referencing a harness definition (e.g. in the @@ -566,6 +576,7 @@ pub struct ManagedAgentSummary { pub log_path: String, pub respond_to: RespondTo, pub respond_to_allowlist: Vec, + pub allow_non_owner_dm: bool, } #[derive(Debug, Serialize)] diff --git a/desktop/src-tauri/src/managed_agents/types/requests.rs b/desktop/src-tauri/src/managed_agents/types/requests.rs index e28b0bd461..7175ac0452 100644 --- a/desktop/src-tauri/src/managed_agents/types/requests.rs +++ b/desktop/src-tauri/src/managed_agents/types/requests.rs @@ -131,6 +131,15 @@ pub struct UpdatePersonaRequest { #[serde(rename_all = "camelCase")] pub struct CreateManagedAgentRequest { pub name: String, + /// Existing local Codex task to promote into this agent identity. + /// The backend resolves its workspace from Codex-owned metadata; callers + /// cannot supply an arbitrary workspace path. + #[serde(default)] + pub codex_task_id: Option, + /// Legacy transport field retained for older clients. Task-bound agents + /// always use the computer-level Codex shared runtime. + #[serde(default)] + pub codex_app_server_url: Option, #[serde(default)] pub persona_id: Option, /// Optional deployment-time team binding for runtime instruction layering. @@ -253,6 +262,9 @@ pub struct UpdateManagedAgentRequest { /// normalized server-side). #[serde(default)] pub respond_to_allowlist: Option>, + /// Absent = don't touch. Present = allow non-owner authors in DMs. + #[serde(default)] + pub allow_non_owner_dm: Option, } #[cfg(test)] diff --git a/desktop/src-tauri/src/managed_agents/types/tests.rs b/desktop/src-tauri/src/managed_agents/types/tests.rs index 1db7b9b524..76205783be 100644 --- a/desktop/src-tauri/src/managed_agents/types/tests.rs +++ b/desktop/src-tauri/src/managed_agents/types/tests.rs @@ -703,6 +703,7 @@ fn summary_fixture( super::ManagedAgentSummary { pubkey: "aa".repeat(32), name: "test".into(), + codex_task_binding: None, persona_id: None, runtime: None, team_id: None, diff --git a/desktop/src-tauri/src/native_websocket.rs b/desktop/src-tauri/src/native_websocket.rs index 128f2df79d..16f0e6ce4d 100644 --- a/desktop/src-tauri/src/native_websocket.rs +++ b/desktop/src-tauri/src/native_websocket.rs @@ -1,16 +1,24 @@ -use std::{collections::HashMap, sync::Arc, time::Duration}; +use std::{collections::HashMap, net::IpAddr, sync::Arc, time::Duration}; use futures_util::{SinkExt, StreamExt}; use serde::{Deserialize, Serialize}; use tauri::{ipc::Channel, plugin::TauriPlugin, Manager, Runtime}; -use tokio::sync::{mpsc, oneshot, Mutex}; +use tokio::{ + net::TcpStream, + sync::{mpsc, oneshot, Mutex}, +}; use tokio_tungstenite::{ - connect_async, - tungstenite::protocol::{frame::coding::CloseCode, CloseFrame, Message}, + client_async, connect_async, + tungstenite::{ + client::IntoClientRequest, + protocol::{frame::coding::CloseCode, CloseFrame, Message}, + }, + MaybeTlsStream, WebSocketStream, }; use tokio_util::sync::CancellationToken; const CONNECT_TIMEOUT: Duration = Duration::from_secs(10); +const LAN_CONNECT_TIMEOUT: Duration = Duration::from_millis(650); const WRITE_TIMEOUT: Duration = Duration::from_secs(10); const SHUTDOWN_TIMEOUT: Duration = Duration::from_millis(250); const SEND_QUEUE_CAPACITY: usize = 64; @@ -22,6 +30,110 @@ pub(crate) fn install_crypto_provider() { type Id = u32; +type NativeSocket = WebSocketStream>; + +#[derive(Debug, Default, Deserialize)] +#[serde(rename_all = "camelCase")] +struct ConnectConfig { + transport_url: Option, +} + +pub(crate) fn normalize_lan_relay_url(input: Option<&str>) -> Result, String> { + let Some(trimmed) = input.map(str::trim).filter(|value| !value.is_empty()) else { + return Ok(None); + }; + let parsed = url::Url::parse(trimmed) + .map_err(|error| format!("invalid Campus / LAN relay URL: {error}"))?; + if parsed.scheme() != "ws" { + return Err("Campus / LAN relay URL must use ws://".to_string()); + } + if !parsed.username().is_empty() + || parsed.password().is_some() + || parsed.query().is_some() + || parsed.fragment().is_some() + || parsed.path() != "/" + { + return Err( + "Campus / LAN relay URL cannot contain credentials, a path, or parameters".to_string(), + ); + } + let is_private = match parsed.host() { + Some(url::Host::Domain(host)) => host.eq_ignore_ascii_case("localhost"), + Some(url::Host::Ipv4(address)) => { + buzz_core_pkg::network::is_private_ip(&IpAddr::V4(address)) + && !address.is_unspecified() + && !address.is_broadcast() + } + Some(url::Host::Ipv6(address)) => { + buzz_core_pkg::network::is_private_ip(&IpAddr::V6(address)) && !address.is_unspecified() + } + None => false, + }; + if !is_private { + return Err( + "Campus / LAN relay URL must use localhost or a private IP address".to_string(), + ); + } + Ok(Some(trimmed.trim_end_matches('/').to_string())) +} + +async fn connect_via_lan(canonical_url: &str, lan_url: &str) -> Result { + let normalized = normalize_lan_relay_url(Some(lan_url))? + .ok_or_else(|| "Campus / LAN relay URL is empty".to_string())?; + let parsed = url::Url::parse(&normalized).map_err(|error| error.to_string())?; + let host = parsed + .host_str() + .ok_or_else(|| "Campus / LAN relay URL has no host".to_string())?; + let port = parsed + .port_or_known_default() + .ok_or_else(|| "Campus / LAN relay URL has no port".to_string())?; + let request = canonical_url + .into_client_request() + .map_err(|error| format!("invalid canonical relay URL: {error}"))?; + + let stream = TcpStream::connect((host, port)) + .await + .map_err(|error| format!("Campus / LAN relay connection failed: {error}"))?; + let (socket, _) = client_async(request, MaybeTlsStream::Plain(stream)) + .await + .map_err(|error| format!("Campus / LAN relay handshake failed: {error}"))?; + Ok(socket) +} + +async fn connect_with_fallback( + canonical_url: &str, + lan_url: Option<&str>, +) -> Result { + if let Some(lan_url) = lan_url { + match tokio::time::timeout(LAN_CONNECT_TIMEOUT, connect_via_lan(canonical_url, lan_url)) + .await + { + Ok(Ok(socket)) => { + eprintln!( + "buzz-desktop: relay transport=campus-lan dial={lan_url} canonical={canonical_url}" + ); + return Ok(socket); + } + Ok(Err(error)) => { + eprintln!( + "buzz-desktop: Campus / LAN relay unavailable ({error}); falling back to {canonical_url}" + ); + } + Err(_) => { + eprintln!( + "buzz-desktop: Campus / LAN relay unavailable (650 ms timeout); falling back to {canonical_url}" + ); + } + } + } + + let (socket, _) = connect_async(canonical_url) + .await + .map_err(|error| error.to_string())?; + eprintln!("buzz-desktop: relay transport=canonical url={canonical_url}"); + Ok(socket) +} + #[derive(Debug, Deserialize)] #[serde(tag = "type", content = "data")] pub(crate) enum WebSocketMessage { @@ -125,11 +237,15 @@ async fn open_connection( manager: &WebSocketManager, url: &str, on_message: Channel, + config: Option, ) -> Result { let connect_cancel = manager.connect_cancel.lock().await.clone(); - let (socket, _) = tokio::select! { + let socket = tokio::select! { _ = connect_cancel.cancelled() => return Err("WebSocket connection cancelled".to_string()), - result = tokio::time::timeout(CONNECT_TIMEOUT, connect_async(url)) => result + result = tokio::time::timeout( + CONNECT_TIMEOUT, + connect_with_fallback(url, config.as_ref().and_then(|value| value.transport_url.as_deref())), + ) => result .map_err(|_| "WebSocket connection timed out".to_string())? .map_err(|error| error.to_string())?, }; @@ -177,9 +293,9 @@ async fn connect( manager: tauri::State<'_, WebSocketManager>, url: String, on_message: Channel, - _config: Option, + config: Option, ) -> Result { - open_connection(manager.inner(), &url, on_message).await + open_connection(manager.inner(), &url, on_message, config).await } pub(crate) async fn send_message( @@ -386,7 +502,7 @@ mod tests { }); let manager = WebSocketManager::default(); - let id = open_connection(&manager, &format!("ws://{address}"), silent_channel()) + let id = open_connection(&manager, &format!("ws://{address}"), silent_channel(), None) .await .unwrap(); send_message(&manager, id, WebSocketMessage::Text("live-probe".into())) @@ -408,6 +524,70 @@ mod tests { .unwrap(); } + #[test] + fn lan_url_validation_accepts_private_transport_only() { + assert_eq!( + normalize_lan_relay_url(Some(" ws://10.24.11.82:3000/ ")).unwrap(), + Some("ws://10.24.11.82:3000".to_string()) + ); + assert_eq!(normalize_lan_relay_url(Some(" ")).unwrap(), None); + assert!(normalize_lan_relay_url(Some("wss://10.24.11.82:3000")).is_err()); + assert!(normalize_lan_relay_url(Some("ws://relay.example.com:3000")).is_err()); + assert!(normalize_lan_relay_url(Some("ws://8.8.8.8:3000")).is_err()); + assert!(normalize_lan_relay_url(Some("ws://10.24.11.82:3000/path")).is_err()); + } + + #[tokio::test] + async fn lan_transport_preserves_canonical_host_header() { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let address = listener.local_addr().unwrap(); + let (host_tx, host_rx) = oneshot::channel(); + let host_tx = Arc::new(std::sync::Mutex::new(Some(host_tx))); + let server = tokio::spawn(async move { + let (stream, _) = listener.accept().await.unwrap(); + let callback_tx = host_tx.clone(); + let mut socket = tokio_tungstenite::accept_hdr_async( + stream, + move |request: &tokio_tungstenite::tungstenite::handshake::server::Request, + response: tokio_tungstenite::tungstenite::handshake::server::Response| { + let host = request + .headers() + .get("host") + .and_then(|value| value.to_str().ok()) + .unwrap_or_default() + .to_string(); + if let Some(sender) = callback_tx.lock().unwrap().take() { + let _ = sender.send(host); + } + Ok(response) + }, + ) + .await + .unwrap(); + while let Some(message) = socket.next().await { + if matches!(message, Ok(Message::Close(_))) { + break; + } + } + }); + + let manager = WebSocketManager::default(); + let id = open_connection( + &manager, + "wss://future-relay.example.com", + silent_channel(), + Some(ConnectConfig { + transport_url: Some(format!("ws://{address}")), + }), + ) + .await + .unwrap(); + assert_eq!(host_rx.await.unwrap(), "future-relay.example.com"); + + manager.disconnect(id).await; + server.await.unwrap(); + } + #[tokio::test] async fn eof_removes_connection() { let manager = WebSocketManager::default(); diff --git a/desktop/src-tauri/src/nostr_convert.rs b/desktop/src-tauri/src/nostr_convert.rs index ec4970e0c9..525a2f8b5f 100644 --- a/desktop/src-tauri/src/nostr_convert.rs +++ b/desktop/src-tauri/src/nostr_convert.rs @@ -13,7 +13,7 @@ use nostr::{Event, ToBech32}; use serde_json::{json, Value}; use crate::models::*; - +pub(crate) mod relay_agents; mod user_search; pub use user_search::{ list_user_search_results, rank_user_search_results, search_users_from_events, diff --git a/desktop/src-tauri/src/nostr_convert/relay_agents.rs b/desktop/src-tauri/src/nostr_convert/relay_agents.rs new file mode 100644 index 0000000000..e59367d810 --- /dev/null +++ b/desktop/src-tauri/src/nostr_convert/relay_agents.rs @@ -0,0 +1,333 @@ +use std::collections::{BTreeMap, BTreeSet, HashMap}; + +use buzz_core_pkg::kind::{KIND_AGENT_PROFILE, KIND_IA_ARCHIVED_LIST, KIND_MANAGED_AGENT}; +use nostr::Event; + +use super::{agents_from_events, first_tag_value, profile_valid_oa_owner_pubkey}; +use crate::managed_agents::{agent_events::managed_agent_content_from_event, RelayAgentInfo}; + +/// Return the agent identities referenced by managed-agent definition events. +/// +/// The `d` tag identifies the agent while the event author identifies its +/// claimed owner. Callers still need to verify that claim against the agent's +/// NIP-OA profile before trusting the definition. +pub(crate) fn managed_agent_target_pubkeys(events: &[Event]) -> Vec { + events + .iter() + .filter(|event| event.kind.as_u16() as u32 == KIND_MANAGED_AGENT) + .filter_map(|event| first_tag_value(event, "d")) + .map(str::trim) + .filter(|pubkey| !pubkey.is_empty()) + .map(str::to_ascii_lowercase) + .collect::>() + .into_iter() + .collect() +} + +/// Build the public agent directory from self-authored kind:10100 profiles and +/// owner-authored kind:30177 managed-agent definitions. +/// +/// A managed definition is accepted only when the target agent's kind:0 +/// profile contains a valid NIP-OA attestation naming the definition author as +/// its owner. This prevents another community member from publishing a forged +/// definition for someone else's agent identity. +pub(crate) fn relay_agents_from_events( + events: &[Event], + identity_profiles: &[Event], +) -> Vec { + let mut deleted: HashMap = HashMap::new(); + for event in events.iter().filter(|event| event.kind.as_u16() == 5) { + for tag in event.tags.iter() { + let values = tag.as_slice(); + let Some(coordinate) = values.get(1) else { continue }; + let mut parts = coordinate.split(':'); + if parts.next() != Some("30177") { continue; } + let Some(owner) = parts.next() else { continue }; + let Some(agent) = parts.next() else { continue }; + if agent.len() == 64 + && owner.len() == 64 + && event.pubkey.to_hex().eq_ignore_ascii_case(owner) + { + deleted.insert(agent.to_ascii_lowercase(), owner.to_ascii_lowercase()); + } + } + } + for event in events + .iter() + .filter(|event| event.kind.as_u16() as u32 == KIND_IA_ARCHIVED_LIST) + { + for tag in event.tags.iter() { + let values = tag.as_slice(); + if values.first().map(String::as_str) == Some("p") { + if let Some(agent) = values.get(1).filter(|value| value.len() == 64) { + deleted.entry(agent.to_ascii_lowercase()).or_default(); + } + } + } + } + let directory_events: Vec = events + .iter() + .filter(|event| event.kind.as_u16() as u32 == KIND_AGENT_PROFILE) + .cloned() + .collect(); + let directory = agents_from_events(&directory_events); + let mut agents: Vec = directory + .get("agents") + .cloned() + .and_then(|value| serde_json::from_value(value).ok()) + .unwrap_or_default(); + let mut agent_indexes: HashMap = agents + .iter() + .enumerate() + .map(|(index, agent)| (agent.pubkey.to_ascii_lowercase(), index)) + .collect(); + + let verified_owners: HashMap = identity_profiles + .iter() + .filter(|event| event.kind.as_u16() == 0) + .filter_map(|event| { + profile_valid_oa_owner_pubkey(event) + .map(|owner| (event.pubkey.to_hex(), owner.to_ascii_lowercase())) + }) + .collect(); + let profile_names: HashMap = identity_profiles + .iter() + .filter_map(|event| { + let value: serde_json::Value = serde_json::from_str(event.content.as_ref()).ok()?; + let name = value + .get("display_name") + .or_else(|| value.get("name")) + .and_then(serde_json::Value::as_str) + .map(str::trim) + .filter(|name| !name.is_empty())?; + Some((event.pubkey.to_hex().to_ascii_lowercase(), name.to_string())) + }) + .collect(); + + // Parameterized replaceable events should already arrive de-duplicated, + // but choosing the newest valid event here keeps the converter deterministic + // with permissive or test relays. + let mut managed_by_agent: BTreeMap = BTreeMap::new(); + for event in events + .iter() + .filter(|event| event.kind.as_u16() as u32 == KIND_MANAGED_AGENT) + { + let Some(target) = first_tag_value(event, "d") + .map(str::trim) + .filter(|target| !target.is_empty()) + .map(str::to_ascii_lowercase) + else { + continue; + }; + let owner = event.pubkey.to_hex(); + if verified_owners.get(&target) != Some(&owner) { + continue; + } + let replace = managed_by_agent.get(&target).is_none_or(|existing| { + (event.created_at.as_secs(), event.id.to_hex()) + > (existing.created_at.as_secs(), existing.id.to_hex()) + }); + if replace { + managed_by_agent.insert(target, event); + } + } + + for agent in &mut agents { + let key = agent.pubkey.to_ascii_lowercase(); + agent.owner_pubkey = verified_owners + .get(&key) + .cloned() + .or_else(|| deleted.get(&key).cloned()); + agent.deleted = deleted.contains_key(&key); + } + + for (pubkey, event) in managed_by_agent { + let Ok(content) = managed_agent_content_from_event(event) else { + continue; + }; + if let Some(index) = agent_indexes.get(&pubkey).copied() { + let agent = &mut agents[index]; + agent.owner_pubkey = verified_owners + .get(&pubkey) + .cloned() + .or_else(|| deleted.get(&pubkey).cloned()); + agent.deleted = deleted.contains_key(&pubkey); + if !content.name.trim().is_empty() { + agent.name = content.name; + } + agent.respond_to = Some(content.respond_to); + agent.respond_to_allowlist = content.respond_to_allowlist; + continue; + } + + agent_indexes.insert(pubkey.clone(), agents.len()); + agents.push(RelayAgentInfo { + pubkey: pubkey.clone(), + name: content.name, + owner_pubkey: verified_owners.get(&pubkey).cloned().or_else(|| deleted.get(&pubkey).cloned()), + deleted: deleted.contains_key(&pubkey), + agent_type: "agent".to_string(), + channels: Vec::new(), + channel_ids: Vec::new(), + capabilities: Vec::new(), + status: "offline".to_string(), + respond_to: Some(content.respond_to), + respond_to_allowlist: content.respond_to_allowlist, + }); + } + + // Tombstones may remove the managed definition before the next directory + // refresh. Keep a useful historical row so a newly-created same-name agent + // cannot be confused with the retired identity. + for (pubkey, owner) in deleted { + if agent_indexes.contains_key(&pubkey) { continue; } + let name = agents + .iter() + .find(|agent| agent.pubkey.eq_ignore_ascii_case(&pubkey)) + .map(|agent| agent.name.clone()) + .or_else(|| profile_names.get(&pubkey).cloned()) + .unwrap_or_else(|| format!("Agent {}", &pubkey[..8.min(pubkey.len())])); + agent_indexes.insert(pubkey.clone(), agents.len()); + agents.push(RelayAgentInfo { + pubkey, + name, + owner_pubkey: (!owner.is_empty()).then_some(owner), + deleted: true, + agent_type: "agent".to_string(), + channels: Vec::new(), + channel_ids: Vec::new(), + capabilities: Vec::new(), + status: "offline".to_string(), + respond_to: None, + respond_to_allowlist: Vec::new(), + }); + } + + agents +} + +#[cfg(test)] +mod tests { + use buzz_core_pkg::kind::{KIND_AGENT_PROFILE, KIND_MANAGED_AGENT}; + use nostr::{Event, EventBuilder, Keys, Kind, Tag}; + + use super::*; + + fn oa_profile_event_for(agent_keys: &Keys, owner_keys: &Keys, content: &str) -> Event { + let agent_pubkey = agent_keys.public_key(); + let tag_json = buzz_sdk_pkg::nip_oa::compute_auth_tag(owner_keys, &agent_pubkey, "") + .expect("compute auth tag"); + let tag_values: Vec = serde_json::from_str(&tag_json).expect("parse auth tag json"); + let auth_tag = Tag::parse(tag_values).expect("parse auth tag"); + + EventBuilder::new(Kind::Metadata, content) + .tags(vec![auth_tag]) + .sign_with_keys(agent_keys) + .expect("sign") + } + + fn managed_agent_event(agent_pubkey: &str, owner_keys: &Keys, content: &str) -> Event { + let d_tag = Tag::parse(["d", agent_pubkey]).expect("parse d tag"); + EventBuilder::new(Kind::Custom(KIND_MANAGED_AGENT as u16), content) + .tags(vec![d_tag]) + .sign_with_keys(owner_keys) + .expect("sign") + } + + #[test] + fn accepts_nip_oa_verified_managed_definition() { + let agent_keys = Keys::generate(); + let owner_keys = Keys::generate(); + let agent_pubkey = agent_keys.public_key().to_hex(); + let identity_profile = + oa_profile_event_for(&agent_keys, &owner_keys, r#"{"name":"Scout"}"#); + let managed = managed_agent_event( + &agent_pubkey, + &owner_keys, + r#"{"name":"Remote Scout","parallelism":1,"respond_to":"anyone"}"#, + ); + + assert_eq!( + managed_agent_target_pubkeys(std::slice::from_ref(&managed)), + vec![agent_pubkey.clone()] + ); + let agents = relay_agents_from_events(&[managed], &[identity_profile]); + + assert_eq!(agents.len(), 1); + assert_eq!(agents[0].pubkey, agent_pubkey); + assert_eq!(agents[0].name, "Remote Scout"); + assert_eq!( + agents[0].respond_to, + Some(crate::managed_agents::RespondTo::Anyone) + ); + } + + #[test] + fn rejects_managed_definition_from_unverified_owner() { + let agent_keys = Keys::generate(); + let owner_keys = Keys::generate(); + let forged_owner_keys = Keys::generate(); + let agent_pubkey = agent_keys.public_key().to_hex(); + let identity_profile = oa_profile_event_for(&agent_keys, &owner_keys, "{}"); + let forged = managed_agent_event( + &agent_pubkey, + &forged_owner_keys, + r#"{"name":"Forged","parallelism":1,"respond_to":"anyone"}"#, + ); + + let agents = relay_agents_from_events(&[forged], &[identity_profile]); + + assert!(agents.is_empty()); + } + + #[test] + fn merges_managed_access_policy_into_directory_profile() { + let agent_keys = Keys::generate(); + let owner_keys = Keys::generate(); + let agent_pubkey = agent_keys.public_key().to_hex(); + let identity_profile = oa_profile_event_for(&agent_keys, &owner_keys, "{}"); + let directory = EventBuilder::new( + Kind::Custom(KIND_AGENT_PROFILE as u16), + r#"{"name":"Old name","channel_ids":["general"],"respond_to":"owner-only"}"#, + ) + .sign_with_keys(&agent_keys) + .expect("sign"); + let managed = managed_agent_event( + &agent_pubkey, + &owner_keys, + r#"{"name":"Current name","parallelism":1,"respond_to":"allowlist","respond_to_allowlist":["aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"]}"#, + ); + + let agents = relay_agents_from_events(&[directory, managed], &[identity_profile]); + + assert_eq!(agents.len(), 1); + assert_eq!(agents[0].name, "Current name"); + assert_eq!(agents[0].channel_ids, vec!["general"]); + assert_eq!( + agents[0].respond_to, + Some(crate::managed_agents::RespondTo::Allowlist) + ); + assert_eq!(agents[0].respond_to_allowlist, vec!["a".repeat(64)]); + } + + #[test] + fn keeps_deleted_agent_with_owner_and_deleted_marker() { + let agent_keys = Keys::generate(); + let owner_keys = Keys::generate(); + let agent_pubkey = agent_keys.public_key().to_hex(); + let owner_pubkey = owner_keys.public_key().to_hex(); + let identity_profile = oa_profile_event_for(&agent_keys, &owner_keys, r#"{"name":"Old Debug"}"#); + let coordinate = format!("30177:{owner_pubkey}:{agent_pubkey}"); + let deletion = EventBuilder::new(Kind::Custom(5), "") + .tags(vec![Tag::parse(["a", coordinate.as_str()]).expect("parse coordinate")]) + .sign_with_keys(&owner_keys) + .expect("sign deletion"); + + let agents = relay_agents_from_events(&[deletion], &[identity_profile]); + + assert_eq!(agents.len(), 1); + assert_eq!(agents[0].name, "Old Debug"); + assert_eq!(agents[0].owner_pubkey.as_deref(), Some(owner_pubkey.as_str())); + assert!(agents[0].deleted); + } +} diff --git a/desktop/src-tauri/src/relay.rs b/desktop/src-tauri/src/relay.rs index 685f83b799..75cbe68d56 100644 --- a/desktop/src-tauri/src/relay.rs +++ b/desktop/src-tauri/src/relay.rs @@ -96,6 +96,34 @@ pub fn relay_api_base_url() -> String { relay_http_base_url(&relay_ws_url()) } +fn relay_url_bypasses_proxy(url: &str) -> bool { + let Ok(parsed) = url::Url::parse(url) else { + return false; + }; + + match parsed.host() { + Some(url::Host::Ipv4(address)) => { + address.is_private() || address.is_loopback() || address.is_link_local() + } + Some(url::Host::Ipv6(address)) => { + address.is_loopback() || address.is_unique_local() || address.is_unicast_link_local() + } + Some(url::Host::Domain(domain)) => { + let domain = domain.trim_end_matches('.').to_ascii_lowercase(); + domain == "localhost" || domain.ends_with(".localhost") || domain.ends_with(".local") + } + None => false, + } +} + +fn relay_http_client<'a>(state: &'a AppState, url: &str) -> &'a reqwest::Client { + if relay_url_bypasses_proxy(url) { + &state.direct_relay_http_client + } else { + &state.http_client + } +} + // ── NIP-98 HTTP auth ──────────────────────────────────────────────────────── pub fn build_nip98_auth_header( @@ -323,8 +351,7 @@ pub async fn query_relay_at( serde_json::to_vec(filters).map_err(|e| format!("filter serialization failed: {e}"))?; let auth = build_nip98_auth_header(&Method::POST, &url, &body_bytes, state)?; - let response = state - .http_client + let response = relay_http_client(state, &url) .post(&url) .header("Authorization", auth) .header("Content-Type", "application/json") @@ -352,8 +379,7 @@ pub async fn query_relay_at_with_keys( let body_bytes = serde_json::to_vec(filters).map_err(|e| format!("filter serialization failed: {e}"))?; let auth = build_nip98_auth_header_for_keys(keys, &Method::POST, &url, &body_bytes)?; - let mut request = state - .http_client + let mut request = relay_http_client(state, &url) .post(&url) .header("Authorization", auth) .header("Content-Type", "application/json"); @@ -455,8 +481,7 @@ pub async fn sync_managed_agent_profile( let url = format!("{}/events", relay_http_base_url(relay_url)); let auth = build_nip98_auth_header_for_keys(agent_keys, &Method::POST, &url, &body_bytes)?; - let mut request = state - .http_client + let mut request = relay_http_client(state, &url) .post(&url) .header("Authorization", auth) .header("Content-Type", "application/json"); @@ -573,8 +598,7 @@ pub async fn submit_signed_event_with_keys( crate::egress_guard::assert_no_key_backup_bytes(&body_bytes, "signed event submit (keys)")?; let auth_header = build_nip98_auth_header_for_keys(keys, &Method::POST, &url, &body_bytes)?; - let mut request = state - .http_client + let mut request = relay_http_client(state, &url) .post(&url) .header("Authorization", auth_header) .header("Content-Type", "application/json"); @@ -608,7 +632,7 @@ mod tests { use super::{ build_profile_event, classify_intercepted_response, effective_agent_relay_url, extract_retry_in_hint, parse_command_response, relay_http_base_url, - MALFORMED_RESPONSE_MESSAGE, + relay_url_bypasses_proxy, MALFORMED_RESPONSE_MESSAGE, }; use serde::Deserialize; @@ -795,6 +819,39 @@ mod tests { ); } + #[test] + fn private_relay_urls_bypass_system_proxy() { + for url in [ + "http://10.24.11.82:3000/events", + "http://127.0.0.1:3000/query", + "http://169.254.10.20:3000/events", + "http://localhost:3000/events", + "http://buzz.local:3000/events", + "http://[::1]:3000/events", + "http://[fd00::1]:3000/events", + ] { + assert!( + relay_url_bypasses_proxy(url), + "expected direct relay: {url}" + ); + } + } + + #[test] + fn public_relay_urls_keep_system_proxy_support() { + for url in [ + "https://relay.example.com/events", + "http://8.8.8.8:3000/events", + "http://localhost.evil.com:3000/events", + "not a url", + ] { + assert!( + !relay_url_bypasses_proxy(url), + "expected proxy-capable relay: {url}" + ); + } + } + // ── classify_intercepted_response ──────────────────────────────────────── #[test] diff --git a/desktop/src-tauri/src/relay/submit.rs b/desktop/src-tauri/src/relay/submit.rs index eaad29d3b1..2579c9f228 100644 --- a/desktop/src-tauri/src/relay/submit.rs +++ b/desktop/src-tauri/src/relay/submit.rs @@ -1,5 +1,11 @@ use super::*; +const CONNECT_RETRY_DELAYS: [std::time::Duration; 3] = [ + std::time::Duration::from_millis(150), + std::time::Duration::from_millis(350), + std::time::Duration::from_millis(750), +]; + /// Response from `POST /events`. #[derive(Debug, Deserialize, serde::Serialize)] pub struct SubmitEventResponse { @@ -8,6 +14,32 @@ pub struct SubmitEventResponse { pub message: String, } +async fn send_signed_event_request( + client: &reqwest::Client, + url: &str, + auth_header: &str, + body_bytes: &[u8], +) -> Result { + let mut retry_index = 0; + loop { + match client + .post(url) + .header("Authorization", auth_header) + .header("Content-Type", "application/json") + .body(body_bytes.to_vec()) + .send() + .await + { + Ok(response) => return Ok(response), + Err(error) if error.is_connect() && retry_index < CONNECT_RETRY_DELAYS.len() => { + tokio::time::sleep(CONNECT_RETRY_DELAYS[retry_index]).await; + retry_index += 1; + } + Err(error) => return Err(classify_request_error(&error)), + } + } +} + /// POST an already-signed event to an explicit relay with an explicit owner. /// /// Deferred/scoped publication uses this form so a workspace or identity @@ -28,15 +60,11 @@ pub async fn submit_signed_event_at_with_keys( crate::egress_guard::assert_no_key_backup_bytes(&body_bytes, "relay event submit")?; let auth_header = build_nip98_auth_header_for_keys(keys, &Method::POST, &url, &body_bytes)?; - let response = state - .http_client - .post(&url) - .header("Authorization", auth_header) - .header("Content-Type", "application/json") - .body(body_bytes) - .send() - .await - .map_err(|e| classify_request_error(&e))?; + // Local proxies and stale pooled connections can reject a request before + // any bytes reach the relay. Retry those connect failures with the exact + // same signed event so a single click is reliable and remains idempotent. + let response = + send_signed_event_request(&state.http_client, &url, &auth_header, &body_bytes).await?; if !response.status().is_success() { return Err(relay_error_message(response).await); @@ -50,6 +78,47 @@ pub async fn submit_signed_event_at_with_keys( Ok(result) } +#[cfg(test)] +mod tests { + use super::*; + use tokio::io::{AsyncReadExt as _, AsyncWriteExt as _}; + + #[tokio::test] + async fn retries_connect_failure_with_the_same_request_body() { + let probe = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let address = probe.local_addr().unwrap(); + drop(probe); + + let server = tokio::spawn(async move { + tokio::time::sleep(std::time::Duration::from_millis(50)).await; + let listener = tokio::net::TcpListener::bind(address).await.unwrap(); + let (mut stream, _) = listener.accept().await.unwrap(); + let mut request = vec![0; 4096]; + let read = stream.read(&mut request).await.unwrap(); + let request = String::from_utf8_lossy(&request[..read]); + assert!(request.contains("test-event")); + stream + .write_all( + b"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: 2\r\nConnection: close\r\n\r\n{}", + ) + .await + .unwrap(); + }); + + let response = send_signed_event_request( + &reqwest::Client::new(), + &format!("http://{address}/events"), + "Nostr test-auth", + b"test-event", + ) + .await + .unwrap(); + + assert_eq!(response.status(), reqwest::StatusCode::OK); + server.await.unwrap(); + } +} + /// Sign with an explicit identity and POST the event to an explicit relay. /// /// The caller owns the signer lifetime. This is important for deferred work: diff --git a/desktop/src-tauri/src/relay_admission.rs b/desktop/src-tauri/src/relay_admission.rs index 4b0dd1f369..efa506573e 100644 --- a/desktop/src-tauri/src/relay_admission.rs +++ b/desktop/src-tauri/src/relay_admission.rs @@ -68,6 +68,18 @@ pub fn activate_rate_limit(retry_in_seconds: Option) { } } +/// Milliseconds remaining in the active admission window, for diagnostics. +/// Returns zero when the gate is inactive or has already expired. +pub fn rate_limit_remaining_ms() -> u64 { + let guard = GATE_EXPIRY + .lock() + .unwrap_or_else(|error| error.into_inner()); + guard + .map(|expiry| expiry.saturating_duration_since(Instant::now()).as_millis()) + .unwrap_or_default() + .min(u128::from(u64::MAX)) as u64 +} + /// Wait until the admission gate is clear. /// /// Returns immediately when no gate is active. Loops after sleeping because a @@ -125,6 +137,18 @@ mod tests { ); } + #[tokio::test(start_paused = true)] + async fn remaining_millis_reports_the_active_window() { + let _serial = TEST_SERIAL.lock().await; + reset_rate_limit_gate(); + activate_rate_limit(Some(3)); + assert_eq!(rate_limit_remaining_ms(), 3_000); + tokio::time::sleep(Duration::from_secs(1)).await; + assert_eq!(rate_limit_remaining_ms(), 2_000); + reset_rate_limit_gate(); + assert_eq!(rate_limit_remaining_ms(), 0); + } + #[tokio::test(start_paused = true)] async fn hintless_429_arms_the_ten_second_default() { let _serial = TEST_SERIAL.lock().await; diff --git a/desktop/src-tauri/src/tray_menu.rs b/desktop/src-tauri/src/tray_menu.rs index 3f9fe49fe0..d412e4be94 100644 --- a/desktop/src-tauri/src/tray_menu.rs +++ b/desktop/src-tauri/src/tray_menu.rs @@ -5,6 +5,7 @@ // Mouse back/forward (X1/X2 buttons and swipe) is also macOS-only native I/O; // group it here so both platform-layer init paths share one call site in lib.rs. +#[cfg(target_os = "macos")] #[path = "mouse_nav.rs"] pub(crate) mod mouse_nav; @@ -18,6 +19,8 @@ use objc2::MainThreadMarker; #[cfg(target_os = "macos")] use objc2_foundation::{NSProcessInfo, NSString}; use serde::{Deserialize, Serialize}; +#[cfg(target_os = "windows")] +use tauri::tray::{MouseButton, MouseButtonState, TrayIconEvent}; use tauri::{ image::Image, menu::{Menu, MenuItem, PredefinedMenuItem}, @@ -27,7 +30,7 @@ use tauri::{ const TRAY_ID: &str = "buzz-tray"; const OPEN_BUZZ_ID: &str = "tray-open-buzz"; -const NEW_CHANNEL_ID: &str = "tray-new-channel"; +const MINIMIZE_BUZZ_ID: &str = "tray-minimize-buzz"; const QUIT_ID: &str = "tray-quit"; const OPEN_CHANNEL_PREFIX: &str = "tray-open-channel:"; const OPEN_CHANNEL_ACTIVITY_SEPARATOR: char = '|'; @@ -181,6 +184,22 @@ fn tray_bee_icon() -> Image<'static> { Image::new_owned(rgba, WIDTH, HEIGHT) } +fn tray_icon(app: &AppHandle) -> Image<'static> { + #[cfg(target_os = "macos")] + { + let _ = app; + tray_bee_icon() + } + + #[cfg(not(target_os = "macos"))] + { + app.default_window_icon() + .cloned() + .map(Image::to_owned) + .unwrap_or_else(tray_bee_icon) + } +} + /// A running agent and its current channel. #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] @@ -211,7 +230,6 @@ struct TrayMenuState { #[derive(Debug, Deserialize, Serialize, PartialEq, Eq)] #[serde(rename_all = "camelCase", tag = "kind")] pub enum TrayAction { - NewChannel, OpenChannel { #[serde(rename = "channelId")] channel_id: String, @@ -237,19 +255,26 @@ pub(crate) fn show_main_window(app: &AppHandle) { } } +fn minimize_main_window(app: &AppHandle) { + let Some(window) = app.get_webview_window("main") else { + return; + }; + if let Err(error) = window.minimize() { + eprintln!("buzz-desktop: failed to minimize main window from tray: {error}"); + } +} + fn queue_tray_action(app: &AppHandle, mut action: TrayAction) { let state = app.state::>(); let Ok(mut queue) = state.action_queue.lock() else { eprintln!("buzz-desktop: tray action queue is unavailable"); return; }; - if let TrayAction::OpenChannel { + let TrayAction::OpenChannel { community_generation, .. - } = &mut action - { - *community_generation = queue.community_generation; - } + } = &mut action; + *community_generation = queue.community_generation; queue.pending_actions.push(action); drop(queue); @@ -327,16 +352,16 @@ fn build_menu( append_separator(app, &menu)?; menu.append(&MenuItem::with_id( app, - NEW_CHANNEL_ID, - "New Channel", + OPEN_BUZZ_ID, + "Open Buzz", true, None::<&str>, )?)?; append_separator(app, &menu)?; menu.append(&MenuItem::with_id( app, - OPEN_BUZZ_ID, - "Open Buzz", + MINIMIZE_BUZZ_ID, + "Minimize Buzz", true, None::<&str>, )?)?; @@ -447,10 +472,7 @@ fn apply_activity_presentation( fn handle_menu_event(app: &AppHandle, id: &str) { match id { OPEN_BUZZ_ID => show_main_window(app), - NEW_CHANNEL_ID => { - show_main_window(app); - queue_tray_action(app, TrayAction::NewChannel); - } + MINIMIZE_BUZZ_ID => minimize_main_window(app), QUIT_ID => app.exit(0), _ => { let Some(channel_id) = id.strip_prefix(OPEN_CHANNEL_PREFIX) else { @@ -472,6 +494,20 @@ fn handle_menu_event(app: &AppHandle, id: &str) { } } +#[cfg(target_os = "windows")] +fn handle_tray_icon_event(tray: &TrayIcon, event: TrayIconEvent) { + if matches!( + event, + TrayIconEvent::Click { + button: MouseButton::Left, + button_state: MouseButtonState::Up, + .. + } + ) { + show_main_window(tray.app_handle()); + } +} + /// Installs the persistent Buzz tray icon with the initial empty activity menu. pub fn init(app: &AppHandle) -> tauri::Result<()> { let preview_activities = preview_activities(); @@ -486,15 +522,22 @@ pub fn init(app: &AppHandle) -> tauri::Result<()> { pending_actions: Vec::new(), }), }); - let tray = TrayIconBuilder::with_id(TRAY_ID) + let tray_builder = TrayIconBuilder::with_id(TRAY_ID) .menu(&menu) - .icon(tray_bee_icon()) - .icon_as_template(true) - .on_menu_event(|app, event| handle_menu_event(app, event.id.as_ref())) - .build(app)?; + .icon(tray_icon(app)) + .tooltip("Buzz") + .on_menu_event(|app, event| handle_menu_event(app, event.id.as_ref())); + #[cfg(target_os = "macos")] + let tray_builder = tray_builder.icon_as_template(true); + #[cfg(target_os = "windows")] + let tray_builder = tray_builder + .show_menu_on_left_click(false) + .on_tray_icon_event(handle_tray_icon_event); + let tray = tray_builder.build(app)?; if let Err(error) = apply_activity_presentation(&tray, activities, recent_activities) { eprintln!("buzz-desktop: failed to apply tray menu presentation: {error}"); } + #[cfg(target_os = "macos")] mouse_nav::init(app); Ok(()) } @@ -511,12 +554,12 @@ pub fn take_tray_actions(app: AppHandle) -> Result) { - actions.retain(|action| match action { - TrayAction::NewChannel => true, - TrayAction::OpenChannel { + actions.retain(|action| { + let TrayAction::OpenChannel { community_generation, .. - } => *community_generation == queue.community_generation, + } = action; + *community_generation == queue.community_generation }); actions.append(&mut queue.pending_actions); queue.pending_actions = actions; @@ -550,9 +593,7 @@ pub fn clear_tray_agent_activity(app: AppHandle) -> Result<(), St .lock() .map_err(|_| "Buzz tray action queue is unavailable".to_string())?; queue.community_generation = queue.community_generation.wrapping_add(1); - queue - .pending_actions - .retain(|action| matches!(action, TrayAction::NewChannel)); + queue.pending_actions.clear(); drop(queue); update_tray_agent_activity(app, Vec::new(), Vec::new()) @@ -650,16 +691,4 @@ mod tests { assert!(queue.pending_actions.is_empty()); } - - #[test] - fn new_channel_actions_survive_community_change() { - let mut queue = TrayActionQueue { - community_generation: 2, - pending_actions: Vec::new(), - }; - - requeue_actions(&mut queue, vec![TrayAction::NewChannel]); - - assert_eq!(queue.pending_actions, vec![TrayAction::NewChannel]); - } } diff --git a/desktop/src-tauri/tauri.codex-lab.conf.json b/desktop/src-tauri/tauri.codex-lab.conf.json new file mode 100644 index 0000000000..afbad21a47 --- /dev/null +++ b/desktop/src-tauri/tauri.codex-lab.conf.json @@ -0,0 +1,26 @@ +{ + "productName": "Buzz Codex Lab", + "identifier": "xyz.chemyibinjiang.buzz.codexlab", + "build": { + "beforeBuildCommand": "corepack pnpm build" + }, + "plugins": { + "deep-link": { + "desktop": { + "schemes": ["buzz", "buzz-codex-lab"] + } + }, + "updater": { + "endpoints": [] + } + }, + "bundle": { + "createUpdaterArtifacts": false, + "targets": ["nsis"], + "windows": { + "nsis": { + "installMode": "currentUser" + } + } + } +} diff --git a/desktop/src-tauri/tauri.conf.json b/desktop/src-tauri/tauri.conf.json index 2986edadaf..ed1e07fb81 100644 --- a/desktop/src-tauri/tauri.conf.json +++ b/desktop/src-tauri/tauri.conf.json @@ -5,7 +5,7 @@ "identifier": "xyz.block.buzz.app", "build": { "beforeDevCommand": { - "script": "exec ./node_modules/.bin/vite", + "script": "pnpm dev", "cwd": "..", "wait": false }, @@ -85,6 +85,11 @@ "y": 330 } } + }, + "windows": { + "nsis": { + "installerHooks": "installer-hooks.nsh" + } } } } diff --git a/desktop/src/app/App.tsx b/desktop/src/app/App.tsx index 0f311f3a65..8c6827a9e8 100644 --- a/desktop/src/app/App.tsx +++ b/desktop/src/app/App.tsx @@ -72,6 +72,7 @@ import { BuzzMark } from "@/shared/ui/buzz-logo/BuzzMark"; import { FlappingBee } from "@/shared/ui/buzz-logo/FlappingBee"; import { FuzzyLogo } from "@/shared/ui/buzz-logo/FuzzyLogo"; import { StartupWindowDragRegion } from "@/shared/ui/StartupWindowDragRegion"; +import { StartupChangelogDialog } from "@/features/settings/ui/StartupChangelogDialog"; const LOADING_TEXT = "Setting up your community..."; @@ -330,7 +331,7 @@ function CommunityApp({ useNestNotifications(); // Composite key: changes when community ID changes OR when - // the active community's config is updated (relayUrl/token). + // the active community's backend config is updated. const communityKey = `${activeCommunity?.id ?? "none"}-${reinitKey}`; // Latch once the community key deviates from its cold-boot value: from then @@ -394,7 +395,6 @@ function CommunityApp({ id: crypto.randomUUID(), name: transaction.communityName, relayUrl: transaction.relayUrl, - token: transaction.token, reposDir: transaction.reposDir, pubkey: currentPubkey ?? undefined, addedAt: new Date().toISOString(), @@ -584,6 +584,12 @@ function CommunityApp({ return ( <> {appContent} + {activeCommunity && + communityApplied && + !community.needsSetup && + !transaction ? ( + + ) : null} {transaction ? (
{!isHuddleRoom ? ( - + ) : null} Promise; - openCreateChannel: () => void; }) { - if (!isMacPlatform()) return null; - return ( - - ); + if (!isMacPlatform() && !isWindowsPlatform()) return null; + return ; } -function MacAppShellTrayMenu({ +function NativeAppShellTrayMenu({ channels, goChannel, - openCreateChannel, }: { channels: Channel[]; goChannel: (channelId: string) => Promise; - openCreateChannel: () => void; }): null { useTrayMenu({ channels, goChannel, - openCreateChannel, }); return null; } diff --git a/desktop/src/app/useTrayMenu.ts b/desktop/src/app/useTrayMenu.ts index 355c8e5d4f..ab1f295142 100644 --- a/desktop/src/app/useTrayMenu.ts +++ b/desktop/src/app/useTrayMenu.ts @@ -23,9 +23,7 @@ type TrayAgentActivity = { elapsed: string; }; -type TrayAction = - | { kind: "newChannel" } - | { kind: "openChannel"; channelId: string }; +type TrayAction = { kind: "openChannel"; channelId: string }; const MAX_RECENT_TRAY_ACTIVITIES = 5; @@ -36,11 +34,9 @@ const MAX_RECENT_TRAY_ACTIVITIES = 5; export function useTrayMenu({ channels, goChannel, - openCreateChannel, }: { channels: Channel[]; goChannel: (channelId: string) => Promise; - openCreateChannel: () => void; }): void { const activeTurns = useActiveAgentTurnsByChannel(); const now = useNow(1000); @@ -112,7 +108,7 @@ export function useTrayMenu({ activities, recentActivities, }).catch((error) => { - console.error("Failed to update the macOS tray menu", error); + console.error("Failed to update the native tray menu", error); }); }, [activities, recentActivities]); @@ -132,11 +128,7 @@ export function useTrayMenu({ return; } for (const action of actions) { - if (action.kind === "newChannel") { - openCreateChannel(); - } else { - void goChannel(action.channelId); - } + void goChannel(action.channelId); } }; @@ -156,5 +148,5 @@ export function useTrayMenu({ disposed = true; unlisten?.(); }; - }, [goChannel, openCreateChannel]); + }, [goChannel]); } diff --git a/desktop/src/features/agents/AGENTS.md b/desktop/src/features/agents/AGENTS.md index 0dc73ef4c3..b13fd47f97 100644 --- a/desktop/src/features/agents/AGENTS.md +++ b/desktop/src/features/agents/AGENTS.md @@ -180,6 +180,30 @@ with a TypeScript lookup table or an id comparison in a component. import boundary. Do not silently strip them: rejection keeps the reviewed string identical to the executed string. New sharing paths must reuse the same validation before they persist or activate a definition. +13. **Definition and instance access can be reconciled explicitly.** A managed + instance stores the effective `Who can send instructions` policy used by its + running harness, while its linked Agent definition stores the default for + current and future linked instances. The instance edit surface offers both + directions: apply the definition policy to this instance, or apply this + instance's policy to the definition (which then propagates through the + existing linked-instance update path). Prefer an exact `personaId` link; + legacy instances may use a unique case-insensitive non-built-in display-name + match, but ambiguous names must not expose a sync target. Running instances + still require a restart after either persisted policy changes. + The ordinary instance `Save changes` path is also authoritative: when its + instruction-access mode or allowlist changes and a definition target can be + resolved, it updates the definition first so the existing propagation path + keeps every linked instance aligned. The explicit buttons are recovery and + direction-selection tools, not a mandatory second save step. +14. **Only ready local instances advertise shared access.** The stored instance + policy remains the owner's requested policy, but the public kind:30177 + directory projection must downgrade a NotReady/setup-mode instance to + owner-only with an empty allowlist. Otherwise other clients offer a shared + @mention target that cannot execute instructions. Interactive retention and + boot reconciliation use the same readiness-aware projection; once the + effective configuration becomes Ready, the requested public policy is + advertised again. Provider-backed remote instances are not evaluated with + local credentials and retain their requested public policy. ## The tests that enforce this diff --git a/desktop/src/features/agents/codexSharedRuntimeHooks.ts b/desktop/src/features/agents/codexSharedRuntimeHooks.ts new file mode 100644 index 0000000000..310ef7cf0f --- /dev/null +++ b/desktop/src/features/agents/codexSharedRuntimeHooks.ts @@ -0,0 +1,72 @@ +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; + +import { + enableCodexSharedRuntime, + getCodexSharedRuntimeStatus, + launchCodexDesktopShared, + takeOverCodexDesktopShared, +} from "@/shared/api/codexTasks"; +import { discoverAcpRuntimes, installAcpRuntime } from "@/shared/api/tauri"; +import { getInstallErrorMessage } from "@/shared/lib/installError"; + +export const codexSharedRuntimeQueryKey = ["codex-shared-runtime"] as const; +const acpRuntimesQueryKey = ["acp-runtimes"] as const; +const managedAgentsQueryKey = ["managed-agents"] as const; + +export function useCodexSharedRuntimeQuery(options?: { enabled?: boolean }) { + return useQuery({ + enabled: options?.enabled ?? true, + queryKey: codexSharedRuntimeQueryKey, + queryFn: getCodexSharedRuntimeStatus, + staleTime: 2_000, + refetchInterval: 10_000, + }); +} + +export function useEnableCodexSharedRuntimeMutation() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: enableCodexSharedRuntime, + onSuccess: (status) => { + queryClient.setQueryData(codexSharedRuntimeQueryKey, status); + }, + }); +} + +export function useSetupCodexSharedRuntimeMutation() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: async () => { + const installResult = await installAcpRuntime("codex"); + if (!installResult.success) { + throw new Error(getInstallErrorMessage(installResult)); + } + queryClient.setQueryData( + acpRuntimesQueryKey, + await discoverAcpRuntimes(), + ); + return enableCodexSharedRuntime(); + }, + onSuccess: (status) => { + queryClient.setQueryData(codexSharedRuntimeQueryKey, status); + }, + onSettled: () => { + void queryClient.invalidateQueries({ queryKey: acpRuntimesQueryKey }); + void queryClient.invalidateQueries({ queryKey: managedAgentsQueryKey }); + }, + }); +} + +export function useLaunchCodexDesktopSharedMutation() { + return useMutation({ mutationFn: launchCodexDesktopShared }); +} + +export function useTakeOverCodexDesktopSharedMutation() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: takeOverCodexDesktopShared, + onSuccess: (status) => { + queryClient.setQueryData(codexSharedRuntimeQueryKey, status); + }, + }); +} diff --git a/desktop/src/features/agents/codexSharedRuntimeStatus.test.mjs b/desktop/src/features/agents/codexSharedRuntimeStatus.test.mjs new file mode 100644 index 0000000000..076fdb5ba4 --- /dev/null +++ b/desktop/src/features/agents/codexSharedRuntimeStatus.test.mjs @@ -0,0 +1,37 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + hasCodexDesktopRuntimeConflict, + isCodexSharedRuntimeUsable, +} from "./codexSharedRuntimeStatus.ts"; + +const status = (overrides = {}) => ({ + enabled: true, + state: "ready", + url: "ws://127.0.0.1:51919", + detail: null, + desktopProcessIds: [], + privateAppServerProcessIds: [], + desktopDetectionError: null, + ...overrides, +}); + +test("a private Desktop backend blocks shared-runtime task use", () => { + const conflict = status({ + desktopProcessIds: [100], + privateAppServerProcessIds: [101], + }); + assert.equal(hasCodexDesktopRuntimeConflict(conflict), true); + assert.equal(isCodexSharedRuntimeUsable(conflict), false); +}); + +test("ready is usable only after process detection succeeds", () => { + assert.equal(isCodexSharedRuntimeUsable(status()), true); + assert.equal( + isCodexSharedRuntimeUsable( + status({ desktopDetectionError: "process query failed" }), + ), + false, + ); +}); diff --git a/desktop/src/features/agents/codexSharedRuntimeStatus.ts b/desktop/src/features/agents/codexSharedRuntimeStatus.ts new file mode 100644 index 0000000000..a8a3181c0d --- /dev/null +++ b/desktop/src/features/agents/codexSharedRuntimeStatus.ts @@ -0,0 +1,17 @@ +import type { CodexSharedRuntimeStatus } from "@/shared/api/codexTaskTypes"; + +export function hasCodexDesktopRuntimeConflict( + status: CodexSharedRuntimeStatus | null | undefined, +): boolean { + return (status?.privateAppServerProcessIds.length ?? 0) > 0; +} + +export function isCodexSharedRuntimeUsable( + status: CodexSharedRuntimeStatus | null | undefined, +): boolean { + return ( + status?.state === "ready" && + !status.desktopDetectionError && + !hasCodexDesktopRuntimeConflict(status) + ); +} diff --git a/desktop/src/features/agents/hooks.ts b/desktop/src/features/agents/hooks.ts index de3d2b9e83..75bab2df9d 100644 --- a/desktop/src/features/agents/hooks.ts +++ b/desktop/src/features/agents/hooks.ts @@ -42,6 +42,7 @@ import { saveCustomHarness, updateManagedAgent, } from "@/shared/api/tauri"; +import { getCodexTaskHistory, listCodexTasks } from "@/shared/api/codexTasks"; import type { HarnessDefinitionInput } from "@/shared/api/tauri"; import { setManagedAgentAutoRestart, @@ -122,6 +123,9 @@ export const managedAgentLogFocusRefetchPolicy = { export const relayAgentsQueryKey = ["relay-agents"] as const; export const managedAgentsQueryKey = ["managed-agents"] as const; +export const codexTasksQueryKey = ["codex-tasks"] as const; +export const codexTaskHistoryQueryKey = (agentPubkey: string) => + ["codex-task-history", agentPubkey] as const; export const personasQueryKey = ["personas"] as const; export const acpRuntimesQueryKey = ["acp-runtimes"] as const; export const acpAuthMethodsQueryKey = ["acp-auth-methods"] as const; @@ -341,14 +345,14 @@ export function useRelayAgentsQuery(options?: { enabled?: boolean }) { return useQuery({ queryKey: relayAgentsQueryKey, queryFn: listRelayAgents, - // Relay agent profiles (kind:10100) are near-static and the backing + // Relay agent profiles (kind:10100/30177) are near-static and the backing // `list_relay_agents` command is an unfiltered relay query for the whole // profile set — mounted on ~13 always-live surfaces (channel screen, // members bar, mentions, sidebar, profile popovers), so a tight interval - // re-pulls the full set app-wide. This poll is also the ONLY refresh path: - // the `agents-data-changed` event fires only for local persona/team/managed - // reconcile (kinds PERSONA/TEAM/MANAGED_AGENT), never for kind:10100. So we - // keep polling but at a relaxed cadence and pause it while backgrounded. + // re-pulls the full set app-wide. Live profile and managed-definition + // updates invalidate this query through `useAgentsDataRefresh`; keep the + // relaxed poll as a backstop for events missed while disconnected, and + // pause it while backgrounded. refetchInterval, enabled: options?.enabled, ...agentsFocusRefetchPolicy, @@ -377,6 +381,27 @@ export function useManagedAgentsQuery(options?: { enabled?: boolean }) { }); } +export function useCodexTasksQuery(options?: { enabled?: boolean }) { + return useQuery({ + enabled: options?.enabled ?? true, + queryKey: codexTasksQueryKey, + queryFn: listCodexTasks, + staleTime: 10_000, + }); +} + +export function useCodexTaskHistoryQuery( + agentPubkey: string | null, + options?: { enabled?: boolean }, +) { + return useQuery({ + enabled: (options?.enabled ?? true) && Boolean(agentPubkey), + queryKey: codexTaskHistoryQueryKey(agentPubkey ?? ""), + queryFn: () => getCodexTaskHistory(agentPubkey ?? ""), + staleTime: 10_000, + }); +} + export function useCreateManagedAgentMutation() { const queryClient = useQueryClient(); diff --git a/desktop/src/features/agents/lib/agentAutocompleteEligibility.test.mjs b/desktop/src/features/agents/lib/agentAutocompleteEligibility.test.mjs index d7a6e75963..0c582cae87 100644 --- a/desktop/src/features/agents/lib/agentAutocompleteEligibility.test.mjs +++ b/desktop/src/features/agents/lib/agentAutocompleteEligibility.test.mjs @@ -114,6 +114,7 @@ test("relayAgentIsSharedWithUser: accepts allowlist agents for the current user" test("relayAgentCanRespondInChannel: requires exact channel membership and viewer access", () => { const agent = { + pubkey: PUB_B, respondTo: "allowlist", respondToAllowlist: [CURRENT_PUBKEY], channelIds: ["general"], @@ -133,6 +134,29 @@ test("relayAgentCanRespondInChannel: requires exact channel membership and viewe ); }); +test("relayAgentCanRespondInChannel: current channel membership overrides a sparse directory profile", () => { + const agent = { + pubkey: PUB_B, + respondTo: "anyone", + respondToAllowlist: [], + channelIds: [], + }; + + assert.equal( + relayAgentCanRespondInChannel( + agent, + "general", + CURRENT_PUBKEY, + new Set([PUB_B]), + ), + true, + ); + assert.equal( + relayAgentCanRespondInChannel(agent, "general", CURRENT_PUBKEY, new Set()), + false, + ); +}); + test("getMentionableAgentPubkeys: keeps managed agents and shared relay agents", () => { const result = getMentionableAgentPubkeys({ eligibilityScope: { type: "community" }, @@ -203,6 +227,26 @@ test("getMentionableAgentPubkeys: scopes channel composers and fails closed with ); }); +test("getMentionableAgentPubkeys: admits an external managed agent from current channel membership", () => { + const result = getMentionableAgentPubkeys({ + eligibilityScope: { type: "channel", channelId: "general" }, + managedAgentPubkeys: [], + currentPubkey: CURRENT_PUBKEY, + relayAgents: [ + { + pubkey: PUB_B, + respondTo: "anyone", + respondToAllowlist: [], + channelIds: [], + }, + ], + sharedChannelIds: new Set(["general"]), + channelMemberPubkeys: new Set([PUB_B]), + }); + + assert.deepEqual(result, new Set([PUB_B])); +}); + test("autocomplete helper extraction preserves safe filtering and labels", () => { assert.equal(isAgentMentionChannelType("stream"), true); assert.equal(isAgentMentionChannelType("forum"), true); diff --git a/desktop/src/features/agents/lib/agentAutocompleteEligibility.ts b/desktop/src/features/agents/lib/agentAutocompleteEligibility.ts index a4b235fa04..38443f1a2c 100644 --- a/desktop/src/features/agents/lib/agentAutocompleteEligibility.ts +++ b/desktop/src/features/agents/lib/agentAutocompleteEligibility.ts @@ -31,14 +31,27 @@ export function relayAgentIsSharedWithUser( } export function relayAgentCanRespondInChannel( - agent: Pick, + agent: Pick< + RelayAgent, + "pubkey" | "channelIds" | "respondTo" | "respondToAllowlist" + >, channelId: string, currentPubkey?: string | null, + channelMemberPubkeys?: ReadonlySet, ) { - return ( - agent.channelIds.includes(channelId) && - relayAgentIsSharedWithUser(agent, new Set([channelId]), currentPubkey) - ); + const isChannelMember = channelMemberPubkeys + ? channelMemberPubkeys.has(normalizePubkey(agent.pubkey)) + : agent.channelIds.includes(channelId); + if (!isChannelMember) return false; + + if (agent.respondTo === "allowlist" && currentPubkey) { + const normalizedCurrentPubkey = normalizePubkey(currentPubkey); + return agent.respondToAllowlist.some( + (pubkey) => normalizePubkey(pubkey) === normalizedCurrentPubkey, + ); + } + + return agent.respondTo === "anyone"; } export type AgentEligibilityScope = @@ -52,12 +65,14 @@ export function getMentionableAgentPubkeys({ managedAgentPubkeys, relayAgents, sharedChannelIds, + channelMemberPubkeys, }: { currentPubkey?: string | null; eligibilityScope: AgentEligibilityScope; managedAgentPubkeys: Iterable; relayAgents: readonly RelayAgent[] | undefined; sharedChannelIds: ReadonlySet; + channelMemberPubkeys?: ReadonlySet; }) { const pubkeys = new Set( [...managedAgentPubkeys].map((pubkey) => normalizePubkey(pubkey)), @@ -73,6 +88,7 @@ export function getMentionableAgentPubkeys({ agent, eligibilityScope.channelId, currentPubkey, + channelMemberPubkeys, ); if (isAllowed) { pubkeys.add(normalizePubkey(agent.pubkey)); diff --git a/desktop/src/features/agents/lib/friendlyAgentLastError.test.mjs b/desktop/src/features/agents/lib/friendlyAgentLastError.test.mjs index 597b1b9323..ddb4725d50 100644 --- a/desktop/src/features/agents/lib/friendlyAgentLastError.test.mjs +++ b/desktop/src/features/agents/lib/friendlyAgentLastError.test.mjs @@ -2,13 +2,45 @@ import assert from "node:assert/strict"; import test from "node:test"; import { + CODEX_TASK_LOAD_FAILED_COPY, + CODEX_WRITER_CONFLICT_COPY, friendlyAgentLastError, friendlyTurnErrorCopy, CLI_ACP_INTERNAL_ERROR_COPY, + isCodexWriterConflictError, MODEL_NOT_FOUND_COPY, RELAY_MESH_DENIED_COPY, } from "./friendlyAgentLastError.ts"; +test("writer conflicts are actionable even with JSON-RPC -32600", () => { + const active = "thread abc already has an active writer"; + const local = "thread abc already has a live local writer"; + + assert.deepEqual(friendlyAgentLastError(active, -32600), { + severity: "generic", + copy: CODEX_WRITER_CONFLICT_COPY, + }); + assert.deepEqual(friendlyAgentLastError(local, -32600), { + severity: "generic", + copy: CODEX_WRITER_CONFLICT_COPY, + }); + assert.equal(isCodexWriterConflictError(active), true); + assert.equal(isCodexWriterConflictError("network timeout"), false); +}); + +test("maps Codex task load timeouts to actionable retry guidance", () => { + assert.deepEqual( + friendlyAgentLastError( + "Codex task load failed: Request timeout — agent did not respond within 60s", + -32004, + ), + { + severity: "generic", + copy: CODEX_TASK_LOAD_FAILED_COPY, + }, + ); +}); + test("null lastError → null", () => { assert.equal(friendlyAgentLastError(null), null); }); diff --git a/desktop/src/features/agents/lib/friendlyAgentLastError.ts b/desktop/src/features/agents/lib/friendlyAgentLastError.ts index 60c77bb04c..bccee4e8c8 100644 --- a/desktop/src/features/agents/lib/friendlyAgentLastError.ts +++ b/desktop/src/features/agents/lib/friendlyAgentLastError.ts @@ -45,6 +45,26 @@ export const MODEL_NOT_FOUND_COPY = export const CLI_ACP_INTERNAL_ERROR_COPY = "The agent's harness reported an internal error. For Codex agents this can mean the configured model isn't supported by your installed codex-acp — check the model in `~/.codex/config.toml` or upgrade the adapter (`brew upgrade codex-acp`)."; +export const CODEX_TASK_LOAD_FAILED_COPY = + "The Codex task did not load before the 60-second timeout. It may be busy in Codex Desktop, or the shared app-server may be unresponsive. Wait for the task to become idle, then retry."; + +export const CODEX_WRITER_CONFLICT_COPY = + "This Codex task is open in a separate Codex Desktop runtime. Open Codex shared runtime settings, take over Desktop, then retry the agent."; + +const CODEX_WRITER_CONFLICT_MARKERS = [ + "already has an active writer", + "already has a live local writer", +] as const; + +export function isCodexWriterConflictError( + raw: string | null | undefined, +): boolean { + const normalized = raw?.toLocaleLowerCase() ?? ""; + return CODEX_WRITER_CONFLICT_MARKERS.some((marker) => + normalized.includes(marker), + ); +} + const EMBEDDED_CODE_RE = /^Agent reported error \(code (-?\d+)\): /; /** Bare form of the standard JSON-RPC -32603 message (after stripping the ACP wrapper prefix). */ const BARE_INTERNAL_ERROR = "Internal error"; @@ -69,6 +89,13 @@ export function friendlyAgentLastError( const trimmed = raw.trim(); if (trimmed.length === 0) return null; + // The app-server can surface this user-actionable condition through more + // than one JSON-RPC code, including -32600. Classify the specific message + // before the unknown-code pass-through so automatic retries can stop. + if (isCodexWriterConflictError(trimmed)) { + return { severity: "generic", copy: CODEX_WRITER_CONFLICT_COPY }; + } + // Structured code first; a code embedded in the message string is the // same signal recovered from a record that lost the field. const embedded = recoverEmbeddedCode(trimmed); @@ -81,6 +108,8 @@ export function friendlyAgentLastError( return { severity: "denied", copy: RELAY_MESH_DENIED_COPY }; case -32002: return { severity: "denied", copy: MODEL_NOT_FOUND_COPY }; + case -32004: + return { severity: "generic", copy: CODEX_TASK_LOAD_FAILED_COPY }; case -32603: { // Standard JSON-RPC "Internal error" — emitted by external harnesses // (e.g. codex-acp) when the configured model is unsupported. Only diff --git a/desktop/src/features/agents/lib/managedAgentControlActions.test.mjs b/desktop/src/features/agents/lib/managedAgentControlActions.test.mjs index e6926b36d2..4a66aa24ee 100644 --- a/desktop/src/features/agents/lib/managedAgentControlActions.test.mjs +++ b/desktop/src/features/agents/lib/managedAgentControlActions.test.mjs @@ -2,8 +2,10 @@ import assert from "node:assert/strict"; import test from "node:test"; import { + getManagedAgentPrimaryActionLabel, startManagedAgentWithRules, respawnManagedAgentWithRules, + stopManagedAgentWithRules, } from "./managedAgentControlActions.ts"; function agent(overrides = {}) { @@ -81,6 +83,99 @@ test("ordinary local agents still start normally", async () => { assert.equal(calledWith, "deadbeef".repeat(8)); }); +test("Codex task agents always use shared-runtime connection actions", () => { + const binding = { + taskId: "019febeb-ae12-71d3-88c4-25c04a461042", + threadName: "Inspect DoE dataset results", + workspace: "C:\\repo", + updatedAt: new Date(0).toISOString(), + model: "gpt-5.4-mini[xhigh]", + appServerUrl: null, + }; + + assert.equal( + getManagedAgentPrimaryActionLabel( + agent({ codexTaskBinding: binding, status: "stopped" }), + ), + "Connect Buzz", + ); + assert.equal( + getManagedAgentPrimaryActionLabel( + agent({ codexTaskBinding: binding, status: "running" }), + ), + "Disconnect Buzz", + ); + + const sharedBinding = { + ...binding, + appServerUrl: "ws://127.0.0.1:51919", + }; + assert.equal( + getManagedAgentPrimaryActionLabel( + agent({ codexTaskBinding: sharedBinding, status: "stopped" }), + ), + "Connect Buzz", + ); + assert.equal( + getManagedAgentPrimaryActionLabel( + agent({ codexTaskBinding: sharedBinding, status: "running" }), + ), + "Disconnect Buzz", + ); +}); + +test("legacy Codex task bindings use shared-runtime disconnect copy", async () => { + const taskAgent = agent({ + codexTaskBinding: { + taskId: "019febeb-ae12-71d3-88c4-25c04a461042", + threadName: "Inspect DoE dataset results", + workspace: "C:\\repo", + updatedAt: new Date(0).toISOString(), + model: "gpt-5.4-mini[xhigh]", + appServerUrl: null, + }, + status: "running", + }); + let stoppedPubkey = null; + + const result = await stopManagedAgentWithRules({ + agent: taskAgent, + channels: [], + relayAgents: [], + stopManagedAgent: async (pubkey) => { + stoppedPubkey = pubkey; + }, + }); + + assert.equal(stoppedPubkey, taskAgent.pubkey); + assert.match(result.noticeMessage, /Disconnected Buzz/); + assert.match(result.noticeMessage, /Other clients connected/); +}); + +test("disconnecting Buzz leaves other shared-runtime clients connected", async () => { + const taskAgent = agent({ + codexTaskBinding: { + taskId: "019febeb-ae12-71d3-88c4-25c04a461042", + threadName: "Inspect DoE dataset results", + workspace: "C:\\repo", + updatedAt: new Date(0).toISOString(), + model: "gpt-5.4-mini[xhigh]", + appServerUrl: "ws://127.0.0.1:51919", + }, + status: "running", + }); + + const result = await stopManagedAgentWithRules({ + agent: taskAgent, + channels: [], + relayAgents: [], + stopManagedAgent: async () => {}, + }); + + assert.match(result.noticeMessage, /Disconnected Buzz/); + assert.match(result.noticeMessage, /Other clients connected/); +}); + // --- respawnManagedAgentWithRules: stop→clear→start boundary tests ----------- test("test_respawn_stop_success_start_failure_onStopped_still_fires", async () => { diff --git a/desktop/src/features/agents/lib/managedAgentControlActions.ts b/desktop/src/features/agents/lib/managedAgentControlActions.ts index 8a4a6898cc..85ce006b39 100644 --- a/desktop/src/features/agents/lib/managedAgentControlActions.ts +++ b/desktop/src/features/agents/lib/managedAgentControlActions.ts @@ -40,6 +40,10 @@ export function getManagedAgentPrimaryActionLabel(agent: ManagedAgent) { return isManagedAgentActive(agent) ? "Shutdown" : "Deploy"; } + if (agent.codexTaskBinding) { + return isManagedAgentActive(agent) ? "Disconnect Buzz" : "Connect Buzz"; + } + if (isManagedAgentActive(agent)) { return "Stop"; } @@ -138,7 +142,11 @@ export async function stopManagedAgentWithRules({ } await stopManagedAgent(agent.pubkey); - return {}; + return agent.codexTaskBinding + ? { + noticeMessage: `Disconnected Buzz from ${agent.codexTaskBinding.threadName}. Other clients connected to the shared Codex runtime can continue.`, + } + : {}; } export async function deleteManagedAgentWithRules({ diff --git a/desktop/src/features/agents/lib/useAgentsDataRefresh.test.mjs b/desktop/src/features/agents/lib/useAgentsDataRefresh.test.mjs new file mode 100644 index 0000000000..1e25b4d504 --- /dev/null +++ b/desktop/src/features/agents/lib/useAgentsDataRefresh.test.mjs @@ -0,0 +1,27 @@ +import assert from "node:assert/strict"; +import { readFile } from "node:fs/promises"; +import test from "node:test"; + +const source = await readFile( + new URL("./useAgentsDataRefresh.ts", import.meta.url), + "utf8", +); +const collapsedSource = source.replace(/\s+/g, " "); + +test("relay agent directory updates refresh mention eligibility live", () => { + assert.match( + collapsedSource, + /subscribeLive\( \{ kinds: \[KIND_PROFILE, KIND_AGENT_PROFILE, KIND_MANAGED_AGENT\], limit: 0,/, + ); + assert.match( + collapsedSource, + /invalidateQueries\(\{ queryKey: relayAgentsQueryKey \}\)/, + ); +}); + +test("relay reconnects refresh agent profiles missed while offline", () => { + assert.match( + collapsedSource, + /subscribeToReconnects\(\(\) => \{ refreshRelayAgents\(\); \}\)/, + ); +}); diff --git a/desktop/src/features/agents/lib/useAgentsDataRefresh.ts b/desktop/src/features/agents/lib/useAgentsDataRefresh.ts index 174fb9c92c..242593d7c4 100644 --- a/desktop/src/features/agents/lib/useAgentsDataRefresh.ts +++ b/desktop/src/features/agents/lib/useAgentsDataRefresh.ts @@ -9,6 +9,12 @@ import { teamsQueryKey, } from "@/features/agents/hooks"; import { managedAgentRuntimesQueryKey } from "@/features/agents/managedAgentRuntimeHooks"; +import { relayClient } from "@/shared/api/relayClient"; +import { + KIND_AGENT_PROFILE, + KIND_MANAGED_AGENT, + KIND_PROFILE, +} from "@/shared/constants/kinds"; // Trailing-coalesce window: a backfill burst (up to 500 inbound events fed // one-by-one through reconcile) fires one `agents-data-changed` per event. @@ -27,6 +33,46 @@ export function useAgentsDataRefresh(): void { useEffect(() => { let timer: ReturnType | undefined; + let relayAgentTimer: ReturnType | undefined; + let relayAgentRetryTimer: ReturnType | undefined; + let relayAgentUnsubscribe: (() => Promise) | undefined; + let cancelled = false; + + const refreshRelayAgents = () => { + if (relayAgentTimer !== undefined) clearTimeout(relayAgentTimer); + relayAgentTimer = setTimeout(() => { + void queryClient.invalidateQueries({ queryKey: relayAgentsQueryKey }); + }, COALESCE_MS); + }; + + const subscribeToRelayAgentProfiles = (attempt = 0) => { + if (cancelled) return; + void relayClient + .subscribeLive( + { + kinds: [KIND_PROFILE, KIND_AGENT_PROFILE, KIND_MANAGED_AGENT], + limit: 0, + }, + () => { + refreshRelayAgents(); + }, + ) + .then((unsubscribe) => { + if (cancelled) { + void unsubscribe(); + return; + } + relayAgentUnsubscribe = unsubscribe; + }) + .catch(() => { + if (cancelled) return; + const delay = Math.min(1_000 * 2 ** attempt, 30_000); + relayAgentRetryTimer = setTimeout( + () => subscribeToRelayAgentProfiles(attempt + 1), + delay, + ); + }); + }; const unlistenRuntime = listen("managed-agent-runtime-status", () => { void queryClient.invalidateQueries({ @@ -47,8 +93,19 @@ export function useAgentsDataRefresh(): void { }, COALESCE_MS); }); + subscribeToRelayAgentProfiles(); + const unsubscribeReconnect = relayClient.subscribeToReconnects(() => { + refreshRelayAgents(); + }); + return () => { + cancelled = true; if (timer !== undefined) clearTimeout(timer); + if (relayAgentTimer !== undefined) clearTimeout(relayAgentTimer); + if (relayAgentRetryTimer !== undefined) + clearTimeout(relayAgentRetryTimer); + unsubscribeReconnect(); + if (relayAgentUnsubscribe) void relayAgentUnsubscribe(); void unlisten.then((fn) => fn()); void unlistenRuntime.then((fn) => fn()); }; diff --git a/desktop/src/features/agents/managedAgentReconciliationPlan.test.mjs b/desktop/src/features/agents/managedAgentReconciliationPlan.test.mjs index 21327c30d9..76ceae9e45 100644 --- a/desktop/src/features/agents/managedAgentReconciliationPlan.test.mjs +++ b/desktop/src/features/agents/managedAgentReconciliationPlan.test.mjs @@ -59,6 +59,7 @@ test("classifyReconcileResult marks the whole batch failed when the call throws" { succeeded: [], failed: attempted, + blocked: [], }, ); }); @@ -94,6 +95,7 @@ test("classifyReconcileResult splits by Failed rows, matching on requested URL", { succeeded: ["ws://127.0.0.1:3000"], failed: ["wss://b.example"], + blocked: [], }, ); }); @@ -103,6 +105,37 @@ test("classifyReconcileResult treats a relay with no rows as reconciled", () => // still count as reconciled so the hook stops retrying it. assert.deepEqual( classifyReconcileResult(["wss://a.example"], [], canonicalRelayUrl), - { succeeded: ["wss://a.example"], failed: [] }, + { succeeded: ["wss://a.example"], failed: [], blocked: [] }, ); }); + +test("classifyReconcileResult stops automatic retries for writer conflicts", () => { + const relay = "wss://relay.example"; + const rows = [ + { + pubkey: "aa", + relayUrl: relay, + requestedRelayUrl: relay, + localSetup: true, + lifecycle: "failed", + pid: null, + error: "thread task-id already has an active writer", + logPath: null, + }, + { + pubkey: "bb", + relayUrl: relay, + requestedRelayUrl: relay, + localSetup: true, + lifecycle: "failed", + pid: null, + error: "another transient startup error", + logPath: null, + }, + ]; + assert.deepEqual(classifyReconcileResult([relay], rows, canonicalRelayUrl), { + succeeded: [], + failed: [], + blocked: [relay], + }); +}); diff --git a/desktop/src/features/agents/managedAgentReconciliationPlan.ts b/desktop/src/features/agents/managedAgentReconciliationPlan.ts index 58d9b58b73..17666cbfc5 100644 --- a/desktop/src/features/agents/managedAgentReconciliationPlan.ts +++ b/desktop/src/features/agents/managedAgentReconciliationPlan.ts @@ -1,4 +1,5 @@ import type { ManagedAgentRuntimeStatus } from "@/shared/api/types"; +import { isCodexWriterConflictError } from "@/features/agents/lib/friendlyAgentLastError"; /** * Pure planning core for incremental, retrying runtime reconciliation. @@ -75,21 +76,30 @@ export function classifyReconcileResult( attempted: readonly string[], rows: readonly ManagedAgentRuntimeStatus[] | null, canonicalize: (url: string) => string | null, -): { succeeded: string[]; failed: string[] } { +): { succeeded: string[]; failed: string[]; blocked: string[] } { if (rows === null) { - return { succeeded: [], failed: [...attempted] }; + return { succeeded: [], failed: [...attempted], blocked: [] }; } const failedRelays = new Set(); + const blockedRelays = new Set(); for (const row of rows) { if (row.lifecycle !== "failed") continue; const canonical = canonicalize(row.requestedRelayUrl ?? row.relayUrl); - if (canonical !== null) failedRelays.add(canonical); + if (canonical === null) continue; + if (isCodexWriterConflictError(row.error)) { + blockedRelays.add(canonical); + failedRelays.delete(canonical); + } else if (!blockedRelays.has(canonical)) { + failedRelays.add(canonical); + } } const succeeded: string[] = []; const failed: string[] = []; + const blocked: string[] = []; for (const relay of attempted) { - if (failedRelays.has(relay)) failed.push(relay); + if (blockedRelays.has(relay)) blocked.push(relay); + else if (failedRelays.has(relay)) failed.push(relay); else succeeded.push(relay); } - return { succeeded, failed }; + return { succeeded, failed, blocked }; } diff --git a/desktop/src/features/agents/ui/AgentHandoffDialog.tsx b/desktop/src/features/agents/ui/AgentHandoffDialog.tsx new file mode 100644 index 0000000000..05e8f7f15f --- /dev/null +++ b/desktop/src/features/agents/ui/AgentHandoffDialog.tsx @@ -0,0 +1,460 @@ +import * as React from "react"; +import { ArrowRightLeft, BookOpen, RefreshCw, Send } from "lucide-react"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; + +import { useRelayAgentsQuery } from "@/features/agents/hooks"; +import { generateManagedAgentHandoff } from "@/shared/api/agentControl"; +import { sendChannelMessage } from "@/shared/api/tauri"; +import { useUsersBatchQuery } from "@/features/profile/hooks"; +import { truncatePubkey } from "@/shared/lib/pubkey"; +import type { ManagedAgent } from "@/shared/api/types"; +import { + getAgentHandoff, + listAgentHandoffs, + sendAgentHandoff, + type AgentHandoffRecord, +} from "@/shared/api/handoffs"; +import { cn } from "@/shared/lib/cn"; +import { Badge } from "@/shared/ui/badge"; +import { Button } from "@/shared/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from "@/shared/ui/dialog"; + +type AgentHandoffDialogProps = { + agent: Pick; + availableAgents?: ReadonlyArray< + Pick & { + ownerPubkey?: string | null; + deleted?: boolean; + } + >; + history: string; + channelId?: string | null; + channelName?: string | null; + initialMode?: "send" | "received"; + trigger?: React.ReactNode; + open?: boolean; + onOpenChange?: (open: boolean) => void; +}; + +export function AgentHandoffDialog({ + agent, + availableAgents: channelAgents, + history, + channelId = null, + channelName = null, + initialMode = "send", + trigger, + open: controlledOpen, + onOpenChange, +}: AgentHandoffDialogProps) { + const [internalOpen, setInternalOpen] = React.useState(false); + const open = controlledOpen ?? internalOpen; + const setOpen = React.useCallback( + (nextOpen: boolean) => { + if (controlledOpen === undefined) { + setInternalOpen(nextOpen); + } + onOpenChange?.(nextOpen); + }, + [controlledOpen, onOpenChange], + ); + const [mode, setMode] = React.useState<"send" | "received">(initialMode); + const [selectedEvent, setSelectedEvent] = React.useState(null); + const [recipient, setRecipient] = React.useState(""); + const [title, setTitle] = React.useState(""); + const [summary, setSummary] = React.useState(""); + const [body, setBody] = React.useState(history); + const [isGenerating, setIsGenerating] = React.useState(false); + const [generationError, setGenerationError] = React.useState( + null, + ); + const relayAgents = useRelayAgentsQuery({ enabled: open }).data ?? []; + const ownerPubkeys = React.useMemo( + () => + relayAgents.flatMap((agent) => + agent.ownerPubkey ? [agent.ownerPubkey] : [], + ), + [relayAgents], + ); + const ownerProfiles = useUsersBatchQuery(ownerPubkeys, { enabled: open }).data + ?.profiles; + const queryClient = useQueryClient(); + const received = useQuery({ + queryKey: ["agent-handoffs"], + queryFn: () => listAgentHandoffs(), + enabled: open && mode === "received", + }); + const detail = useQuery({ + queryKey: ["agent-handoff", selectedEvent], + queryFn: () => getAgentHandoff(selectedEvent as string), + enabled: Boolean(selectedEvent), + }); + const send = useMutation({ + mutationFn: async () => { + const result = await sendAgentHandoff({ + recipientPubkey: recipient, + title, + summary: summary.trim() || undefined, + history: body, + }); + if (channelId) { + const recipientAgent = availableAgents.find( + (candidate) => candidate.pubkey === recipient, + ); + const recipientName = recipientAgent?.name?.trim() || "Agent"; + try { + await sendChannelMessage( + channelId, + `@${recipientName} 收到一份 Agent handoff,请运行 \`buzz agents handoff list\` 查看并继续处理:${title.trim() || "未命名任务"}`, + null, + undefined, + [recipient], + ); + } catch (error) { + console.warn("Agent handoff notification could not be posted", error); + } + } + return result; + }, + onSuccess: () => { + setOpen(false); + setRecipient(""); + setTitle(""); + setSummary(""); + void queryClient.invalidateQueries({ queryKey: ["agent-handoffs"] }); + }, + }); + + async function generateDraft(source = history) { + setIsGenerating(true); + setGenerationError(null); + try { + const markdown = await generateManagedAgentHandoff( + agent.pubkey, + channelId, + ); + const draft = buildHandoffDraft(agent.name, markdown); + setBody(draft.history); + setTitle((current) => current.trim() || draft.title); + setSummary((current) => current.trim() || draft.summary); + } catch (error) { + setGenerationError( + error instanceof Error + ? error.message + : "Unable to generate handoff draft.", + ); + setBody(source); + } finally { + setIsGenerating(false); + } + } + + function openSend() { + setMode("send"); + setOpen(true); + void generateDraft(history); + } + + function openReceived() { + setMode("received"); + setSelectedEvent(null); + setOpen(true); + } + + function openDialog() { + if (initialMode === "received") { + openReceived(); + return; + } + openSend(); + } + + const availableAgents = channelAgents + ? channelAgents.filter( + (candidate) => candidate.pubkey !== agent.pubkey && !candidate.deleted, + ) + : relayAgents.filter( + (candidate) => + candidate.pubkey !== agent.pubkey && + !candidate.deleted && + channelId !== null && + (candidate.channelIds.includes(channelId) || + (channelName !== null && candidate.channels.includes(channelName))), + ); + + return ( + <> +
+ {trigger ? ( + React.cloneElement( + trigger as React.ReactElement<{ onSelect?: () => void }>, + { onSelect: openDialog }, + ) + ) : ( + <> + + + + )} +
+ + + + + + + Agent handoff + + + Share a curated task snapshot with another Agent. Hidden reasoning + and credentials are excluded. + + + +
+ + +
+ + {mode === "send" ? ( +
+ + + +