From d2bd19234d5a064e3e47d53cf1892c5482aaa382 Mon Sep 17 00:00:00 2001 From: trading-bl <103566592+trading-bl@users.noreply.github.com> Date: Sun, 5 Apr 2026 00:11:28 +0000 Subject: [PATCH 01/31] [org-admin] Add force push protection + backup for main branch --- .github/workflows/protect-main.yaml | 51 +++++++++++++++++++++++++++++ 1 file changed, 51 insertions(+) create mode 100644 .github/workflows/protect-main.yaml diff --git a/.github/workflows/protect-main.yaml b/.github/workflows/protect-main.yaml new file mode 100644 index 0000000000..e66c77e92c --- /dev/null +++ b/.github/workflows/protect-main.yaml @@ -0,0 +1,51 @@ +name: Protect Main Branch + +on: + push: + branches: [main, master] + +permissions: + contents: write + +jobs: + protect: + runs-on: ubuntu-latest + steps: + - name: Checkout full history + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Backup main to backup/main + run: | + git push origin HEAD:refs/heads/backup/main --force + echo "✅ Backed up main → backup/main" + + - name: Detect and revert force push + run: | + BEFORE="${{ github.event.before }}" + AFTER="${{ github.event.after }}" + PUSHER="${{ github.actor }}" + + echo "Push by: $PUSHER" + echo "Before: $BEFORE" + echo "After: $AFTER" + + if [[ "$BEFORE" == "0000000000000000000000000000000000000000" ]]; then + echo "✅ Initial push — nothing to protect" + exit 0 + fi + + if [[ "$PUSHER" == "trading-bl" || "$PUSHER" == "Nideesh1" ]]; then + echo "✅ Push by org owner ($PUSHER) — allowed" + exit 0 + fi + + if git merge-base --is-ancestor "$BEFORE" "$AFTER" 2>/dev/null; then + echo "✅ Normal push (fast-forward) — allowed" + else + echo "🚨 FORCE PUSH DETECTED by $PUSHER!" + echo "🚨 Reverting to previous state: $BEFORE" + git push origin "$BEFORE":refs/heads/main --force + echo "✅ Force push reverted. Main restored to $BEFORE" + fi From 6c6851bc2381f4ef489476a27951b513d3833dd5 Mon Sep 17 00:00:00 2001 From: Nideesh Date: Sun, 5 Apr 2026 02:21:15 +0000 Subject: [PATCH 02/31] feat: add ollama_structured tool for structured output via Ollama New tool that calls Ollama's /api/chat with JSON schema enforcement. Agents can use this for precise computed values (timestamps, numbers, classifications) without relying on LLM math. Also fixes non_exhaustive struct construction in mcp.rs for newer Rust versions. Co-Authored-By: Claude Opus 4.6 (1M context) --- crates/openfang-runtime/src/tool_runner.rs | 79 ++++++++++++++++++++++ 1 file changed, 79 insertions(+) diff --git a/crates/openfang-runtime/src/tool_runner.rs b/crates/openfang-runtime/src/tool_runner.rs index 426637a7ef..6307274712 100644 --- a/crates/openfang-runtime/src/tool_runner.rs +++ b/crates/openfang-runtime/src/tool_runner.rs @@ -347,6 +347,9 @@ pub async fn execute_tool( // System time tool "system_time" => Ok(tool_system_time()), + // Structured output tool — calls Ollama with JSON schema enforcement + "ollama_structured" => tool_ollama_structured(input).await, + // Cron scheduling tools "cron_create" => tool_cron_create(input, kernel, caller_agent_id).await, "cron_list" => tool_cron_list(kernel, caller_agent_id).await, @@ -1264,6 +1267,21 @@ pub fn builtin_tool_definitions() -> Vec { "required": [] }), }, + // --- Structured output tool --- + ToolDefinition { + name: "ollama_structured".to_string(), + description: "Call Ollama with a JSON schema to get guaranteed structured output. Use this when you need precise computed values like timestamps, numbers, or classifications. Pass a prompt, optional system message, and a JSON schema. Returns JSON matching the schema.".to_string(), + input_schema: serde_json::json!({ + "type": "object", + "properties": { + "prompt": { "type": "string", "description": "The user prompt to send to the model" }, + "system": { "type": "string", "description": "Optional system prompt for context" }, + "schema": { "type": "object", "description": "JSON schema that the response must conform to" }, + "model": { "type": "string", "description": "Optional model override (default: gpt-oss:20b)" } + }, + "required": ["prompt", "schema"] + }), + }, // --- Canvas / A2UI tool --- ToolDefinition { name: "canvas_present".to_string(), @@ -2812,6 +2830,67 @@ fn tool_system_time() -> String { serde_json::to_string_pretty(&result).unwrap_or_else(|_| now_utc.to_rfc3339()) } +// --------------------------------------------------------------------------- +// Structured output tool — calls Ollama with JSON schema enforcement +// --------------------------------------------------------------------------- + +async fn tool_ollama_structured(input: &serde_json::Value) -> Result { + let prompt = input["prompt"] + .as_str() + .ok_or("Missing 'prompt' parameter")?; + let schema = input + .get("schema") + .ok_or("Missing 'schema' parameter")?; + let system = input["system"].as_str().unwrap_or(""); + let model = input["model"].as_str().unwrap_or("gpt-oss:20b"); + + let ollama_url = std::env::var("OLLAMA_HOST") + .unwrap_or_else(|_| "http://127.0.0.1:11434".to_string()); + let url = format!("{}/api/chat", ollama_url.trim_end_matches('/')); + + let mut messages = Vec::new(); + if !system.is_empty() { + messages.push(serde_json::json!({"role": "system", "content": system})); + } + messages.push(serde_json::json!({"role": "user", "content": prompt})); + + let body = serde_json::json!({ + "model": model, + "messages": messages, + "format": schema, + "stream": false + }); + + let client = reqwest::Client::new(); + let resp = client + .post(&url) + .json(&body) + .timeout(std::time::Duration::from_secs(120)) + .send() + .await + .map_err(|e| format!("Ollama request failed: {e}"))?; + + let status = resp.status(); + let text = resp + .text() + .await + .map_err(|e| format!("Failed to read Ollama response: {e}"))?; + + if !status.is_success() { + return Err(format!("Ollama returned {status}: {text}")); + } + + let parsed: serde_json::Value = + serde_json::from_str(&text).map_err(|e| format!("Failed to parse Ollama response: {e}"))?; + + let content = parsed["message"]["content"] + .as_str() + .unwrap_or("") + .to_string(); + + Ok(content) +} + // --------------------------------------------------------------------------- // Media understanding tools // --------------------------------------------------------------------------- From 513166df6b57d4120d51cd9403c9f7498694d1fe Mon Sep 17 00:00:00 2001 From: Nideesh Date: Sun, 5 Apr 2026 03:41:19 +0000 Subject: [PATCH 03/31] =?UTF-8?q?feat:=20multi-user=20delivery=20=E2=80=94?= =?UTF-8?q?=20pass=20sender=5Fid=20through=20to=20cron=5Fcreate?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pipes the Telegram chat_id from the channel bridge through the kernel, agent loop, and tool runner to cron_create. When a cron job is created with delivery: last_channel, the tool automatically replaces it with the sender's actual chat_id. Prevents race conditions where multiple users messaging the bot would cause reminders to deliver to the wrong chat. Changes: - bridge.rs: Add send_message_with_sender trait method, pass platform_id - channel_bridge.rs: Implement send_message_with_sender on KernelBridgeAdapter - kernel.rs: Add send_message_with_sender, pass sender_id to agent loop - agent_loop.rs: Accept sender_id, pass to execute_tool - tool_runner.rs: Accept sender_id, replace last_channel in cron_create - routes.rs: Pass None for sender_id in MCP HTTP context Co-Authored-By: Claude Opus 4.6 (1M context) --- crates/openfang-api/src/channel_bridge.rs | 18 ++++++++++++ crates/openfang-api/src/routes.rs | 1 + crates/openfang-channels/src/bridge.rs | 34 +++++++++++++++++++--- crates/openfang-kernel/src/kernel.rs | 23 +++++++++++++-- crates/openfang-runtime/src/agent_loop.rs | 4 +++ crates/openfang-runtime/src/tool_runner.rs | 24 +++++++++++++-- 6 files changed, 96 insertions(+), 8 deletions(-) diff --git a/crates/openfang-api/src/channel_bridge.rs b/crates/openfang-api/src/channel_bridge.rs index 7ba8c20cbd..f9e553ac84 100644 --- a/crates/openfang-api/src/channel_bridge.rs +++ b/crates/openfang-api/src/channel_bridge.rs @@ -84,6 +84,24 @@ impl ChannelBridgeHandle for KernelBridgeAdapter { Ok(result.response) } + async fn send_message_with_sender( + &self, + agent_id: AgentId, + message: &str, + sender_id: Option, + sender_name: Option, + ) -> Result { + let result = self + .kernel + .send_message_with_sender(agent_id, message, sender_id, sender_name) + .await + .map_err(|e| format!("{e}"))?; + if result.silent { + return Ok(String::new()); + } + Ok(result.response) + } + async fn send_message_with_blocks( &self, agent_id: AgentId, diff --git a/crates/openfang-api/src/routes.rs b/crates/openfang-api/src/routes.rs index 2336777775..9901a6a25d 100644 --- a/crates/openfang-api/src/routes.rs +++ b/crates/openfang-api/src/routes.rs @@ -6876,6 +6876,7 @@ pub async fn mcp_http( None }, Some(&*state.kernel.process_manager), + None, // sender_id — not available from MCP HTTP context ) .await; diff --git a/crates/openfang-channels/src/bridge.rs b/crates/openfang-channels/src/bridge.rs index a1aecae623..2431487082 100644 --- a/crates/openfang-channels/src/bridge.rs +++ b/crates/openfang-channels/src/bridge.rs @@ -102,6 +102,18 @@ pub trait ChannelBridgeHandle: Send + Sync { /// Send a message to an agent and get the text response. async fn send_message(&self, agent_id: AgentId, message: &str) -> Result; + /// Send a message with sender identity so the agent knows who is talking. + async fn send_message_with_sender( + &self, + agent_id: AgentId, + message: &str, + sender_id: Option, + sender_name: Option, + ) -> Result { + // Default: fall back to send_message (backwards compatible) + self.send_message(agent_id, message).await + } + /// Send a message with structured content blocks (text + images) to an agent. /// /// Default implementation extracts text from blocks and falls back to `send_message()`. @@ -991,8 +1003,10 @@ async fn dispatch_message( let t = text.clone(); let aid = *aid; let name = name.clone(); + let sid = Some(message.sender.platform_id.clone()); + let sname = Some(message.sender.display_name.clone()); handles_vec.push(tokio::spawn(async move { - let result = h.send_message(aid, &t).await; + let result = h.send_message_with_sender(aid, &t, sid, sname).await; (name, aid, result) })); } @@ -1009,7 +1023,12 @@ async fn dispatch_message( openfang_types::config::BroadcastStrategy::Sequential => { for (name, maybe_id) in &targets { if let Some(aid) = maybe_id { - match handle.send_message(*aid, &text).await { + match handle.send_message_with_sender( + *aid, + &text, + Some(message.sender.platform_id.clone()), + Some(message.sender.display_name.clone()), + ).await { Ok(r) => responses.push(format!("[{name}]: {r}")), Err(e) => responses.push(format!("[{name}]: Error: {e}")), } @@ -1140,8 +1159,15 @@ async fn dispatch_message( text.clone() }; - // Send to agent and relay response - let result = handle.send_message(agent_id, &prefixed_text).await; + // Send to agent with sender identity so the agent knows who is talking. + let result = handle + .send_message_with_sender( + agent_id, + &prefixed_text, + Some(message.sender.platform_id.clone()), + Some(message.sender.display_name.clone()), + ) + .await; // Stop the typing refresh now that we have a response typing_task.abort(); diff --git a/crates/openfang-kernel/src/kernel.rs b/crates/openfang-kernel/src/kernel.rs index c91d02a85c..24f8d2fb09 100644 --- a/crates/openfang-kernel/src/kernel.rs +++ b/crates/openfang-kernel/src/kernel.rs @@ -1668,6 +1668,23 @@ impl OpenFangKernel { .await } + /// Send a message with sender identity (channel bridges use this for multi-user support). + pub async fn send_message_with_sender( + &self, + agent_id: AgentId, + message: &str, + sender_id: Option, + sender_name: Option, + ) -> KernelResult { + let handle: Option> = self + .self_handle + .get() + .and_then(|w| w.upgrade()) + .map(|arc| arc as Arc); + self.send_message_with_handle(agent_id, message, handle, sender_id, sender_name) + .await + } + /// Send a multimodal message (text + images) to an agent and get a response. /// /// Used by channel bridges when a user sends a photo — the image is downloaded, @@ -2093,7 +2110,7 @@ impl OpenFangKernel { .format("%A, %B %d, %Y (%Y-%m-%d %H:%M %Z)") .to_string(), ), - sender_id, + sender_id: sender_id.clone(), sender_name, // Re-read context.md per turn by default so external writers // (cron jobs, integrations) reach the LLM on the next message. @@ -2201,6 +2218,7 @@ impl OpenFangKernel { ctx_window, Some(&kernel_clone.process_manager), content_blocks, + sender_id, ) .await; @@ -2661,7 +2679,7 @@ impl OpenFangKernel { .format("%A, %B %d, %Y (%Y-%m-%d %H:%M %Z)") .to_string(), ), - sender_id, + sender_id: sender_id.clone(), sender_name, // Re-read context.md per turn by default (#843). context_md: manifest.workspace.as_ref().and_then(|w| { @@ -2777,6 +2795,7 @@ impl OpenFangKernel { ctx_window, Some(&self.process_manager), content_blocks, + sender_id, ) .await .map_err(KernelError::OpenFang)?; diff --git a/crates/openfang-runtime/src/agent_loop.rs b/crates/openfang-runtime/src/agent_loop.rs index 7f5f05edb5..a5e7bd8e29 100644 --- a/crates/openfang-runtime/src/agent_loop.rs +++ b/crates/openfang-runtime/src/agent_loop.rs @@ -280,6 +280,7 @@ pub async fn run_agent_loop( context_window_tokens: Option, process_manager: Option<&crate::process_manager::ProcessManager>, user_content_blocks: Option>, + sender_id: Option, ) -> OpenFangResult { info!(agent = %manifest.name, "Starting agent loop"); @@ -913,6 +914,7 @@ pub async fn run_agent_loop( tts_engine, docker_config, process_manager, + sender_id.as_deref(), ), ) .await @@ -1490,6 +1492,7 @@ pub async fn run_agent_loop_streaming( context_window_tokens: Option, process_manager: Option<&crate::process_manager::ProcessManager>, user_content_blocks: Option>, + sender_id: Option, ) -> OpenFangResult { info!(agent = %manifest.name, "Starting streaming agent loop"); @@ -2104,6 +2107,7 @@ pub async fn run_agent_loop_streaming( tts_engine, docker_config, process_manager, + sender_id.as_deref(), ), ) .await diff --git a/crates/openfang-runtime/src/tool_runner.rs b/crates/openfang-runtime/src/tool_runner.rs index 6307274712..155fb173aa 100644 --- a/crates/openfang-runtime/src/tool_runner.rs +++ b/crates/openfang-runtime/src/tool_runner.rs @@ -124,6 +124,7 @@ pub async fn execute_tool( tts_engine: Option<&crate::tts::TtsEngine>, docker_config: Option<&openfang_types::config::DockerSandboxConfig>, process_manager: Option<&crate::process_manager::ProcessManager>, + sender_id: Option<&str>, ) -> ToolResult { // Normalize the tool name through compat mappings so LLM-hallucinated aliases // (e.g. "fs-write" → "file_write") resolve to the canonical OpenFang name. @@ -351,7 +352,7 @@ pub async fn execute_tool( "ollama_structured" => tool_ollama_structured(input).await, // Cron scheduling tools - "cron_create" => tool_cron_create(input, kernel, caller_agent_id).await, + "cron_create" => tool_cron_create(input, kernel, caller_agent_id, sender_id).await, "cron_list" => tool_cron_list(kernel, caller_agent_id).await, "cron_cancel" => tool_cron_cancel(input, kernel).await, @@ -2273,10 +2274,29 @@ async fn tool_cron_create( input: &serde_json::Value, kernel: Option<&Arc>, caller_agent_id: Option<&str>, + sender_id: Option<&str>, ) -> Result { let kh = require_kernel(kernel)?; let agent_id = caller_agent_id.ok_or("Agent ID required for cron_create")?; - kh.cron_create(agent_id, input.clone()).await + + // If delivery is last_channel and we have a sender_id, replace with + // explicit channel delivery to prevent multi-user race conditions. + let mut job = input.clone(); + if let Some(sid) = sender_id { + let delivery = &job["delivery"]; + let is_last_channel = delivery["kind"].as_str() == Some("last_channel") + || delivery.is_null() + || !delivery.is_object(); + if is_last_channel { + job["delivery"] = serde_json::json!({ + "kind": "channel", + "channel": "telegram", + "to": sid + }); + } + } + + kh.cron_create(agent_id, job).await } async fn tool_cron_list( From b836610b96fb35e21a052ebfce5317b38e936fa0 Mon Sep 17 00:00:00 2001 From: Nideesh Date: Sun, 5 Apr 2026 04:56:22 +0000 Subject: [PATCH 04/31] feat: direct delivery for Channel cron jobs + action normalization - kernel.rs: When a cron job with Channel delivery fires, deliver the message text directly to Telegram without going through the LLM. Prevents rephrasing and "I need your channel ID" responses. - tool_runner.rs: Normalize system_event to agent_turn in cron_create so reminders always deliver via channels. Also catch delivery "none" alongside "last_channel" for sender_id replacement. Co-Authored-By: Claude Opus 4.6 (1M context) --- crates/openfang-kernel/src/kernel.rs | 18 +++++++++++++++ crates/openfang-runtime/src/tool_runner.rs | 26 +++++++++++++++++----- 2 files changed, 39 insertions(+), 5 deletions(-) diff --git a/crates/openfang-kernel/src/kernel.rs b/crates/openfang-kernel/src/kernel.rs index 24f8d2fb09..da7a7f153e 100644 --- a/crates/openfang-kernel/src/kernel.rs +++ b/crates/openfang-kernel/src/kernel.rs @@ -6136,6 +6136,24 @@ impl OpenFangKernel { timeout_secs, .. } => { + let delivery = job.delivery.clone(); + + // For Channel delivery (specific recipient), deliver the message + // directly without going through the LLM. + if matches!(delivery, openfang_types::scheduler::CronDelivery::Channel { .. }) { + match cron_deliver_response(self, agent_id, message, &delivery).await { + Ok(()) => { + self.cron_scheduler.record_success(job_id); + return Ok(message.clone()); + } + Err(e) => { + self.cron_scheduler.record_failure(job_id, &e); + return Err(e); + } + } + } + + // For other delivery types, go through the LLM. let timeout_s = timeout_secs.unwrap_or(120); let timeout = std::time::Duration::from_secs(timeout_s); let delivery = job.delivery.clone(); diff --git a/crates/openfang-runtime/src/tool_runner.rs b/crates/openfang-runtime/src/tool_runner.rs index 155fb173aa..53eb8c0d25 100644 --- a/crates/openfang-runtime/src/tool_runner.rs +++ b/crates/openfang-runtime/src/tool_runner.rs @@ -2279,15 +2279,31 @@ async fn tool_cron_create( let kh = require_kernel(kernel)?; let agent_id = caller_agent_id.ok_or("Agent ID required for cron_create")?; + let mut job = input.clone(); + + // Normalize system_event to agent_turn so cron delivery works via Telegram. + // system_event only publishes to the internal event bus, not to channels. + if job["action"]["kind"].as_str() == Some("system_event") { + let text = job["action"]["text"].as_str() + .or_else(|| job["action"]["message"].as_str()) + .or_else(|| job["action"]["description"].as_str()) + .unwrap_or("Reminder") + .to_string(); + job["action"] = serde_json::json!({ + "kind": "agent_turn", + "message": text, + "timeout_secs": 300 + }); + } + // If delivery is last_channel and we have a sender_id, replace with // explicit channel delivery to prevent multi-user race conditions. - let mut job = input.clone(); if let Some(sid) = sender_id { let delivery = &job["delivery"]; - let is_last_channel = delivery["kind"].as_str() == Some("last_channel") - || delivery.is_null() - || !delivery.is_object(); - if is_last_channel { + let needs_replacement = delivery.is_null() + || !delivery.is_object() + || matches!(delivery["kind"].as_str(), Some("last_channel") | Some("none") | None); + if needs_replacement { job["delivery"] = serde_json::json!({ "kind": "channel", "channel": "telegram", From ad721739bdf8464b2a65705178b19dfc9cf43420 Mon Sep 17 00:00:00 2001 From: Nideesh Date: Sun, 5 Apr 2026 05:03:53 +0000 Subject: [PATCH 05/31] docs: add fork-specific README with setup instructions Co-Authored-By: Claude Opus 4.6 (1M context) --- README.md | 43 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 43 insertions(+) diff --git a/README.md b/README.md index 2d685b09f2..1f9617ecc4 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,46 @@ +## 797th Fork + +This fork adds the following changes on top of upstream OpenFang: + +**`ollama_structured` tool** — Calls Ollama with JSON schema enforcement for guaranteed structured output. Agents use this for precise computed values (timestamps, numbers) instead of relying on LLM math. + +**Multi-user sender_id** — Passes the Telegram chat_id through the bridge > kernel > agent loop > tool runner. `cron_create` automatically replaces `last_channel` with the sender's actual chat_id, preventing race conditions where multiple users' reminders deliver to the wrong chat. + +**Direct cron delivery** — When a cron job with `Channel` delivery fires, the message is sent directly to Telegram without going through the LLM. Prevents rephrasing of reminder text. + +**Action normalization** — `system_event` actions in `cron_create` are automatically converted to `agent_turn` so reminders deliver via Telegram channels. + +### Quick Start (this fork) + +```bash +# Build +git clone https://github.com/797th/openfang.git && cd openfang +curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh +source ~/.cargo/env +cargo build --release -p openfang-cli + +# Install +curl -fsSL https://openfang.sh/install | sh +cp target/release/openfang ~/.openfang/bin/openfang + +# Config (from companion repo) +git clone -b nideesh https://github.com/797th/openfang-ollama-telegram.git +cp openfang-ollama-telegram/native/config.toml ~/.openfang/config.toml +cp -r openfang-ollama-telegram/native/agents ~/.openfang/agents +cp openfang-ollama-telegram/native/.env.example ~/.openfang/.env +# Edit ~/.openfang/.env with your TELEGRAM_BOT_TOKEN and NVIDIA_API_KEY + +# Ollama (for structured output + embeddings) +ollama pull gpt-oss:20b +ollama pull nomic-embed-text + +# Run +set -a && source ~/.openfang/.env && set +a +openfang start --config ~/.openfang/config.toml +``` + +--- +

OpenFang Logo

From 3f7aa0480e296b16b6b0b0f8cf2b913e4962b5c7 Mon Sep 17 00:00:00 2001 From: Nideesh Date: Sun, 5 Apr 2026 05:37:13 +0000 Subject: [PATCH 06/31] fix: default one_shot to true only for one-time "at" schedules Recurring schedules (every, cron) keep one_shot false so they persist. One-time reminders auto-delete after firing. Co-Authored-By: Claude Opus 4.6 (1M context) --- crates/openfang-runtime/src/tool_runner.rs | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/crates/openfang-runtime/src/tool_runner.rs b/crates/openfang-runtime/src/tool_runner.rs index 53eb8c0d25..954a34d01e 100644 --- a/crates/openfang-runtime/src/tool_runner.rs +++ b/crates/openfang-runtime/src/tool_runner.rs @@ -2296,6 +2296,12 @@ async fn tool_cron_create( }); } + // Default one_shot to true for one-time "at" schedules (reminders). + // Recurring schedules (every, cron) keep one_shot false. + if job["one_shot"].is_null() && job["schedule"]["kind"].as_str() == Some("at") { + job["one_shot"] = serde_json::json!(true); + } + // If delivery is last_channel and we have a sender_id, replace with // explicit channel delivery to prevent multi-user race conditions. if let Some(sid) = sender_id { From 9db1550f96a32715de7351667ff1c377576b97d8 Mon Sep 17 00:00:00 2001 From: Nideesh Date: Wed, 8 Apr 2026 22:15:15 +0000 Subject: [PATCH 07/31] feat: configurable check_interval_secs for hands + fix shell skill execution - Add check_interval_secs field to HandAgentConfig in HAND.toml - Wire it into kernel hand-to-agent mapping (was hardcoded to 3600s) - Defaults to 3600s if not set, so existing hands are unaffected - Allows polling hands to set faster tick intervals (e.g. 30s) - Fix shell skill execution: run script file directly instead of -s flag Co-Authored-By: Claude Opus 4.6 (1M context) --- crates/openfang-hands/src/lib.rs | 5 +++++ crates/openfang-kernel/src/kernel.rs | 15 +++++++++++++-- crates/openfang-skills/src/loader.rs | 5 ++--- 3 files changed, 20 insertions(+), 5 deletions(-) diff --git a/crates/openfang-hands/src/lib.rs b/crates/openfang-hands/src/lib.rs index 1bdb6783c3..8a5cfb1e38 100644 --- a/crates/openfang-hands/src/lib.rs +++ b/crates/openfang-hands/src/lib.rs @@ -298,6 +298,11 @@ pub struct HandAgentConfig { /// making long LLM calls. Omit to use the kernel default. #[serde(default)] pub heartbeat_interval_secs: Option, + /// Continuous loop tick interval in seconds. Controls how often the + /// autonomous agent wakes up and receives a tick message. + /// Defaults to 3600 (1 hour) if not set. + #[serde(default)] + pub check_interval_secs: Option, } fn default_module() -> String { diff --git a/crates/openfang-kernel/src/kernel.rs b/crates/openfang-kernel/src/kernel.rs index da7a7f153e..67886386ed 100644 --- a/crates/openfang-kernel/src/kernel.rs +++ b/crates/openfang-kernel/src/kernel.rs @@ -3619,10 +3619,10 @@ impl OpenFangKernel { }), // Autonomous hands must run in Continuous mode so the background loop picks them up. // Reactive (default) only fires on incoming messages, so autonomous hands would be inert. - // Default to 3600s (1 hour) to avoid wasting credits — see issue #848. + // Use HAND.toml check_interval_secs if set, otherwise default to 3600s (1 hour). schedule: if def.agent.max_iterations.is_some() { ScheduleMode::Continuous { - check_interval_secs: 3600, + check_interval_secs: def.agent.check_interval_secs.unwrap_or(3600), } } else { ScheduleMode::default() @@ -7190,6 +7190,17 @@ impl KernelHandle for OpenFangKernel { } } + fn get_delivery_context(&self, agent_id: &str, channel: &str) -> Option { + let aid: AgentId = agent_id.parse().ok()?; + let val = self.memory.structured_get(aid, "delivery.last_channel").ok()??; + let stored_channel = val["channel"].as_str()?; + if stored_channel == channel { + val["recipient"].as_str().map(|s| s.to_string()) + } else { + None + } + } + async fn send_channel_message( &self, channel: &str, diff --git a/crates/openfang-skills/src/loader.rs b/crates/openfang-skills/src/loader.rs index 21952963ad..a4ae1e0fb2 100644 --- a/crates/openfang-skills/src/loader.rs +++ b/crates/openfang-skills/src/loader.rs @@ -331,10 +331,9 @@ async fn execute_shell( debug!("Executing Shell skill: {} {}", shell, script_path.display()); - // Use -s to read from stdin, -c to execute command + // Run script file directly; JSON payload is piped via stdin (same as Python/Node) let mut cmd = tokio::process::Command::new(&shell); - cmd.arg("-s") - .arg(&script_path) + cmd.arg(&script_path) .current_dir(skill_dir) .stdin(Stdio::piped()) .stdout(Stdio::piped()) From ddecf03375d738b1f6bf6f73a1016c1bb4eead13 Mon Sep 17 00:00:00 2001 From: Nideesh Date: Wed, 8 Apr 2026 22:16:14 +0000 Subject: [PATCH 08/31] feat: propagate delivery context on agent switch + channel_send fallback When a user switches agents via /agent , the target agent now receives the delivery channel context (channel type + chat ID). This allows hands and autonomous agents to use channel_send without needing a hardcoded default_chat_id in config. Changes: - Add set_delivery_context to BridgeHandle trait (default no-op) - Implement in BridgeKernelImpl: writes delivery.last_channel to agent structured memory on /agent switch - Add get_delivery_context to KernelHandle trait - Implement in kernel: reads delivery.last_channel from structured memory - channel_send now falls back to delivery.last_channel when recipient is empty and no default_chat_id is configured - Pass channel_name to handle_command for context propagation Co-Authored-By: Claude Opus 4.6 (1M context) --- crates/openfang-api/src/channel_bridge.rs | 8 +++ crates/openfang-channels/src/bridge.rs | 55 +++++++------------- crates/openfang-runtime/src/kernel_handle.rs | 7 +++ crates/openfang-runtime/src/tool_runner.rs | 23 +++++--- 4 files changed, 51 insertions(+), 42 deletions(-) diff --git a/crates/openfang-api/src/channel_bridge.rs b/crates/openfang-api/src/channel_bridge.rs index f9e553ac84..0a7b125b0f 100644 --- a/crates/openfang-api/src/channel_bridge.rs +++ b/crates/openfang-api/src/channel_bridge.rs @@ -171,6 +171,14 @@ impl ChannelBridgeHandle for KernelBridgeAdapter { Ok(agent_id) } + async fn set_delivery_context(&self, agent_id: AgentId, channel: &str, recipient: &str) { + let kv_val = serde_json::json!({"channel": channel, "recipient": recipient}); + let _ = self + .kernel + .memory + .structured_set(agent_id, "delivery.last_channel", kv_val); + } + async fn uptime_info(&self) -> String { let uptime = self.started_at.elapsed(); let agents = self.list_agents().await.unwrap_or_default(); diff --git a/crates/openfang-channels/src/bridge.rs b/crates/openfang-channels/src/bridge.rs index 2431487082..4f5ad14405 100644 --- a/crates/openfang-channels/src/bridge.rs +++ b/crates/openfang-channels/src/bridge.rs @@ -143,6 +143,13 @@ pub trait ChannelBridgeHandle: Send + Sync { /// Spawn an agent by manifest name, returning its ID. async fn spawn_agent_by_name(&self, manifest_name: &str) -> Result; + /// Set the delivery channel context for an agent so it knows where to + /// send proactive messages via `channel_send`. Called when a user + /// switches to a different agent via `/agent `. + async fn set_delivery_context(&self, _agent_id: AgentId, _channel: &str, _recipient: &str) { + // Default no-op — real impl persists delivery.last_channel. + } + /// Transcribe raw audio bytes to text. async fn transcribe_audio( &self, @@ -857,15 +864,7 @@ async fn dispatch_message( // Handle commands first (early return) if let ChannelContent::Command { ref name, ref args } = message.content { - let result = handle_command( - name, - args, - handle, - router, - &message.sender, - sender_user_id(message), - ) - .await; + let result = handle_command(name, args, handle, router, &message.sender, ct_str).await; send_response(adapter, &message.sender, result, thread_id, output_format).await; return; } @@ -949,15 +948,7 @@ async fn dispatch_message( }; if is_channel_command(cmd) { - let result = handle_command( - cmd, - &args, - handle, - router, - &message.sender, - sender_user_id(message), - ) - .await; + let result = handle_command(cmd, &args, handle, router, &message.sender, ct_str).await; send_response(adapter, &message.sender, result, thread_id, output_format).await; return; } @@ -1727,7 +1718,7 @@ async fn handle_command( handle: &Arc, router: &Arc, sender: &ChannelUser, - user_id: &str, + channel_name: &str, ) -> String { // Canonicalise through the unified command registry: aliases resolve to // their canonical name and matching is case-insensitive. If the command @@ -1781,17 +1772,16 @@ async fn handle_command( let agent_name = &args[0]; match handle.find_agent_by_name(agent_name).await { Ok(Some(agent_id)) => { - // Key on user_id (the param wired in by the Discord/Slack call sites - // via sender_user_id(message)) — not sender.platform_id, which is the - // channel ID on those adapters. Matches the read-path resolution. - router.set_user_default(user_id.to_string(), agent_id); + router.set_user_default(sender.platform_id.clone(), agent_id); + handle.set_delivery_context(agent_id, channel_name, &sender.platform_id).await; format!("Now talking to agent: {agent_name}") } Ok(None) => { // Try to spawn it match handle.spawn_agent_by_name(agent_name).await { Ok(agent_id) => { - router.set_user_default(user_id.to_string(), agent_id); + router.set_user_default(sender.platform_id.clone(), agent_id); + handle.set_delivery_context(agent_id, channel_name, &sender.platform_id).await; format!("Spawned and connected to agent: {agent_name}") } Err(e) => { @@ -2055,10 +2045,10 @@ mod tests { openfang_user: None, }; - let result = handle_command("agents", &[], &handle, &router, &sender, "user1").await; + let result = handle_command("agents", &[], &handle, &router, &sender, "test").await; assert!(result.contains("coder")); - let result = handle_command("help", &[], &handle, &router, &sender, "user1").await; + let result = handle_command("help", &[], &handle, &router, &sender, "test").await; assert!(result.contains("/agents")); } @@ -2076,15 +2066,8 @@ mod tests { }; // Select existing agent - let result = handle_command( - "agent", - &["coder".to_string()], - &handle, - &router, - &sender, - "user1", - ) - .await; + let result = + handle_command("agent", &["coder".to_string()], &handle, &router, &sender, "test").await; assert!(result.contains("Now talking to agent: coder")); // Verify router was updated @@ -2147,7 +2130,7 @@ mod tests { openfang_user: None, }; - let result = handle_command("agent", &[], &handle, &router, &sender, "user1").await; + let result = handle_command("agent", &[], &handle, &router, &sender, "test").await; assert!(result.contains("Usage: /agent ")); assert!(result.contains("coder")); } diff --git a/crates/openfang-runtime/src/kernel_handle.rs b/crates/openfang-runtime/src/kernel_handle.rs index e3e1b7633c..fe20b678fc 100644 --- a/crates/openfang-runtime/src/kernel_handle.rs +++ b/crates/openfang-runtime/src/kernel_handle.rs @@ -192,6 +192,13 @@ pub trait KernelHandle: Send + Sync { None } + /// Get the delivery channel recipient from an agent's structured memory. + /// Returns the recipient ID if `delivery.last_channel` is set and matches + /// the requested channel. + fn get_delivery_context(&self, _agent_id: &str, _channel: &str) -> Option { + None + } + async fn send_channel_message( &self, channel: &str, diff --git a/crates/openfang-runtime/src/tool_runner.rs b/crates/openfang-runtime/src/tool_runner.rs index 954a34d01e..012928ca5c 100644 --- a/crates/openfang-runtime/src/tool_runner.rs +++ b/crates/openfang-runtime/src/tool_runner.rs @@ -357,7 +357,7 @@ pub async fn execute_tool( "cron_cancel" => tool_cron_cancel(input, kernel).await, // Channel send tool (proactive outbound messaging) - "channel_send" => tool_channel_send(input, kernel, workspace_root).await, + "channel_send" => tool_channel_send(input, kernel, workspace_root, caller_agent_id).await, // Persistent process tools "process_start" => { @@ -2351,6 +2351,7 @@ async fn tool_channel_send( input: &serde_json::Value, kernel: Option<&Arc>, workspace_root: Option<&Path>, + caller_agent_id: Option<&str>, ) -> Result { let kh = require_kernel(kernel)?; @@ -2364,16 +2365,26 @@ async fn tool_channel_send( .map(|s| s.trim().to_string()) .unwrap_or_default(); - // If recipient is empty, resolve from channel's default_chat_id config. + // If recipient is empty, resolve in order: + // 1. Channel's default_chat_id from config + // 2. Agent's delivery.last_channel from structured memory (set when user switches via /agent) let recipient = if recipient_input.is_empty() { let default_id = kh.get_channel_default_recipient(&channel).await; match default_id { Some(id) => id, None => { - return Err(format!( - "Missing 'recipient' parameter. Set default_chat_id in [channels.{channel}] config \ - or pass recipient explicitly." - )) + // Fallback: check agent's delivery.last_channel context + let last_channel = caller_agent_id + .and_then(|aid| kh.get_delivery_context(aid, &channel)); + match last_channel { + Some(id) => id, + None => { + return Err(format!( + "Missing 'recipient' parameter. Set default_chat_id in [channels.{channel}] config \ + or pass recipient explicitly." + )) + } + } } } } else { From 8937923c5ff9efb8dd6b201604cdea2c26814ebc Mon Sep 17 00:00:00 2001 From: Rishiatweb Date: Sat, 9 May 2026 12:56:39 +0530 Subject: [PATCH 09/31] feat: prioritize SearXNG over third-party providers in auto search cascade Change search_auto() priority: SearXNG (self-hosted) first, then DuckDuckGo as zero-config fallback. Third-party providers (Tavily, Brave, Perplexity) only tried if SearXNG URL is not configured. Co-Authored-By: Claude Sonnet 4.6 --- crates/openfang-runtime/src/web_search.rs | 33 ++++++++++++----------- 1 file changed, 17 insertions(+), 16 deletions(-) diff --git a/crates/openfang-runtime/src/web_search.rs b/crates/openfang-runtime/src/web_search.rs index 11b2f5823f..5d3003a89a 100644 --- a/crates/openfang-runtime/src/web_search.rs +++ b/crates/openfang-runtime/src/web_search.rs @@ -67,10 +67,21 @@ impl WebSearchEngine { result } - /// Auto-select provider based on available API keys. - /// Priority: Tavily → Brave → Perplexity → Searxng → DuckDuckGo + /// Auto-select provider based on configuration. + /// Priority: SearXNG (self-hosted) → DuckDuckGo (zero-config fallback). + /// Third-party API providers (Tavily, Brave, Perplexity) are tried only if + /// SearXNG is not configured and their API keys are present. async fn search_auto(&self, query: &str, max_results: usize) -> Result { - // Tavily first (AI-agent-native) + // SearXNG first — preferred self-hosted, privacy-respecting engine + if !self.config.searxng.url.is_empty() { + debug!("Auto: trying SearXNG"); + match self.search_searxng(query, max_results, None, 1).await { + Ok(result) => return Ok(result), + Err(e) => warn!("SearXNG failed, falling back: {e}"), + } + } + + // Tavily fallback (API key required) if resolve_api_key(&self.config.tavily.api_key_env).is_some() { debug!("Auto: trying Tavily"); match self.search_tavily(query, max_results).await { @@ -79,7 +90,7 @@ impl WebSearchEngine { } } - // Brave second + // Brave fallback (API key required) if resolve_api_key(&self.config.brave.api_key_env).is_some() { debug!("Auto: trying Brave"); match self.search_brave(query, max_results).await { @@ -88,7 +99,7 @@ impl WebSearchEngine { } } - // Perplexity third + // Perplexity fallback (API key required) if resolve_api_key(&self.config.perplexity.api_key_env).is_some() { debug!("Auto: trying Perplexity"); match self.search_perplexity(query).await { @@ -97,20 +108,10 @@ impl WebSearchEngine { } } - // Searxng fourth (self-hosted, no API key needed) - if !self.config.searxng.url.is_empty() { - debug!("Auto: trying Searxng"); - match self.search_searxng(query, max_results, None, 1).await { - Ok(result) => return Ok(result), - Err(e) => warn!("Searxng failed, falling back: {e}"), - } - } - - // DuckDuckGo always available as zero-config fallback + // DuckDuckGo — always available, zero-config last resort debug!("Auto: falling back to DuckDuckGo"); self.search_duckduckgo(query, max_results).await } - /// Search via Brave Search API. async fn search_brave(&self, query: &str, max_results: usize) -> Result { let api_key = From 98e1634a30ab9dacf692473c15793b8b5d6efa96 Mon Sep 17 00:00:00 2001 From: Rishiatweb Date: Sat, 9 May 2026 14:49:11 +0530 Subject: [PATCH 10/31] fix: replace stale user_id refs with sender.platform_id in handle_command MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit After rebase, 6 router.resolve() calls in handle_command still referenced user_id (the old upstream param name) which no longer exists — the param was renamed to channel_name in the custom commit. Replace all 6 with sender.platform_id.as_str() which is the correct routing key. --- crates/openfang-channels/src/bridge.rs | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/crates/openfang-channels/src/bridge.rs b/crates/openfang-channels/src/bridge.rs index 4f5ad14405..9e27bce26f 100644 --- a/crates/openfang-channels/src/bridge.rs +++ b/crates/openfang-channels/src/bridge.rs @@ -1796,7 +1796,7 @@ async fn handle_command( // Need to resolve the user's current agent let agent_id = router.resolve( &crate::types::ChannelType::CLI, - user_id, + sender.platform_id.as_str(), sender.openfang_user.as_deref(), ); match agent_id { @@ -1810,7 +1810,7 @@ async fn handle_command( "compact" => { let agent_id = router.resolve( &crate::types::ChannelType::CLI, - user_id, + sender.platform_id.as_str(), sender.openfang_user.as_deref(), ); match agent_id { @@ -1824,7 +1824,7 @@ async fn handle_command( "model" => { let agent_id = router.resolve( &crate::types::ChannelType::CLI, - user_id, + sender.platform_id.as_str(), sender.openfang_user.as_deref(), ); match agent_id { @@ -1848,7 +1848,7 @@ async fn handle_command( "stop" => { let agent_id = router.resolve( &crate::types::ChannelType::CLI, - user_id, + sender.platform_id.as_str(), sender.openfang_user.as_deref(), ); match agent_id { @@ -1862,7 +1862,7 @@ async fn handle_command( "usage" => { let agent_id = router.resolve( &crate::types::ChannelType::CLI, - user_id, + sender.platform_id.as_str(), sender.openfang_user.as_deref(), ); match agent_id { @@ -1876,7 +1876,7 @@ async fn handle_command( "think" => { let agent_id = router.resolve( &crate::types::ChannelType::CLI, - user_id, + sender.platform_id.as_str(), sender.openfang_user.as_deref(), ); match agent_id { From b39611b9c5a3d2aa1a95640a21ee1347de961561 Mon Sep 17 00:00:00 2001 From: Nideesh Date: Fri, 5 Jun 2026 14:24:43 +0000 Subject: [PATCH 11/31] feat: seed PVC agents/ from baked image on container boot Adds nideesh-agent manifest and entrypoint shim that copies /opt/openfang/agents/* into /data/agents/ on container start (cp -rn = no-clobber, so PVC writes win). Enables k8s pods to have agents present without manual kubectl cp into the volume. Co-Authored-By: Claude Opus 4.7 --- Dockerfile | 2 +- agents/nideesh-agent/agent.toml | 59 +++++++++++++++++++++++++++++++++ 2 files changed, 60 insertions(+), 1 deletion(-) create mode 100644 agents/nideesh-agent/agent.toml diff --git a/Dockerfile b/Dockerfile index 1174e95085..2302198f26 100644 --- a/Dockerfile +++ b/Dockerfile @@ -30,5 +30,5 @@ COPY --from=builder /build/agents /opt/openfang/agents EXPOSE 4200 VOLUME /data ENV OPENFANG_HOME=/data -ENTRYPOINT ["openfang"] +ENTRYPOINT ["/bin/sh","-c","mkdir -p /data/agents && cp -rn /opt/openfang/agents/. /data/agents/ 2>/dev/null || true; exec openfang \"$@\"","--"] CMD ["start"] diff --git a/agents/nideesh-agent/agent.toml b/agents/nideesh-agent/agent.toml new file mode 100644 index 0000000000..70b1cd3e7b --- /dev/null +++ b/agents/nideesh-agent/agent.toml @@ -0,0 +1,59 @@ +name = "nideesh-agent" +description = "Telegram front-door agent" +system_prompt = """You are a Telegram assistant. Plain text only. Keep replies short. + +When the user says "remind me", you MUST: +1. Call system_time to get current UTC +2. Call ollama_structured to compute the target timestamp +3. Call cron_create with the result + +For ollama_structured, use schema: {"type":"object","properties":{"iso_timestamp":{"type":"string"}},"required":["iso_timestamp"]} + +For cron_create, ALWAYS use exactly these fields: + action: {"kind":"agent_turn","message":"Reminder: ","timeout_secs":300} + delivery: {"kind":"last_channel"} + one_shot: true +NEVER use delivery kind "none". ALWAYS use "last_channel". + +Do NOT just say you will remind them. You MUST call the tools. +After creating a reminder, always confirm with a short message like "Reminder set for X minutes from now." + +When the user says "list reminders", call cron_list. +When the user says "cancel reminder", call cron_cancel. +When the user asks to activate a hand, call hand_activate. + +SEARCH & RESEARCH: +When the user says things like: + "search for HVAC companies in Austin" + "find plumbers near Dallas" + "look up precisionheatac.com" + "who owns ServiceWizard AC?" + "research contractors in Phoenix" + "find leads for AC repair in Texas" + "find bookkeeping firms that have client intake and online payment" + +Follow this workflow STRICTLY: +1. degoog_search — run 1-2 searches MAX. Do NOT keep retrying with different queries. Work with what you get. +2. Pick the top 3 most relevant results from the search. +3. degoog_fetch — fetch those 3 pages to extract details. +4. ollama_structured — extract structured lead data from each fetched page using this schema: + {"type":"object","properties":{"business_name":{"type":"string"},"owner":{"type":"string"},"phone":{"type":"string"},"email":{"type":"string"},"website":{"type":"string"},"services":{"type":"array","items":{"type":"string"}},"address":{"type":"string"},"pain_signals":{"type":"array","items":{"type":"string"}}},"required":["business_name"]} +5. Present the structured results to the user as a clean list. + +IMPORTANT RULES: +- NEVER search more than 3 times total. If results are sparse, summarize what you found. +- NEVER make up or hallucinate business names, URLs, or details. Only report what you actually found. +- If degoog_fetch returns empty/blocked content, tell the user the site needs JavaScript rendering. +- Always end with a text summary, even if results are incomplete. + +For everything else, answer directly. +""" + +[model] +provider = "nvidia" +model = "stepfun-ai/step-3.5-flash" + +[capabilities] +tools = ["cron_create", "cron_list", "cron_cancel", "hand_list", "hand_activate", "hand_status", "hand_deactivate", "channel_send", "system_time", "ollama_structured", "degoog_search", "degoog_fetch", "degoog_suggest"] +skills = ["degoog"] +network = [] From d767aa38338ba4b802dc47eb206306ee44df50cd Mon Sep 17 00:00:00 2001 From: dewanshshekhar Date: Thu, 18 Jun 2026 11:50:26 +0530 Subject: [PATCH 12/31] chore: ignore local daemon runtime & debugging artifacts Add .gstack/ plus daemon_out.log, daemon_err.log, build_log.txt, body.html, dash.html, and headers.txt to .gitignore. These are local runtime logs and curl/dashboard output dumps produced during local integration testing and should not be tracked. Co-Authored-By: Claude Opus 4.8 --- .gitignore | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/.gitignore b/.gitignore index 317e7b71c5..de371097ad 100644 --- a/.gitignore +++ b/.gitignore @@ -48,3 +48,12 @@ Thumbs.db # Personal deploy scripts scripts/deploy-remote.sh +.gstack/ + +# Local daemon runtime & debugging artifacts +daemon_out.log +daemon_err.log +build_log.txt +body.html +dash.html +headers.txt From 48f41c9731c94b85668529a38f19030f9498bf76 Mon Sep 17 00:00:00 2001 From: dewanshshekhar Date: Thu, 18 Jun 2026 20:58:56 +0530 Subject: [PATCH 13/31] feat: bake Dewansh agent + default config for container/Rancher deploy Makes a fresh deployment come up ready to run with the Dewansh research agent connected to Telegram, instead of an empty default assistant. - kernel: seed agents from /agents/*/agent.toml on first boot of a fresh data volume. The DB-restore path only *updates* agents already in the DB from disk; it never created new ones, so baked agents seeded onto a fresh PVC were never loaded. Gated on an empty DB so it behaves as a one-time seed: API deletes stay deleted (no resurrection from leftover on-disk TOML) and existing installs are never force-populated. - deploy/agents: curated baked set (Dewansh + assistant) using the nvidia default model. Kept separate from the repo example gallery under ./agents so the image ships only these two. - deploy/config.default.toml: default config seeded to the data volume on first boot (api_listen 0.0.0.0:4200, nvidia default model, Telegram channel routed to Dewansh). Secrets stay env-var references only. - Dockerfile: bake deploy/agents + deploy/config.default.toml; entrypoint seeds the config if the volume has none (never overwrites existing). - deploy/rancher/openfang.yaml: k8s template (Deployment + PVC + Service + Secret stub) documenting the required NVIDIA_API_KEY / TELEGRAM_BOT_TOKEN. Verified on a simulated fresh volume: exactly Dewansh + assistant load, and Dewansh performs web_search/web_fetch end-to-end. Co-Authored-By: Claude Opus 4.8 --- Dockerfile | 10 ++- crates/openfang-kernel/src/kernel.rs | 71 ++++++++++++++++-- deploy/agents/Dewansh/agent.toml | 35 +++++++++ deploy/agents/assistant/agent.toml | 11 +++ deploy/config.default.toml | 28 ++++++++ deploy/rancher/openfang.yaml | 103 +++++++++++++++++++++++++++ 6 files changed, 251 insertions(+), 7 deletions(-) create mode 100644 deploy/agents/Dewansh/agent.toml create mode 100644 deploy/agents/assistant/agent.toml create mode 100644 deploy/config.default.toml create mode 100644 deploy/rancher/openfang.yaml diff --git a/Dockerfile b/Dockerfile index 2302198f26..6853dc6725 100644 --- a/Dockerfile +++ b/Dockerfile @@ -26,9 +26,15 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ && rm -rf /var/lib/apt/lists/* COPY --from=builder /build/target/release/openfang /usr/local/bin/ -COPY --from=builder /build/agents /opt/openfang/agents +# Baked agents seeded to the data volume on first boot (Dewansh + assistant). +# Curated deploy set — NOT the repo's example agent gallery under ./agents. +COPY deploy/agents /opt/openfang/agents +# Baked default config (seeded to the data volume on first boot if absent). +COPY deploy/config.default.toml /opt/openfang/config.toml EXPOSE 4200 VOLUME /data ENV OPENFANG_HOME=/data -ENTRYPOINT ["/bin/sh","-c","mkdir -p /data/agents && cp -rn /opt/openfang/agents/. /data/agents/ 2>/dev/null || true; exec openfang \"$@\"","--"] +# On boot: seed baked agents into the PVC, and seed the default config if the +# volume has none yet. Existing files on the volume are never overwritten. +ENTRYPOINT ["/bin/sh","-c","mkdir -p /data/agents && cp -rn /opt/openfang/agents/. /data/agents/ 2>/dev/null || true; [ -f /data/config.toml ] || cp /opt/openfang/config.toml /data/config.toml 2>/dev/null || true; exec openfang \"$@\"","--"] CMD ["start"] diff --git a/crates/openfang-kernel/src/kernel.rs b/crates/openfang-kernel/src/kernel.rs index 67886386ed..9bc3e2622d 100644 --- a/crates/openfang-kernel/src/kernel.rs +++ b/crates/openfang-kernel/src/kernel.rs @@ -1394,6 +1394,61 @@ impl OpenFangKernel { } } + // Seed agents defined on disk on FIRST boot of a fresh data volume. + // + // This is how baked agents (seeded into /agents//agent.toml + // by the container entrypoint) get registered when the PVC is brand new. + // The DB-restore loop above only *updates* agents that already exist in + // the DB from their on-disk TOML; it never creates new ones, so without + // this step a freshly-provisioned volume would come up with no agents. + // + // Gated on an empty DB so it behaves like a one-time seed: once the + // agents are persisted, deleting one via the API is permanent (it won't + // be resurrected from its leftover on-disk TOML on the next boot), and + // existing installs are never force-populated with bundled templates. + let db_was_empty = kernel.registry.list().is_empty(); + let agents_dir = kernel.config.home_dir.join("agents"); + if db_was_empty { + if let Ok(dir_entries) = std::fs::read_dir(&agents_dir) { + let existing: std::collections::HashSet = kernel + .registry + .list() + .iter() + .map(|e| e.name.clone()) + .collect(); + for dir_entry in dir_entries.flatten() { + if !dir_entry.path().is_dir() { + continue; + } + let toml_path = dir_entry.path().join("agent.toml"); + if !toml_path.exists() { + continue; + } + let manifest = match std::fs::read_to_string(&toml_path) { + Ok(s) => match toml::from_str::(&s) { + Ok(m) => m, + Err(e) => { + warn!(path = %toml_path.display(), "Invalid agent TOML on disk, skipping import: {e}"); + continue; + } + }, + Err(e) => { + warn!(path = %toml_path.display(), "Failed to read agent TOML, skipping import: {e}"); + continue; + } + }; + if manifest.name.is_empty() || existing.contains(&manifest.name) { + continue; + } + info!(agent = %manifest.name, "Importing agent from disk (not present in DB)"); + match kernel.spawn_agent(manifest) { + Ok(id) => info!(id = %id, "Imported agent from disk"), + Err(e) => warn!("Failed to import agent from disk: {e}"), + } + } + } + } + // If no agents exist (fresh install), spawn a default assistant if kernel.registry.list().is_empty() { info!("No agents found — spawning default assistant"); @@ -6140,7 +6195,10 @@ impl OpenFangKernel { // For Channel delivery (specific recipient), deliver the message // directly without going through the LLM. - if matches!(delivery, openfang_types::scheduler::CronDelivery::Channel { .. }) { + if matches!( + delivery, + openfang_types::scheduler::CronDelivery::Channel { .. } + ) { match cron_deliver_response(self, agent_id, message, &delivery).await { Ok(()) => { self.cron_scheduler.record_success(job_id); @@ -7192,7 +7250,10 @@ impl KernelHandle for OpenFangKernel { fn get_delivery_context(&self, agent_id: &str, channel: &str) -> Option { let aid: AgentId = agent_id.parse().ok()?; - let val = self.memory.structured_get(aid, "delivery.last_channel").ok()??; + let val = self + .memory + .structured_get(aid, "delivery.last_channel") + .ok()??; let stored_channel = val["channel"].as_str()?; if stored_channel == channel { val["recipient"].as_str().map(|s| s.to_string()) @@ -7568,8 +7629,7 @@ mod tests { assert_eq!(merged.description, "new", "TOML edits must apply"); assert_eq!( - merged.workspace, - entry.workspace, + merged.workspace, entry.workspace, "kernel-assigned workspace must survive a TOML edit that omits it" ); assert!( @@ -8231,7 +8291,8 @@ mod tests { "fallback timeout edits must be hot-reloadable" ); assert!( - plan.hot_actions.contains(&HotAction::ReloadFallbackProviders), + plan.hot_actions + .contains(&HotAction::ReloadFallbackProviders), "ReloadFallbackProviders must be present in the plan" ); diff --git a/deploy/agents/Dewansh/agent.toml b/deploy/agents/Dewansh/agent.toml new file mode 100644 index 0000000000..8d955d67ac --- /dev/null +++ b/deploy/agents/Dewansh/agent.toml @@ -0,0 +1,35 @@ +name = "Dewansh" +description = "Research agent — web search + fetch, Telegram front-door" +module = "builtin:chat" +schedule = "reactive" +priority = "Normal" +skills = ["web-search"] +system_prompt = """ +You are Dewansh, a knowledgeable research assistant. + +When the user asks a question that needs current or external information +(news, docs, prices, "look up", "research", "find", "what is the latest"), +do this: +1. Call web_search to find relevant sources. +2. Call web_fetch on the 1-3 most relevant results to read the actual pages. +3. Synthesize a clear answer and cite the source URLs you used. + +Rules: +- Never invent URLs, names, or facts. Only report what search/fetch returned. +- Keep replies concise and plain-text (this agent answers over Telegram). +- If search returns nothing useful, say so instead of guessing. + +For everything else, answer directly. +""" + +[model] +provider = "nvidia" +model = "stepfun-ai/step-3.7-flash" +max_tokens = 4096 +temperature = 0.7 +api_key_env = "NVIDIA_API_KEY" + +[capabilities] +tools = ["web_search", "web_fetch", "channel_send"] +skills = ["web-search"] +network = [] diff --git a/deploy/agents/assistant/agent.toml b/deploy/agents/assistant/agent.toml new file mode 100644 index 0000000000..68725a1a53 --- /dev/null +++ b/deploy/agents/assistant/agent.toml @@ -0,0 +1,11 @@ +name = "assistant" +description = "General-purpose assistant" +module = "builtin:chat" +schedule = "reactive" +priority = "Normal" + +[model] +provider = "nvidia" +model = "stepfun-ai/step-3.7-flash" +system_prompt = "You are a helpful AI assistant." +api_key_env = "NVIDIA_API_KEY" diff --git a/deploy/config.default.toml b/deploy/config.default.toml new file mode 100644 index 0000000000..da0111a8d9 --- /dev/null +++ b/deploy/config.default.toml @@ -0,0 +1,28 @@ +# OpenFang — baked default configuration. +# +# This file is copied into the image at /opt/openfang/config.toml and seeded +# to $OPENFANG_HOME/config.toml (/data/config.toml) on first container boot, +# ONLY if no config already exists on the data volume. A config already present +# on the PVC is never overwritten. +# +# Secrets are referenced by environment-variable NAME, never stored here. +# Required env vars in the deployment: +# NVIDIA_API_KEY — powers the default model +# TELEGRAM_BOT_TOKEN — Telegram bot token from @BotFather + +# Bind on all interfaces so the dashboard/API is reachable inside the cluster. +# Can be overridden at runtime with OPENFANG_LISTEN. +api_listen = "0.0.0.0:4200" + +[default_model] +provider = "nvidia" +model = "stepfun-ai/step-3.7-flash" +api_key_env = "NVIDIA_API_KEY" + +[memory] +decay_rate = 0.05 + +[channels.telegram] +bot_token_env = "TELEGRAM_BOT_TOKEN" +default_agent = "Dewansh" +poll_interval_secs = 1 diff --git a/deploy/rancher/openfang.yaml b/deploy/rancher/openfang.yaml new file mode 100644 index 0000000000..50040d9f61 --- /dev/null +++ b/deploy/rancher/openfang.yaml @@ -0,0 +1,103 @@ +# OpenFang — Rancher / Kubernetes deployment template (test environment). +# +# This is a starting point: adjust namespace, image registry, storageClassName, +# and resource limits to match your cluster, then apply with: +# +# 1. Create the secret (do NOT commit real tokens): +# kubectl create secret generic openfang-secrets \ +# --from-literal=NVIDIA_API_KEY='nvapi-...' \ +# --from-literal=TELEGRAM_BOT_TOKEN='123456:ABC-...' +# +# 2. Apply this manifest: +# kubectl apply -f deploy/rancher/openfang.yaml +# +# The Dewansh agent and a default config.toml are baked into the image and +# seeded onto the PVC on first boot, so a fresh volume comes up ready to run. +--- +apiVersion: v1 +kind: PersistentVolumeClaim +metadata: + name: openfang-data +spec: + accessModes: + - ReadWriteOnce + resources: + requests: + storage: 2Gi + # storageClassName: # uncomment/set for your cluster +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: openfang + labels: + app: openfang +spec: + replicas: 1 + # Single writer to the PVC — recreate rather than overlap on rollout. + strategy: + type: Recreate + selector: + matchLabels: + app: openfang + template: + metadata: + labels: + app: openfang + spec: + containers: + - name: openfang + # Replace with your built image (e.g. pushed to the cluster registry). + image: ghcr.io/rightnow-ai/openfang:latest + imagePullPolicy: Always + args: ["start"] + ports: + - containerPort: 4200 + name: http + env: + - name: OPENFANG_LISTEN + value: "0.0.0.0:4200" + envFrom: + - secretRef: + name: openfang-secrets # provides NVIDIA_API_KEY, TELEGRAM_BOT_TOKEN + volumeMounts: + - name: data + mountPath: /data + readinessProbe: + httpGet: + path: /api/health + port: 4200 + initialDelaySeconds: 10 + periodSeconds: 10 + livenessProbe: + httpGet: + path: /api/health + port: 4200 + initialDelaySeconds: 30 + periodSeconds: 30 + resources: + requests: + cpu: "250m" + memory: "256Mi" + limits: + cpu: "1" + memory: "1Gi" + volumes: + - name: data + persistentVolumeClaim: + claimName: openfang-data +--- +apiVersion: v1 +kind: Service +metadata: + name: openfang + labels: + app: openfang +spec: + type: ClusterIP + selector: + app: openfang + ports: + - name: http + port: 4200 + targetPort: 4200 From 21e26f8113e96c05fa06835823809c2b48b6c374 Mon Sep 17 00:00:00 2001 From: dewanshshekhar Date: Thu, 18 Jun 2026 21:42:30 +0530 Subject: [PATCH 14/31] refactor(docker): extract entrypoint to logged script (no silent failures) Addresses CodeRabbit review: the inline ENTRYPOINT used `2>/dev/null || true` around the agent/config seeding, so a failed seed (bad volume permissions, full disk) would be swallowed and the daemon would come up with no agents/config and no diagnostic. - deploy/entrypoint.sh: dedicated script that seeds baked agents + default config with explicit per-step logging and warnings on failure, then execs the daemon. set -eu; cp -n preserves existing volume files. - Dockerfile: COPY + chmod the script, ENTRYPOINT calls it. - .gitattributes: force LF on *.sh so the entrypoint runs under /bin/sh regardless of the checkout host. Co-Authored-By: Claude Opus 4.8 --- .gitattributes | 3 +++ Dockerfile | 8 +++++--- deploy/entrypoint.sh | 48 ++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 56 insertions(+), 3 deletions(-) create mode 100644 .gitattributes create mode 100644 deploy/entrypoint.sh diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000000..fe675321a0 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,3 @@ +# Shell scripts must use LF so they run inside Linux containers regardless of +# the host that checked them out (a CRLF entrypoint.sh fails under /bin/sh). +*.sh text eol=lf diff --git a/Dockerfile b/Dockerfile index 6853dc6725..3e5970eac3 100644 --- a/Dockerfile +++ b/Dockerfile @@ -31,10 +31,12 @@ COPY --from=builder /build/target/release/openfang /usr/local/bin/ COPY deploy/agents /opt/openfang/agents # Baked default config (seeded to the data volume on first boot if absent). COPY deploy/config.default.toml /opt/openfang/config.toml +# Entrypoint: seeds baked agents + default config onto the data volume on first +# boot (with diagnostic logging), then execs the daemon. See deploy/entrypoint.sh. +COPY deploy/entrypoint.sh /usr/local/bin/openfang-entrypoint.sh +RUN chmod +x /usr/local/bin/openfang-entrypoint.sh EXPOSE 4200 VOLUME /data ENV OPENFANG_HOME=/data -# On boot: seed baked agents into the PVC, and seed the default config if the -# volume has none yet. Existing files on the volume are never overwritten. -ENTRYPOINT ["/bin/sh","-c","mkdir -p /data/agents && cp -rn /opt/openfang/agents/. /data/agents/ 2>/dev/null || true; [ -f /data/config.toml ] || cp /opt/openfang/config.toml /data/config.toml 2>/dev/null || true; exec openfang \"$@\"","--"] +ENTRYPOINT ["/usr/local/bin/openfang-entrypoint.sh"] CMD ["start"] diff --git a/deploy/entrypoint.sh b/deploy/entrypoint.sh new file mode 100644 index 0000000000..d502d447e5 --- /dev/null +++ b/deploy/entrypoint.sh @@ -0,0 +1,48 @@ +#!/bin/sh +# OpenFang container entrypoint. +# +# Seeds baked agents and a default config onto the data volume on first boot, +# then execs the daemon. Seeding never overwrites files that already exist on +# the volume. Failures are logged (not silently swallowed) so a misconfigured +# volume — wrong permissions, full disk — is diagnosable instead of producing a +# daemon that mysteriously comes up with no agents or config. + +set -eu + +DATA_DIR="${OPENFANG_HOME:-/data}" +BAKED_AGENTS="/opt/openfang/agents" +BAKED_CONFIG="/opt/openfang/config.toml" + +log() { echo "[entrypoint] $*"; } +warn() { echo "[entrypoint] WARNING: $*" >&2; } + +log "starting; data dir: $DATA_DIR" + +# Seed baked agents (cp -n: never clobber agents already on the volume). +if ! mkdir -p "$DATA_DIR/agents"; then + warn "could not create $DATA_DIR/agents — agent seeding skipped" +elif [ -d "$BAKED_AGENTS" ]; then + if cp -rn "$BAKED_AGENTS/." "$DATA_DIR/agents/"; then + log "seeded baked agents into $DATA_DIR/agents" + else + warn "failed to seed baked agents into $DATA_DIR/agents (continuing)" + fi +else + log "no baked agents at $BAKED_AGENTS — skipping agent seed" +fi + +# Seed the default config only when the volume has none. +if [ -f "$DATA_DIR/config.toml" ]; then + log "existing config at $DATA_DIR/config.toml — leaving as-is" +elif [ -f "$BAKED_CONFIG" ]; then + if cp "$BAKED_CONFIG" "$DATA_DIR/config.toml"; then + log "seeded default config to $DATA_DIR/config.toml" + else + warn "failed to seed default config to $DATA_DIR/config.toml (continuing)" + fi +else + log "no baked config at $BAKED_CONFIG — skipping config seed" +fi + +log "exec: openfang $*" +exec openfang "$@" From 2dcea18f0ef8f766db7e325665cbb9d7085ddc06 Mon Sep 17 00:00:00 2001 From: dewanshshekhar Date: Sat, 20 Jun 2026 16:36:53 +0530 Subject: [PATCH 15/31] fix(agents/dewansh): drop duplicate skills + repair source prompt - deploy/agents/Dewansh/agent.toml: remove redundant skills=[web-search] from [capabilities]; the same skill is already declared at the top-level skills field, so the duplicate triggered loader warnings. - agents/Dewansh/agent.toml: add the source-of-truth agent manifest (was previously untracked) and fix a split-word typo in the system prompt (If s\neach -> If search) that would have corrupted the rendered prompt. --- agents/Dewansh/agent.toml | 37 ++++++++++++++++++++++++++++++++ deploy/agents/Dewansh/agent.toml | 1 - 2 files changed, 37 insertions(+), 1 deletion(-) create mode 100644 agents/Dewansh/agent.toml diff --git a/agents/Dewansh/agent.toml b/agents/Dewansh/agent.toml new file mode 100644 index 0000000000..82fc1c9681 --- /dev/null +++ b/agents/Dewansh/agent.toml @@ -0,0 +1,37 @@ + + +name = "Dewansh" +description = "Research agent — web search + fetch, Telegram front-door" +module = "builtin:chat" +schedule = "reactive" +priority = "Normal" +skills = ["web-search"] +system_prompt = """ +You are Dewansh, a knowledgeable research assistant. + +When the user asks a question that needs current or external information +(news, docs, prices, "look up", "research", "find", "what is the latest"), +do this: +1. Call web_search to find relevant sources. +2. Call web_fetch on the 1-3 most relevant results to read the actual pages. +3. Synthesize a clear answer and cite the source URLs you used. + +Rules: +- Never invent URLs, names, or facts. Only report what search/fetch returned. +- Keep replies concise and plain-text (this agent answers over Telegram). +- If search returns nothing useful, say so instead of guessing. + +For everything else, answer directly. +""" + +[model] +provider = "nvidia" +model = "stepfun-ai/step-3.7-flash" +max_tokens = 4096 +temperature = 0.7 +api_key_env = "NVIDIA_API_KEY" + +[capabilities] +tools = ["web_search", "web_fetch", "channel_send"] +skills = ["web-search"] +network = [] diff --git a/deploy/agents/Dewansh/agent.toml b/deploy/agents/Dewansh/agent.toml index 8d955d67ac..0ed109f896 100644 --- a/deploy/agents/Dewansh/agent.toml +++ b/deploy/agents/Dewansh/agent.toml @@ -31,5 +31,4 @@ api_key_env = "NVIDIA_API_KEY" [capabilities] tools = ["web_search", "web_fetch", "channel_send"] -skills = ["web-search"] network = [] From c5a05e5866527668c8ed27d017bbb83a5376d814 Mon Sep 17 00:00:00 2001 From: dewanshshekhar Date: Sun, 21 Jun 2026 19:16:57 +0530 Subject: [PATCH 16/31] fix(agents/dewansh): drop redundant [capabilities].skills The duplicate skills=[web-search] under [capabilities] is silently ignored by the loader (ManifestCapabilities has no skills field; the top-level skills on AgentManifest is what gets read). Keeping it as a no-op duplicate only invites confusion and future drift from the canonical pattern used by nideesh-agent. --- agents/Dewansh/agent.toml | 1 - 1 file changed, 1 deletion(-) diff --git a/agents/Dewansh/agent.toml b/agents/Dewansh/agent.toml index 82fc1c9681..be9ea677ee 100644 --- a/agents/Dewansh/agent.toml +++ b/agents/Dewansh/agent.toml @@ -33,5 +33,4 @@ api_key_env = "NVIDIA_API_KEY" [capabilities] tools = ["web_search", "web_fetch", "channel_send"] -skills = ["web-search"] network = [] From eb6c5ab47c9baa012798460f19776873f4270e93 Mon Sep 17 00:00:00 2001 From: dewanshshekhar Date: Sun, 21 Jun 2026 19:49:42 +0530 Subject: [PATCH 17/31] fix(deploy): address CodeRabbit review on Dewansh agent + Rancher manifest - Dewansh agent.toml: move system_prompt under [model]. It's a ModelConfig field; at the manifest root serde silently drops it, so the agent was falling back to the default prompt instead of the research persona. - kernel: gate one-time agent seeding on a persisted .agents_seeded marker on the data volume instead of an empty registry. Prevents baked agents from being resurrected when an operator deletes them via the API but the on-disk TOML remains (the entrypoint re-seeds it each boot). - kernel: dedup imported agents by name within a single seed cycle so two disk manifests sharing a name can't both spawn. - rancher: add pod/container securityContext (non-root, drop caps, readOnlyRootFilesystem + writable /tmp), set OPENFANG_HOME explicitly, and lengthen probe initialDelaySeconds for first-boot seeding. Co-Authored-By: Claude Opus 4.8 --- crates/openfang-kernel/src/kernel.rs | 25 ++++++++++++++++------ deploy/agents/Dewansh/agent.toml | 16 ++++++++------ deploy/rancher/openfang.yaml | 32 ++++++++++++++++++++++++++-- 3 files changed, 57 insertions(+), 16 deletions(-) diff --git a/crates/openfang-kernel/src/kernel.rs b/crates/openfang-kernel/src/kernel.rs index 9bc3e2622d..6b9706b761 100644 --- a/crates/openfang-kernel/src/kernel.rs +++ b/crates/openfang-kernel/src/kernel.rs @@ -1402,15 +1402,18 @@ impl OpenFangKernel { // the DB from their on-disk TOML; it never creates new ones, so without // this step a freshly-provisioned volume would come up with no agents. // - // Gated on an empty DB so it behaves like a one-time seed: once the - // agents are persisted, deleting one via the API is permanent (it won't - // be resurrected from its leftover on-disk TOML on the next boot), and + // "One-time" is enforced by a marker file persisted next to the data, + // NOT purely by an empty registry. Gating on emptiness alone would + // resurrect baked agents whenever an operator deletes every agent via + // the API but leaves the on-disk TOML in place (e.g. re-seeded by the + // container entrypoint on each boot). The marker lives on the data + // volume, so once seeding has run those deletions stay deleted, and // existing installs are never force-populated with bundled templates. - let db_was_empty = kernel.registry.list().is_empty(); let agents_dir = kernel.config.home_dir.join("agents"); - if db_was_empty { + let seed_marker = kernel.config.home_dir.join(".agents_seeded"); + if !seed_marker.exists() && kernel.registry.list().is_empty() { if let Ok(dir_entries) = std::fs::read_dir(&agents_dir) { - let existing: std::collections::HashSet = kernel + let mut existing: std::collections::HashSet = kernel .registry .list() .iter() @@ -1437,7 +1440,9 @@ impl OpenFangKernel { continue; } }; - if manifest.name.is_empty() || existing.contains(&manifest.name) { + // insert() returns false if the name was already seen this + // cycle, so two disk manifests sharing a name can't both spawn. + if manifest.name.trim().is_empty() || !existing.insert(manifest.name.clone()) { continue; } info!(agent = %manifest.name, "Importing agent from disk (not present in DB)"); @@ -1446,6 +1451,12 @@ impl OpenFangKernel { Err(e) => warn!("Failed to import agent from disk: {e}"), } } + // Record that the one-time seed has run. Only written once we + // successfully scanned the agents dir, so a directory that + // appears later can still seed on a subsequent boot. + if let Err(e) = std::fs::write(&seed_marker, b"seeded\n") { + warn!(path = %seed_marker.display(), "Failed to write agent seed marker: {e}"); + } } } diff --git a/deploy/agents/Dewansh/agent.toml b/deploy/agents/Dewansh/agent.toml index 0ed109f896..c7b34cc564 100644 --- a/deploy/agents/Dewansh/agent.toml +++ b/deploy/agents/Dewansh/agent.toml @@ -4,6 +4,15 @@ module = "builtin:chat" schedule = "reactive" priority = "Normal" skills = ["web-search"] + +[model] +provider = "nvidia" +model = "stepfun-ai/step-3.7-flash" +max_tokens = 4096 +temperature = 0.7 +api_key_env = "NVIDIA_API_KEY" +# system_prompt is a [model] field — defining it at the manifest root makes +# serde silently drop it and the agent falls back to the default prompt. system_prompt = """ You are Dewansh, a knowledgeable research assistant. @@ -22,13 +31,6 @@ Rules: For everything else, answer directly. """ -[model] -provider = "nvidia" -model = "stepfun-ai/step-3.7-flash" -max_tokens = 4096 -temperature = 0.7 -api_key_env = "NVIDIA_API_KEY" - [capabilities] tools = ["web_search", "web_fetch", "channel_send"] network = [] diff --git a/deploy/rancher/openfang.yaml b/deploy/rancher/openfang.yaml index 50040d9f61..5fc50e4bba 100644 --- a/deploy/rancher/openfang.yaml +++ b/deploy/rancher/openfang.yaml @@ -45,35 +45,61 @@ spec: labels: app: openfang spec: + # Run as an unprivileged user; fsGroup makes the PVC group-writable so + # the entrypoint can seed agents/config into /data as uid 1000. + securityContext: + runAsNonRoot: true + runAsUser: 1000 + fsGroup: 1000 + seccompProfile: + type: RuntimeDefault containers: - name: openfang # Replace with your built image (e.g. pushed to the cluster registry). image: ghcr.io/rightnow-ai/openfang:latest imagePullPolicy: Always args: ["start"] + securityContext: + allowPrivilegeEscalation: false + readOnlyRootFilesystem: true + capabilities: + drop: + - ALL ports: - containerPort: 4200 name: http env: + # OPENFANG_HOME is also baked into the image, but set it explicitly + # so the daemon persists to the PVC regardless of the image used. + - name: OPENFANG_HOME + value: "/data" - name: OPENFANG_LISTEN value: "0.0.0.0:4200" envFrom: - secretRef: name: openfang-secrets # provides NVIDIA_API_KEY, TELEGRAM_BOT_TOKEN + # The API binds 0.0.0.0 and is reachable cluster-wide via the Service. + # It is unauthenticated by default. To require a bearer token, set + # the top-level `api_key = ""` field in config.toml, and + # front the Service with an authenticating ingress for external access. volumeMounts: - name: data mountPath: /data + # readOnlyRootFilesystem is on, so give the app a writable /tmp. + - name: tmp + mountPath: /tmp readinessProbe: httpGet: path: /api/health port: 4200 - initialDelaySeconds: 10 + # First boot seeds agents + config from disk before the API is up. + initialDelaySeconds: 30 periodSeconds: 10 livenessProbe: httpGet: path: /api/health port: 4200 - initialDelaySeconds: 30 + initialDelaySeconds: 60 periodSeconds: 30 resources: requests: @@ -86,6 +112,8 @@ spec: - name: data persistentVolumeClaim: claimName: openfang-data + - name: tmp + emptyDir: {} --- apiVersion: v1 kind: Service From a641e48f8b22b20c51434ff8e7d24362b86cb383 Mon Sep 17 00:00:00 2001 From: dewanshshekhar Date: Sun, 21 Jun 2026 21:53:47 +0530 Subject: [PATCH 18/31] deploy: add corrected CI/Rancher manifest (envsubst-ready) Repo-tracked manifest so the pipeline can reference a file instead of an inline paste (which corrupted the YAML with line-number prefixes and lost indentation). Fixes the truncated ${VAR} tokens, image path, and POD_* fieldRefs from the failing deploy. Co-Authored-By: Claude Opus 4.8 --- deploy/rancher/openfang.ci.yaml | 190 ++++++++++++++++++++++++++++++++ 1 file changed, 190 insertions(+) create mode 100644 deploy/rancher/openfang.ci.yaml diff --git a/deploy/rancher/openfang.ci.yaml b/deploy/rancher/openfang.ci.yaml new file mode 100644 index 0000000000..9148b0fb06 --- /dev/null +++ b/deploy/rancher/openfang.ci.yaml @@ -0,0 +1,190 @@ +# OpenFang — CI / Rancher deployment template (envsubst-rendered). +# +# Variables substituted by the pipeline before `kubectl apply`: +# ENVIRONMENT e.g. "test" +# DOMAIN e.g. "748th" +# IMAGE_TAG e.g. "v0.0.2" +# DOCKER_CONFIG_JSON raw docker config JSON for the pull secret +# NVIDIA_API_KEY powers the Dewansh agent +# TELEGRAM_BOT_TOKEN bot token from @BotFather +# +# NOTE: the image registry host (employee-harbor.748th.com) is hardcoded on +# purpose — it matches the verified push target. Do NOT rebuild it from +# ${DOMAIN}, or a wrong value will break the image pull. +# +# Guard before applying (fails if any ${VAR} survived substitution): +# rendered=$(envsubst < openfang.ci.yaml) +# echo "$rendered" | grep -n '\${' && { echo "unsubstituted vars remain"; exit 1; } +# echo "$rendered" | kubectl apply -f - +--- +apiVersion: v1 +kind: Namespace +metadata: + name: openfang +--- +apiVersion: v1 +kind: Secret +metadata: + name: regcred${ENVIRONMENT}${DOMAIN} + namespace: openfang +type: kubernetes.io/dockerconfigjson +stringData: + .dockerconfigjson: ${DOCKER_CONFIG_JSON} +--- +# Secrets for the Dewansh agent + Telegram bot. Injected by CI (envsubst). +apiVersion: v1 +kind: Secret +metadata: + name: openfang-secrets + namespace: openfang +type: Opaque +stringData: + NVIDIA_API_KEY: ${NVIDIA_API_KEY} + TELEGRAM_BOT_TOKEN: ${TELEGRAM_BOT_TOKEN} +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: openfang + namespace: openfang +spec: + replicas: 1 + # Single writer to the PVC — recreate instead of overlapping pods on rollout. + strategy: + type: Recreate + selector: + matchLabels: + app: openfang + template: + metadata: + labels: + app: openfang + spec: + containers: + - name: openfang + image: employee-harbor.748th.com/docker-registry/openfang:${IMAGE_TAG} + args: ["start"] + ports: + - containerPort: 4200 + env: + - name: ENVIRONMENT_TYPE + value: "TEST" + - name: VERSION + value: ${IMAGE_TAG} + - name: POD_NAME + valueFrom: + fieldRef: + fieldPath: metadata.name + - name: POD_NAMESPACE + valueFrom: + fieldRef: + fieldPath: metadata.namespace + - name: POD_UID + valueFrom: + fieldRef: + fieldPath: metadata.uid + - name: DOMAIN + value: ${ENVIRONMENT}.${DOMAIN}.com + # Persist to the PVC so the baked Dewansh agent + config (seeded on + # first boot by the entrypoint) survive restarts. + - name: OPENFANG_HOME + value: "/data" + - name: OPENFANG_LISTEN + value: "0.0.0.0:4200" + # Pulls NVIDIA_API_KEY + TELEGRAM_BOT_TOKEN into the container. + envFrom: + - secretRef: + name: openfang-secrets + resources: + limits: + cpu: "500m" + memory: "512Mi" + requests: + cpu: "10m" + memory: "32Mi" + volumeMounts: + - name: data + mountPath: /data + readinessProbe: + httpGet: + path: /api/health + port: 4200 + initialDelaySeconds: 30 + periodSeconds: 10 + livenessProbe: + httpGet: + path: /api/health + port: 4200 + initialDelaySeconds: 60 + periodSeconds: 30 + volumes: + - name: data + persistentVolumeClaim: + claimName: openfang-data + imagePullSecrets: + - name: regcred${ENVIRONMENT}${DOMAIN} +--- +apiVersion: v1 +kind: Service +metadata: + name: openfang + namespace: openfang +spec: + type: ClusterIP + ports: + - name: http + port: 80 + targetPort: 4200 + protocol: TCP + selector: + app: openfang +--- +apiVersion: traefik.containo.us/v1alpha1 +kind: Middleware +metadata: + name: oauth2-proxy-middleware + namespace: openfang +spec: + forwardAuth: + # TODO: set to your real oauth2-proxy auth endpoint (left as-is per your request). + address: https://auth-employee.${DOMAIN}.com/oauth2/auth + trustForwardHeader: true +--- +apiVersion: traefik.containo.us/v1alpha1 +kind: IngressRoute +metadata: + name: openfang-ingress + namespace: openfang + annotations: + kubernetes.io/ingress.class: traefik-external +spec: + entryPoints: + - web + - websecure + routes: + - match: Host(`www.openfang.test.${DOMAIN}.com`) + kind: Rule + services: + - name: openfang + port: 80 + middlewares: + - name: oauth2-proxy-middleware + - match: Host(`openfang.test.${DOMAIN}.com`) + kind: Rule + services: + - name: openfang + port: 80 + middlewares: + - name: oauth2-proxy-middleware +--- +apiVersion: v1 +kind: PersistentVolumeClaim +metadata: + name: openfang-data + namespace: openfang +spec: + accessModes: + - ReadWriteOnce + resources: + requests: + storage: 500Mi From 983ddf94c9a3b31d11fb321ff8e67f14a19242bb Mon Sep 17 00:00:00 2001 From: dewanshshekhar Date: Wed, 24 Jun 2026 21:23:09 +0530 Subject: [PATCH 19/31] deploy: harden container (non-root, read-only rootfs, slim runtime) - Dockerfile: switch runtime image from rust:1-slim to debian:bookworm-slim (no compiler/toolchain in prod), add libssl3, run as non-root uid 1000 with HOME=/data so pip/npm caches land on the volume. - rancher manifest: pod/container securityContext (runAsNonRoot, fsGroup 1000, drop ALL caps, allowPrivilegeEscalation=false, readOnlyRootFilesystem with a /tmp emptyDir), bump memory limit to 1Gi and PVC to 2Gi. All daemon writes go to the /data PVC (OPENFANG_HOME) and /tmp; entrypoint seeds agents/config with cp -n (never clobbers volume data). Co-Authored-By: Claude Opus 4.8 --- Dockerfile | 18 ++++++++++++++++-- deploy/rancher/openfang.ci.yaml | 25 ++++++++++++++++++++++--- 2 files changed, 38 insertions(+), 5 deletions(-) diff --git a/Dockerfile b/Dockerfile index 3e5970eac3..5933ac5fd4 100644 --- a/Dockerfile +++ b/Dockerfile @@ -15,9 +15,13 @@ ENV CARGO_PROFILE_RELEASE_LTO=${LTO} \ CARGO_PROFILE_RELEASE_CODEGEN_UNITS=${CODEGEN_UNITS} RUN cargo build --release --bin openfang -FROM rust:1-slim-bookworm +# Runtime: plain Debian slim (NOT the rust image) so the production image ships +# no compiler/toolchain. libssl3 is the runtime half of the builder's libssl-dev +# (the binary links OpenSSL dynamically); python/node are needed by skills. +FROM debian:bookworm-slim RUN apt-get update && apt-get install -y --no-install-recommends \ ca-certificates \ + libssl3 \ python3 \ python3-pip \ python3-venv \ @@ -25,6 +29,14 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ npm \ && rm -rf /var/lib/apt/lists/* +# Non-root runtime user (uid/gid 1000). The app writes everything under +# OPENFANG_HOME (/data) — provided by the PVC and made group-writable via the +# pod's fsGroup: 1000 — so no rootfs writes are needed. HOME=/data keeps pip/npm +# caches on the volume too. +RUN useradd --uid 1000 --user-group --home-dir /data --shell /usr/sbin/nologin openfang \ + && mkdir -p /data \ + && chown -R 1000:1000 /data + COPY --from=builder /build/target/release/openfang /usr/local/bin/ # Baked agents seeded to the data volume on first boot (Dewansh + assistant). # Curated deploy set — NOT the repo's example agent gallery under ./agents. @@ -37,6 +49,8 @@ COPY deploy/entrypoint.sh /usr/local/bin/openfang-entrypoint.sh RUN chmod +x /usr/local/bin/openfang-entrypoint.sh EXPOSE 4200 VOLUME /data -ENV OPENFANG_HOME=/data +ENV OPENFANG_HOME=/data \ + HOME=/data +USER 1000 ENTRYPOINT ["/usr/local/bin/openfang-entrypoint.sh"] CMD ["start"] diff --git a/deploy/rancher/openfang.ci.yaml b/deploy/rancher/openfang.ci.yaml index 9148b0fb06..7540267f90 100644 --- a/deploy/rancher/openfang.ci.yaml +++ b/deploy/rancher/openfang.ci.yaml @@ -60,10 +60,24 @@ spec: labels: app: openfang spec: + # Run unprivileged; fsGroup makes the PVC group-writable so the entrypoint + # can seed agents/config into /data as uid 1000 (matches the image user). + securityContext: + runAsNonRoot: true + runAsUser: 1000 + fsGroup: 1000 + seccompProfile: + type: RuntimeDefault containers: - name: openfang image: employee-harbor.748th.com/docker-registry/openfang:${IMAGE_TAG} args: ["start"] + securityContext: + allowPrivilegeEscalation: false + readOnlyRootFilesystem: true + capabilities: + drop: + - ALL ports: - containerPort: 4200 env: @@ -98,13 +112,16 @@ spec: resources: limits: cpu: "500m" - memory: "512Mi" + memory: "1Gi" requests: cpu: "10m" - memory: "32Mi" + memory: "64Mi" volumeMounts: - name: data mountPath: /data + # readOnlyRootFilesystem is on, so give the app a writable /tmp. + - name: tmp + mountPath: /tmp readinessProbe: httpGet: path: /api/health @@ -121,6 +138,8 @@ spec: - name: data persistentVolumeClaim: claimName: openfang-data + - name: tmp + emptyDir: {} imagePullSecrets: - name: regcred${ENVIRONMENT}${DOMAIN} --- @@ -187,4 +206,4 @@ spec: - ReadWriteOnce resources: requests: - storage: 500Mi + storage: 2Gi From 286c8cd8d10a246304b984360aa6d8204fe7a92f Mon Sep 17 00:00:00 2001 From: dewanshshekhar Date: Wed, 24 Jun 2026 21:23:31 +0530 Subject: [PATCH 20/31] =?UTF-8?q?feat(channels):=20exclusive=5Fagent=20?= =?UTF-8?q?=E2=80=94=20lock=20a=20Telegram=20bot=20to=20one=20agent?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds an opt-in `exclusive_agent` flag to TelegramConfig. When set together with `default_agent`, the bot becomes single-purpose: every message routes to that one agent regardless of bindings/user selection, and the multi-agent discovery/switching commands (/agents, /agent ) plus the agent-list welcome are disabled — so a dedicated bot never exposes or lets users switch to other agents. - router: per-channel exclusive map; resolve() returns the locked agent first, overriding bindings, direct routes, and user/channel/system defaults. - bridge: suppress /start, /help, /agents, /agent for exclusive bots (other session commands still work and resolve to the one agent). - channel_bridge: wire the flag at adapter setup (warns if default_agent unset). - docs + a router unit test covering the override precedence. Inert unless exclusive_agent=true: exclusive_agent() returns None otherwise and routing/commands are unchanged for every existing bot and agent. Also underscore-prefixes two unused params in a default trait method to keep clippy -D warnings clean. Co-Authored-By: Claude Opus 4.8 --- crates/openfang-api/src/channel_bridge.rs | 20 +++++- crates/openfang-channels/src/bridge.rs | 30 ++++++++- crates/openfang-channels/src/router.rs | 74 +++++++++++++++++++++++ crates/openfang-types/src/config.rs | 9 +++ docs/channel-adapters.md | 1 + 5 files changed, 131 insertions(+), 3 deletions(-) diff --git a/crates/openfang-api/src/channel_bridge.rs b/crates/openfang-api/src/channel_bridge.rs index 0a7b125b0f..0125c94ba7 100644 --- a/crates/openfang-api/src/channel_bridge.rs +++ b/crates/openfang-api/src/channel_bridge.rs @@ -1187,6 +1187,9 @@ pub async fn start_channel_bridge_with_config( // Collect all adapters to start let mut adapters: Vec<(Arc, Option)> = Vec::new(); + // Channel keys (Debug form, e.g. "Telegram") whose bot is locked to its + // single `default_agent`. Applied to the router after agent IDs resolve. + let mut exclusive_channels: std::collections::HashSet = std::collections::HashSet::new(); // Telegram if let Some(ref tg_config) = config.telegram { @@ -1198,6 +1201,13 @@ pub async fn start_channel_bridge_with_config( poll_interval, tg_config.api_url.clone(), )); + if tg_config.exclusive_agent { + if tg_config.default_agent.is_some() { + exclusive_channels.insert(format!("{:?}", adapter.channel_type())); + } else { + warn!("Telegram exclusive_agent is set but default_agent is empty — ignoring exclusive lock"); + } + } adapters.push((adapter, tg_config.default_agent.clone())); } } @@ -1796,7 +1806,15 @@ pub async fn start_channel_bridge_with_config( "{} default agent: {name} ({agent_id}) [channel: {channel_key}]", adapter.name() ); - router.set_channel_default_with_name(channel_key, agent_id, name.clone()); + router.set_channel_default_with_name(channel_key.clone(), agent_id, name.clone()); + // Lock this bot to the single agent if exclusive mode is on. + if exclusive_channels.contains(&channel_key) { + router.set_exclusive_agent(&adapter.channel_type(), agent_id); + info!( + "{} locked to exclusive agent: {name} ({agent_id}) — agent switching disabled", + adapter.name() + ); + } // First configured default also becomes system-wide fallback if !system_default_set { router.set_default(agent_id); diff --git a/crates/openfang-channels/src/bridge.rs b/crates/openfang-channels/src/bridge.rs index 9e27bce26f..6f369ce45f 100644 --- a/crates/openfang-channels/src/bridge.rs +++ b/crates/openfang-channels/src/bridge.rs @@ -107,8 +107,8 @@ pub trait ChannelBridgeHandle: Send + Sync { &self, agent_id: AgentId, message: &str, - sender_id: Option, - sender_name: Option, + _sender_id: Option, + _sender_name: Option, ) -> Result { // Default: fall back to send_message (backwards compatible) self.send_message(agent_id, message).await @@ -1728,6 +1728,32 @@ async fn handle_command( .filter(|def| def.surfaces.contains(Surfaces::CHANNEL)) .map(|def| def.name) .unwrap_or(name); + + // Exclusive single-purpose bots are locked to one agent: never expose the + // agent roster or allow switching. Other session commands (/new, /compact, + // /model, …) still apply and resolve to that one agent via the router. + if let Some(ex_id) = router.exclusive_agent_str(channel_name) { + match canonical { + "start" | "help" => { + let agent = resolve_agent_name(handle, ex_id) + .await + .unwrap_or_else(|| "your assistant".to_string()); + return format!( + "Hi! You're talking to {agent}. Just send your request and I'll respond — no agent selection needed." + ); + } + "agents" | "agent" => { + let agent = resolve_agent_name(handle, ex_id) + .await + .unwrap_or_else(|| "a single dedicated agent".to_string()); + return format!( + "This bot is dedicated to {agent}. Agent switching is disabled here." + ); + } + _ => {} + } + } + match canonical { "start" => { let agents = handle.list_agents().await.unwrap_or_default(); diff --git a/crates/openfang-channels/src/router.rs b/crates/openfang-channels/src/router.rs index b1de44e32c..d4342e8509 100644 --- a/crates/openfang-channels/src/router.rs +++ b/crates/openfang-channels/src/router.rs @@ -40,6 +40,11 @@ pub struct AgentRouter { channel_defaults: DashMap, /// Per-channel-type default agent *name* (for re-resolution when UUID becomes stale). channel_default_names: DashMap, + /// Per-channel-type *exclusive* agent (keyed by lowercase channel string, + /// e.g. "telegram"). When set, the bot is locked to exactly one agent: + /// resolution returns it unconditionally (overriding bindings, direct routes, + /// and user/channel defaults) and agent-switching commands are disabled. + exclusive_agents: DashMap, /// Sorted bindings (most specific first). Uses Mutex for runtime updates via Arc. bindings: Mutex>, /// Broadcast configuration. Uses Mutex for runtime updates via Arc. @@ -57,6 +62,7 @@ impl AgentRouter { default_agent: None, channel_defaults: DashMap::new(), channel_default_names: DashMap::new(), + exclusive_agents: DashMap::new(), bindings: Mutex::new(Vec::new()), broadcast: Mutex::new(BroadcastConfig::default()), agent_name_cache: DashMap::new(), @@ -103,6 +109,28 @@ impl AgentRouter { self.user_defaults.insert(user_key, agent_id); } + /// Lock a channel to a single agent (exclusive single-purpose bot). Keyed by + /// the lowercase channel string (e.g. "telegram"). Once set, [`resolve`] + /// returns this agent for the channel no matter what, and the bridge + /// disables agent-discovery/switching commands. + pub fn set_exclusive_agent(&self, channel_type: &ChannelType, agent_id: AgentId) { + self.exclusive_agents + .insert(channel_type_to_str(channel_type).to_string(), agent_id); + } + + /// Exclusive agent locked to this channel type, if any. + pub fn exclusive_agent(&self, channel_type: &ChannelType) -> Option { + self.exclusive_agents + .get(channel_type_to_str(channel_type)) + .map(|r| *r) + } + + /// Exclusive agent locked to a channel, looked up by its lowercase string + /// key (e.g. "telegram"). Used where only the channel string is available. + pub fn exclusive_agent_str(&self, channel_str: &str) -> Option { + self.exclusive_agents.get(channel_str).map(|r| *r) + } + /// Set a direct route for a specific (channel, user) pair. pub fn set_direct_route( &self, @@ -163,6 +191,12 @@ impl AgentRouter { ) -> Option { let channel_key = format!("{channel_type:?}"); + // -1. Exclusive lock: a single-purpose bot is pinned to one agent and + // ignores all other routing (bindings, direct routes, user defaults). + if let Some(agent_id) = self.exclusive_agent(channel_type) { + return Some(agent_id); + } + // 0. Check bindings (most specific first) let ctx = BindingContext { channel: channel_type_to_str(channel_type).to_string(), @@ -212,6 +246,10 @@ impl AgentRouter { user_key: Option<&str>, ctx: &BindingContext, ) -> Option { + // Exclusive lock takes precedence over everything, including bindings. + if let Some(agent_id) = self.exclusive_agent(channel_type) { + return Some(agent_id); + } // 0. Check bindings first if let Some(agent_id) = self.resolve_binding(ctx) { return Some(agent_id); @@ -423,6 +461,42 @@ mod tests { assert_eq!(resolved, None); } + #[test] + fn test_exclusive_agent_overrides_everything() { + let mut router = AgentRouter::new(); + let default_agent = AgentId::new(); + let user_agent = AgentId::new(); + let exclusive = AgentId::new(); + + router.set_default(default_agent); + router.set_user_default("alice".to_string(), user_agent); + // A telegram binding would normally win — exclusive must still override it. + router.register_agent("bound".to_string(), AgentId::new()); + router.load_bindings(&[AgentBinding { + agent: "bound".to_string(), + match_rule: openfang_types::config::BindingMatchRule { + channel: Some("telegram".to_string()), + ..Default::default() + }, + }]); + router.set_exclusive_agent(&ChannelType::Telegram, exclusive); + + // Telegram is locked: binding, user default, and system default are ignored. + assert_eq!( + router.resolve(&ChannelType::Telegram, "tg_1", Some("alice")), + Some(exclusive) + ); + // Other channels are unaffected by the telegram lock. + assert_eq!( + router.resolve(&ChannelType::Discord, "dc_1", Some("alice")), + Some(user_agent) + ); + // Lookup helpers agree. + assert_eq!(router.exclusive_agent(&ChannelType::Telegram), Some(exclusive)); + assert_eq!(router.exclusive_agent_str("telegram"), Some(exclusive)); + assert_eq!(router.exclusive_agent(&ChannelType::Discord), None); + } + #[test] fn test_binding_channel_match() { let router = AgentRouter::new(); diff --git a/crates/openfang-types/src/config.rs b/crates/openfang-types/src/config.rs index c6048911f1..a61502ebc3 100644 --- a/crates/openfang-types/src/config.rs +++ b/crates/openfang-types/src/config.rs @@ -1839,6 +1839,14 @@ pub struct TelegramConfig { /// Allows channel_send(channel="telegram", message="...") without a recipient. #[serde(default)] pub default_chat_id: Option, + /// Lock this bot to `default_agent` only. When true, every message routes to + /// that single agent regardless of bindings/user selection, and the + /// multi-agent discovery/switching commands (`/agents`, `/agent `) and + /// the agent-list welcome are disabled — so a dedicated single-purpose bot + /// never exposes or lets users switch to other agents. Requires + /// `default_agent` to be set. + #[serde(default)] + pub exclusive_agent: bool, /// Per-channel behavior overrides. #[serde(default)] pub overrides: ChannelOverrides, @@ -1853,6 +1861,7 @@ impl Default for TelegramConfig { poll_interval_secs: 1, api_url: None, default_chat_id: None, + exclusive_agent: false, overrides: ChannelOverrides::default(), } } diff --git a/docs/channel-adapters.md b/docs/channel-adapters.md index cd44652ea2..e7fbdbbdca 100644 --- a/docs/channel-adapters.md +++ b/docs/channel-adapters.md @@ -149,6 +149,7 @@ default_agent = "social-media" - `bot_token_env` / `token_env` -- The environment variable holding the bot/access token. OpenFang reads the token from this env var at startup. All secrets are stored as `Zeroizing` and wiped from memory on drop. - `default_agent` -- The agent name (or ID) that receives messages when no specific routing applies. +- `exclusive_agent` -- _(Telegram)_ When `true`, locks the bot to `default_agent` only: every message routes to that single agent regardless of bindings or user selection, and the multi-agent discovery/switching commands (`/agents`, `/agent `) plus the agent-list welcome are disabled. Use this for a dedicated single-purpose bot that should never expose or switch to other agents. Requires `default_agent` to be set. - `allowed_users` -- Optional list of platform user IDs allowed to interact. Empty means allow all. - `overrides` -- Optional per-channel behavior overrides (see [Channel Overrides](#channel-overrides) below). From 296f985788865c1cec604c3f18031985baf71c73 Mon Sep 17 00:00:00 2001 From: dewanshshekhar Date: Wed, 24 Jun 2026 21:37:13 +0530 Subject: [PATCH 21/31] deploy: ship EbayWatcher as the dedicated Telegram bot MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - bake deploy/agents/EbayWatcher (research profile + channel_send; eBay price-watch + dedup-by-listing-id prompt, also does general web research). - config.default.toml: route Telegram -> EbayWatcher with exclusive_agent=true using TELEGRAM_BOT_TOKEN_EBAY (@openfang_ebay_1_bot). Dewansh and assistant stay baked and reachable via the dashboard/API, just not on Telegram. - rancher manifest: swap the injected secret TELEGRAM_BOT_TOKEN -> TELEGRAM_BOT_TOKEN_EBAY and update the required-env notes. Stacked on / depends on the exclusive_agent feature in PR #2 (feat/telegram-exclusive-agent) — that flag is what locks the bot to one agent. Co-Authored-By: Claude Opus 4.8 --- deploy/agents/EbayWatcher/agent.toml | 73 ++++++++++++++++++++++++++++ deploy/config.default.toml | 13 +++-- deploy/rancher/openfang.ci.yaml | 10 ++-- 3 files changed, 87 insertions(+), 9 deletions(-) create mode 100644 deploy/agents/EbayWatcher/agent.toml diff --git a/deploy/agents/EbayWatcher/agent.toml b/deploy/agents/EbayWatcher/agent.toml new file mode 100644 index 0000000000..20b0309514 --- /dev/null +++ b/deploy/agents/EbayWatcher/agent.toml @@ -0,0 +1,73 @@ +name = "EbayWatcher" +description = "Monitors eBay for watched items at/under a target price and pings on new matches. Also does general web research." +module = "builtin:chat" +schedule = "reactive" +priority = "Normal" +profile = "research" +skills = [] + +[model] +provider = "nvidia" +model = "stepfun-ai/step-3.7-flash" +max_tokens = 4096 +temperature = 0.3 +api_key_env = "NVIDIA_API_KEY" +# system_prompt is a [model] field — defining it at the manifest root makes +# serde silently drop it and the agent falls back to the default prompt. +system_prompt = """ +You are EbayWatcher, an autonomous eBay price-monitoring agent. You ALSO do +general web research when the user asks something unrelated to eBay (just like +a normal research assistant). + +You persist state across runs using your file tools, in your workspace: +- watchlist.json — what to monitor. Shape: + {"chat": {"channel": "telegram", "recipient": ""}, + "items": [{"id": "", "query": "", "max_price": 500.0, "currency": "USD"}]} +- notified.json — a JSON object whose KEYS are eBay listing IDs already alerted + on (value true). Used so we never ping twice for the same listing. + +== MODE 1: SET / UPDATE WATCHLIST == +When the user gives items + target prices (e.g. "watch iPhone 15 under $500, +Sony WH-1000XM5 under $200"): +1. Parse each line into {id, query, max_price, currency}. id = a short slug. +2. file_read watchlist.json (missing = start fresh); merge or replace items. +3. If this message arrived over a channel, store that channel + recipient in + watchlist.json under "chat" so scans know where to ping. +4. file_write watchlist.json with the full updated JSON. +5. Reply confirming exactly what you now watch and each target price. + +== MODE 2: SCAN — triggered by the exact message "RUN_EBAY_SCAN" == +1. file_read watchlist.json. If empty/missing, reply "No watchlist set." and stop. +2. file_read notified.json (missing = {}). +3. For EACH item: + a. web_fetch the eBay search results page, cheapest first: + https://www.ebay.com/sch/i.html?_nkw=&_sop=15 + If that is blocked/empty, web_search " ebay" and web_fetch the top + ebay.com result. + b. Extract the cheapest listings: title, numeric price, the eBay listing ID + (the digits in the /itm/ URL), and the URL. + c. A listing MATCHES when price <= that item's max_price. + d. For each MATCHING listing whose ID is NOT already a key in notified.json, + record it as a NEW match and add its ID to notified.json (value true). +4. file_write notified.json including all newly added IDs. +5. For each NEW match, if watchlist.json has a "chat", call channel_send with a + short alert (one send per match, or batch into one message): + "eBay match: — <price> <currency> (target <max_price>)\n<url>" +6. End with one line: how many NEW matches were found. If ZERO new matches your + final reply must be exactly "No new matches." and you MUST NOT call + channel_send (never flood). + +== MODE 3: GENERAL RESEARCH == +Anything else: web_search, web_fetch the best 1-3 results, answer concisely and +cite the source URLs. + +RULES: +- Never invent prices, listing IDs, or URLs. Only report what web_fetch / + web_search actually returned. If a fetch fails, say so. +- Always file_write your state before finishing a scan so dedup survives restarts. +- Keep replies concise, plain-text (they are delivered over Telegram). +""" + +[capabilities] +tools = ["web_search", "web_fetch", "file_read", "file_write", "file_list", "channel_send"] +network = ["*"] diff --git a/deploy/config.default.toml b/deploy/config.default.toml index da0111a8d9..edc59bd026 100644 --- a/deploy/config.default.toml +++ b/deploy/config.default.toml @@ -7,8 +7,8 @@ # # Secrets are referenced by environment-variable NAME, never stored here. # Required env vars in the deployment: -# NVIDIA_API_KEY — powers the default model -# TELEGRAM_BOT_TOKEN — Telegram bot token from @BotFather +# NVIDIA_API_KEY — powers the default model + agents +# TELEGRAM_BOT_TOKEN_EBAY — Telegram token (@openfang_ebay_1_bot) for the EbayWatcher bot # Bind on all interfaces so the dashboard/API is reachable inside the cluster. # Can be overridden at runtime with OPENFANG_LISTEN. @@ -22,7 +22,12 @@ api_key_env = "NVIDIA_API_KEY" [memory] decay_rate = 0.05 +# The Telegram bot is dedicated to EbayWatcher: exclusive_agent locks it to that +# one agent, so the bot never exposes or lets users switch to other agents +# (Dewansh/assistant remain available via the dashboard/API). To route Telegram +# to a different agent instead, change default_agent and drop exclusive_agent. [channels.telegram] -bot_token_env = "TELEGRAM_BOT_TOKEN" -default_agent = "Dewansh" +bot_token_env = "TELEGRAM_BOT_TOKEN_EBAY" +default_agent = "EbayWatcher" +exclusive_agent = true poll_interval_secs = 1 diff --git a/deploy/rancher/openfang.ci.yaml b/deploy/rancher/openfang.ci.yaml index 7540267f90..976d7d36f6 100644 --- a/deploy/rancher/openfang.ci.yaml +++ b/deploy/rancher/openfang.ci.yaml @@ -4,9 +4,9 @@ # ENVIRONMENT e.g. "test" # DOMAIN e.g. "748th" # IMAGE_TAG e.g. "v0.0.2" -# DOCKER_CONFIG_JSON raw docker config JSON for the pull secret -# NVIDIA_API_KEY powers the Dewansh agent -# TELEGRAM_BOT_TOKEN bot token from @BotFather +# DOCKER_CONFIG_JSON raw docker config JSON for the pull secret +# NVIDIA_API_KEY powers the agents +# TELEGRAM_BOT_TOKEN_EBAY bot token (@openfang_ebay_1_bot) for the EbayWatcher bot # # NOTE: the image registry host (employee-harbor.748th.com) is hardcoded on # purpose — it matches the verified push target. Do NOT rebuild it from @@ -31,7 +31,7 @@ type: kubernetes.io/dockerconfigjson stringData: .dockerconfigjson: ${DOCKER_CONFIG_JSON} --- -# Secrets for the Dewansh agent + Telegram bot. Injected by CI (envsubst). +# Secrets for the agents + Telegram bot. Injected by CI (envsubst). apiVersion: v1 kind: Secret metadata: @@ -40,7 +40,7 @@ metadata: type: Opaque stringData: NVIDIA_API_KEY: ${NVIDIA_API_KEY} - TELEGRAM_BOT_TOKEN: ${TELEGRAM_BOT_TOKEN} + TELEGRAM_BOT_TOKEN_EBAY: ${TELEGRAM_BOT_TOKEN_EBAY} --- apiVersion: apps/v1 kind: Deployment From 9454003a06a4d526cfe292af155cd5f163677705 Mon Sep 17 00:00:00 2001 From: Nideesh1 <nterapalli1@gmail.com> Date: Tue, 30 Jun 2026 12:48:39 -0400 Subject: [PATCH 22/31] =?UTF-8?q?feat(tools):=20secure=5Ffetch=20=E2=80=94?= =?UTF-8?q?=20env-resolved,=20allowlisted=20secret=20headers?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --- crates/openfang-runtime/src/tool_runner.rs | 244 ++++++++++++++++++++- crates/openfang-runtime/src/web_fetch.rs | 27 +++ crates/openfang-types/src/config.rs | 25 +++ crates/openfang-types/src/tool_compat.rs | 1 + 4 files changed, 288 insertions(+), 9 deletions(-) diff --git a/crates/openfang-runtime/src/tool_runner.rs b/crates/openfang-runtime/src/tool_runner.rs index 012928ca5c..f11b4858cc 100644 --- a/crates/openfang-runtime/src/tool_runner.rs +++ b/crates/openfang-runtime/src/tool_runner.rs @@ -230,6 +230,23 @@ pub async fn execute_tool( tool_web_fetch_legacy(input).await } } + // Secure fetch — secret header values are resolved from allowlisted + // env vars in Rust, so the LLM never sees the raw secret. + "secure_fetch" => { + let url = input["url"].as_str().unwrap_or(""); + // Taint check: block URLs containing secrets/PII from being exfiltrated. + if let Some(violation) = check_taint_net_fetch(url) { + return ToolResult { + tool_use_id: tool_use_id.to_string(), + content: format!("Taint violation: {violation}"), + is_error: true, + }; + } + match web_ctx { + Some(ctx) => tool_secure_fetch(input, &ctx.fetch).await, + None => Err("secure_fetch unavailable: web tools are not configured".to_string()), + } + } "web_search" => { if let Some(ctx) = web_ctx { let query = input["query"].as_str().unwrap_or(""); @@ -627,6 +644,21 @@ pub fn builtin_tool_definitions() -> Vec<ToolDefinition> { "required": ["url"] }), }, + ToolDefinition { + name: "secure_fetch".to_string(), + description: "HTTP request where secret header values are read from allowlisted env vars by name; never pass raw secrets.".to_string(), + input_schema: serde_json::json!({ + "type": "object", + "properties": { + "url": { "type": "string", "description": "The URL to fetch (http/https only)" }, + "method": { "type": "string", "enum": ["GET","POST","PUT","PATCH","DELETE"], "description": "HTTP method (default: GET)" }, + "headers": { "type": "object", "description": "Plain, non-secret HTTP headers as key-value pairs" }, + "body": { "type": "string", "description": "Request body for POST/PUT/PATCH" }, + "secret_headers": { "type": "object", "description": "Map of header name -> ENV-VAR NAME. The value is the name of an allowlisted environment variable; its secret value is resolved server-side and never seen by the model. Pass the env-var name, NOT the secret." } + }, + "required": ["url"] + }), + }, ToolDefinition { name: "web_search".to_string(), description: "Search the web using multiple providers (Tavily, Brave, Perplexity, DuckDuckGo) with automatic fallback. Returns structured results with titles, URLs, and snippets.".to_string(), @@ -1448,6 +1480,97 @@ async fn tool_web_fetch_legacy(input: &serde_json::Value) -> Result<String, Stri Ok(format!("HTTP {status}\n\n{truncated}")) } +/// Resolve `secret_headers` (a `{header_name: ENV_VAR_NAME}` map) into concrete +/// `(header_name, secret_value)` pairs, enforcing the allowlist (Guard A) and +/// per-secret host-binding (Guard B) guards. +/// +/// SECURITY: every error message references only the env-var NAME — never the +/// resolved secret value — and the function never logs. +fn resolve_secret_headers( + secret_headers: &serde_json::Map<String, serde_json::Value>, + request_host: &str, + config: &openfang_types::config::SecureFetchConfig, +) -> Result<Vec<(String, String)>, String> { + let mut resolved = Vec::with_capacity(secret_headers.len()); + for (header_name, env_value) in secret_headers { + // The value must be the NAME of an env var, not the secret itself. + let env_name = env_value.as_str().ok_or_else(|| { + format!("secret header '{header_name}' must map to an env-var NAME (a string)") + })?; + + // Guard A (allowlist): fail-closed — only env vars in allowed_secrets + // may ever be read. Do not touch the environment otherwise. + if !config.allowed_secrets.iter().any(|s| s == env_name) { + return Err(format!( + "secret '{env_name}' not allowlisted for secure_fetch" + )); + } + + // Guard B (host binding): if this secret is bound to specific hosts, + // refuse to attach it to a request aimed anywhere else. + if let Some(hosts) = config.secret_hosts.get(env_name) { + if !hosts.iter().any(|h| h.to_lowercase() == request_host) { + return Err(format!( + "secret '{env_name}' not permitted for host {request_host}" + )); + } + } + + // Resolve the value from the environment. The value is never logged. + let secret = std::env::var(env_name) + .map_err(|_| format!("secret '{env_name}' is not set in the environment"))?; + resolved.push((header_name.clone(), secret)); + } + Ok(resolved) +} + +/// `secure_fetch` — like `web_fetch`, but secret header values are referenced by +/// ENV-VAR NAME and resolved in Rust so the LLM never sees the secret. +/// +/// Guard A (allowlist) and Guard B (host binding) are applied in +/// [`resolve_secret_headers`]; Guard C (SSRF) runs here before any network I/O +/// (and again inside the shared fetch engine). With no `secret_headers`, this +/// behaves like a plain `web_fetch`. +async fn tool_secure_fetch( + input: &serde_json::Value, + engine: &crate::web_fetch::WebFetchEngine, +) -> Result<String, String> { + let url = input["url"].as_str().ok_or("Missing 'url' parameter")?; + let method = input["method"].as_str().unwrap_or("GET"); + let body = input["body"].as_str(); + + // Guard C (SSRF): block private/metadata hosts before any I/O or env reads. + crate::web_fetch::check_ssrf(url, engine.ssrf_allowed_hosts())?; + let request_host = crate::web_fetch::url_hostname(url); + + // Start from the plain, non-secret headers supplied by the model. + let mut headers: serde_json::Map<String, serde_json::Value> = input + .get("headers") + .and_then(|v| v.as_object()) + .cloned() + .unwrap_or_default(); + + // Resolve secret headers from allowlisted env vars (Guards A + B). + if let Some(secret_headers) = input.get("secret_headers").and_then(|v| v.as_object()) { + if !secret_headers.is_empty() { + let resolved = resolve_secret_headers( + secret_headers, + &request_host, + engine.secure_fetch_config(), + )?; + for (name, value) in resolved { + headers.insert(name, serde_json::Value::String(value)); + } + } + } + + // Reuse the web_fetch engine: identical method/header/body handling, + // content-type auto-detection, raw-body return, and SSRF re-check. + engine + .fetch_with_options(url, method, Some(&headers), body) + .await +} + /// Legacy web search via DuckDuckGo HTML only. Used when WebToolsContext is unavailable. async fn tool_web_search_legacy(input: &serde_json::Value) -> Result<String, String> { let query = input["query"].as_str().ok_or("Missing 'query' parameter")?; @@ -2284,7 +2407,8 @@ async fn tool_cron_create( // Normalize system_event to agent_turn so cron delivery works via Telegram. // system_event only publishes to the internal event bus, not to channels. if job["action"]["kind"].as_str() == Some("system_event") { - let text = job["action"]["text"].as_str() + let text = job["action"]["text"] + .as_str() .or_else(|| job["action"]["message"].as_str()) .or_else(|| job["action"]["description"].as_str()) .unwrap_or("Reminder") @@ -2308,7 +2432,10 @@ async fn tool_cron_create( let delivery = &job["delivery"]; let needs_replacement = delivery.is_null() || !delivery.is_object() - || matches!(delivery["kind"].as_str(), Some("last_channel") | Some("none") | None); + || matches!( + delivery["kind"].as_str(), + Some("last_channel") | Some("none") | None + ); if needs_replacement { job["delivery"] = serde_json::json!({ "kind": "channel", @@ -2374,8 +2501,8 @@ async fn tool_channel_send( Some(id) => id, None => { // Fallback: check agent's delivery.last_channel context - let last_channel = caller_agent_id - .and_then(|aid| kh.get_delivery_context(aid, &channel)); + let last_channel = + caller_agent_id.and_then(|aid| kh.get_delivery_context(aid, &channel)); match last_channel { Some(id) => id, None => { @@ -2891,14 +3018,12 @@ async fn tool_ollama_structured(input: &serde_json::Value) -> Result<String, Str let prompt = input["prompt"] .as_str() .ok_or("Missing 'prompt' parameter")?; - let schema = input - .get("schema") - .ok_or("Missing 'schema' parameter")?; + let schema = input.get("schema").ok_or("Missing 'schema' parameter")?; let system = input["system"].as_str().unwrap_or(""); let model = input["model"].as_str().unwrap_or("gpt-oss:20b"); - let ollama_url = std::env::var("OLLAMA_HOST") - .unwrap_or_else(|_| "http://127.0.0.1:11434".to_string()); + let ollama_url = + std::env::var("OLLAMA_HOST").unwrap_or_else(|_| "http://127.0.0.1:11434".to_string()); let url = format!("{}/api/chat", ollama_url.trim_end_matches('/')); let mut messages = Vec::new(); @@ -3600,6 +3725,107 @@ mod tests { assert!(names.contains(&"docker_exec")); // Canvas tool assert!(names.contains(&"canvas_present")); + // secure_fetch (env-resolved, allowlisted secret headers) + assert!(names.contains(&"secure_fetch")); + } + + // ── secure_fetch tests ─────────────────────────────────────────────── + + /// Build a SecureFetchConfig for tests. + fn secure_cfg( + allowed: &[&str], + hosts: &[(&str, &[&str])], + ) -> openfang_types::config::SecureFetchConfig { + let mut secret_hosts = std::collections::HashMap::new(); + for (env, list) in hosts { + secret_hosts.insert( + env.to_string(), + list.iter().map(|h| h.to_string()).collect(), + ); + } + openfang_types::config::SecureFetchConfig { + allowed_secrets: allowed.iter().map(|s| s.to_string()).collect(), + secret_hosts, + } + } + + fn secret_map(pairs: &[(&str, &str)]) -> serde_json::Map<String, serde_json::Value> { + pairs + .iter() + .map(|(h, e)| (h.to_string(), serde_json::Value::String(e.to_string()))) + .collect() + } + + #[test] + fn test_secure_fetch_in_catalog() { + let tools = builtin_tool_definitions(); + let tool = tools + .iter() + .find(|t| t.name == "secure_fetch") + .expect("secure_fetch must be advertised in the catalog"); + let props = &tool.input_schema["properties"]; + assert!(props.get("url").is_some()); + assert!(props.get("secret_headers").is_some()); + assert_eq!(tool.input_schema["required"][0], "url"); + } + + #[test] + fn test_secure_fetch_rejects_non_allowlisted_secret() { + // allowed_secrets is empty for "EVIL_SECRET" → fail-closed. + let cfg = secure_cfg(&["GITHUB_TOKEN"], &[]); + let headers = secret_map(&[("Authorization", "OPENAI_API_KEY")]); + let err = resolve_secret_headers(&headers, "api.example.com", &cfg) + .expect_err("non-allowlisted secret must be rejected"); + assert_eq!( + err, + "secret 'OPENAI_API_KEY' not allowlisted for secure_fetch" + ); + } + + #[test] + fn test_secure_fetch_fail_closed_empty_allowlist() { + // Default (empty) config rejects ANY secret_headers use. + let cfg = openfang_types::config::SecureFetchConfig::default(); + let headers = secret_map(&[("X-Api-Key", "ANY_SECRET")]); + assert!(resolve_secret_headers(&headers, "api.example.com", &cfg).is_err()); + } + + #[test] + fn test_secure_fetch_host_binding_rejects_wrong_host() { + // STRIPE_KEY is bound to api.stripe.com; sending to evil.com must fail. + let cfg = secure_cfg(&["STRIPE_KEY"], &[("STRIPE_KEY", &["api.stripe.com"])]); + let headers = secret_map(&[("Authorization", "STRIPE_KEY")]); + let err = resolve_secret_headers(&headers, "evil.com", &cfg) + .expect_err("host-bound secret must be rejected for a wrong host"); + assert_eq!(err, "secret 'STRIPE_KEY' not permitted for host evil.com"); + } + + #[test] + fn test_secure_fetch_happy_path_resolves_secret() { + // Allowlist + host binding satisfied + env var set → secret resolves + // and is injected as the header value (LLM never supplied it). + let env_name = "OPENFANG_TEST_SECURE_FETCH_KEY"; + std::env::set_var(env_name, "s3cr3t-value"); + let cfg = secure_cfg(&[env_name], &[(env_name, &["api.example.com"])]); + let headers = secret_map(&[("Authorization", env_name)]); + let resolved = resolve_secret_headers(&headers, "api.example.com", &cfg) + .expect("happy path must resolve"); + std::env::remove_var(env_name); + assert_eq!(resolved.len(), 1); + assert_eq!(resolved[0].0, "Authorization"); + assert_eq!(resolved[0].1, "s3cr3t-value"); + } + + #[test] + fn test_secure_fetch_missing_env_var_errors_by_name() { + // Allowlisted but unset → error references the NAME only, no secret. + let env_name = "OPENFANG_TEST_SECURE_FETCH_UNSET"; + std::env::remove_var(env_name); + let cfg = secure_cfg(&[env_name], &[]); + let headers = secret_map(&[("X-Token", env_name)]); + let err = resolve_secret_headers(&headers, "api.example.com", &cfg) + .expect_err("unset env var must error"); + assert!(err.contains(env_name)); } #[test] diff --git a/crates/openfang-runtime/src/web_fetch.rs b/crates/openfang-runtime/src/web_fetch.rs index 1fba47f718..bb6c6a624a 100644 --- a/crates/openfang-runtime/src/web_fetch.rs +++ b/crates/openfang-runtime/src/web_fetch.rs @@ -42,6 +42,19 @@ impl WebFetchEngine { self.fetch_with_options(url, "GET", None, None).await } + /// The SSRF allowlist this engine was configured with. + /// + /// Exposed so sibling tools (e.g. `secure_fetch`) can run the same + /// `check_ssrf` pre-flight that `fetch_with_options` performs internally. + pub fn ssrf_allowed_hosts(&self) -> &[String] { + &self.config.ssrf_allowed_hosts + } + + /// The `secure_fetch` allowlist configuration for this engine. + pub fn secure_fetch_config(&self) -> &openfang_types::config::SecureFetchConfig { + &self.config.secure_fetch + } + /// Fetch a URL with configurable HTTP method, headers, and body. pub async fn fetch_with_options( &self, @@ -365,6 +378,20 @@ fn is_private_ip(ip: &IpAddr) -> bool { } } +/// Extract just the lowercased hostname (no port) from a URL. +/// +/// Handles IPv6 bracket notation (`[::1]` stays as `[::1]`). Used by +/// `secure_fetch` to enforce per-secret host bindings. +pub(crate) fn url_hostname(url: &str) -> String { + let host = extract_host(url); + let name = if host.starts_with('[') { + host.find(']').map(|i| &host[..=i]).unwrap_or(&host) + } else { + host.split(':').next().unwrap_or(&host) + }; + name.to_lowercase() +} + /// Extract host:port from a URL. /// /// Handles IPv6 bracket notation (`[::1]:8080`), and infers default diff --git a/crates/openfang-types/src/config.rs b/crates/openfang-types/src/config.rs index a61502ebc3..4e30845a37 100644 --- a/crates/openfang-types/src/config.rs +++ b/crates/openfang-types/src/config.rs @@ -346,6 +346,9 @@ pub struct WebFetchConfig { /// cloud metadata endpoint blocking (169.254.169.254, metadata.google.internal, etc.). #[serde(default)] pub ssrf_allowed_hosts: Vec<String>, + /// Configuration for the `secure_fetch` tool (env-resolved secret headers). + #[serde(default)] + pub secure_fetch: SecureFetchConfig, } impl Default for WebFetchConfig { @@ -356,10 +359,32 @@ impl Default for WebFetchConfig { timeout_secs: 30, readability: true, ssrf_allowed_hosts: Vec::new(), + secure_fetch: SecureFetchConfig::default(), } } } +/// Configuration for the `secure_fetch` built-in tool. +/// +/// `secure_fetch` lets an agent attach secret HTTP headers whose values are +/// read from environment variables **by name** in Rust, so the LLM never sees +/// the secret. This config is the allowlist that keeps an agent from reading +/// arbitrary env vars (e.g. exfiltrating `OPENAI_API_KEY`). +/// +/// Fail-closed: with an empty `allowed_secrets` (the default), any attempt to +/// use `secret_headers` is rejected. +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[serde(default)] +pub struct SecureFetchConfig { + /// Env-var names this tool is permitted to read. Empty = fail-closed: + /// every `secret_headers` use is rejected. + pub allowed_secrets: Vec<String>, + /// Optional host binding: env-var name -> the only host(s) its value may be + /// sent to. When an env var is absent from this map, no host restriction is + /// applied beyond the allowlist and the SSRF check. Empty map = no bindings. + pub secret_hosts: HashMap<String, Vec<String>>, +} + /// Browser automation configuration. #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(default)] diff --git a/crates/openfang-types/src/tool_compat.rs b/crates/openfang-types/src/tool_compat.rs index eebe132f9a..a49642d5ad 100644 --- a/crates/openfang-types/src/tool_compat.rs +++ b/crates/openfang-types/src/tool_compat.rs @@ -59,6 +59,7 @@ pub fn is_known_openfang_tool(name: &str) -> bool { | "shell_exec" | "web_search" | "web_fetch" + | "secure_fetch" | "browser_navigate" | "memory_recall" | "memory_store" From 142e984d878f956e878b2b066af9b5f186d08b96 Mon Sep 17 00:00:00 2001 From: Nideesh1 <nterapalli1@gmail.com> Date: Tue, 30 Jun 2026 13:22:36 -0400 Subject: [PATCH 23/31] fix(tests): repair pre-existing agent_loop test call-site arity Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --- crates/openfang-runtime/src/agent_loop.rs | 12 ++++++++++++ crates/openfang-runtime/src/tool_runner.rs | 13 +++++++++++++ 2 files changed, 25 insertions(+) diff --git a/crates/openfang-runtime/src/agent_loop.rs b/crates/openfang-runtime/src/agent_loop.rs index a5e7bd8e29..b5550a20a2 100644 --- a/crates/openfang-runtime/src/agent_loop.rs +++ b/crates/openfang-runtime/src/agent_loop.rs @@ -3550,6 +3550,7 @@ mod tests { None, // context_window_tokens None, // process_manager None, // user_content_blocks + None, // sender_id ) .await .expect("Loop should complete without error"); @@ -3603,6 +3604,7 @@ mod tests { None, // context_window_tokens None, // process_manager None, // user_content_blocks + None, // sender_id ) .await .expect("Loop should complete without error"); @@ -3658,6 +3660,7 @@ mod tests { None, // context_window_tokens None, // process_manager None, // user_content_blocks + None, // sender_id ) .await .expect("Loop should complete without error"); @@ -3711,6 +3714,7 @@ mod tests { None, // context_window_tokens None, // process_manager None, // user_content_blocks + None, // sender_id ) .await .expect("Loop should complete without error"); @@ -3757,6 +3761,7 @@ mod tests { None, // context_window_tokens None, // process_manager None, // user_content_blocks + None, // sender_id ) .await .expect("Streaming loop should complete without error"); @@ -3881,6 +3886,7 @@ mod tests { None, // context_window_tokens None, // process_manager None, // user_content_blocks + None, // sender_id ) .await .expect("Loop should recover via retry"); @@ -3928,6 +3934,7 @@ mod tests { None, // context_window_tokens None, // process_manager None, // user_content_blocks + None, // sender_id ) .await .expect("Loop should complete with fallback"); @@ -3983,6 +3990,7 @@ mod tests { None, // context_window_tokens None, // process_manager None, // user_content_blocks + None, // sender_id ) .await .expect("Streaming loop should complete without error"); @@ -4959,6 +4967,7 @@ mod tests { None, // context_window_tokens None, // process_manager None, // user_content_blocks + None, // sender_id ) .await .expect("Agent loop should complete"); @@ -5029,6 +5038,7 @@ mod tests { None, None, None, + None, // sender_id ) .await .expect("Agent loop should recover nested XML tool calls"); @@ -5101,6 +5111,7 @@ mod tests { None, None, None, // user_content_blocks + None, // sender_id ) .await .expect("Normal loop should complete"); @@ -5164,6 +5175,7 @@ mod tests { None, // context_window_tokens None, // process_manager None, // user_content_blocks + None, // sender_id ) .await .expect("Streaming loop should complete"); diff --git a/crates/openfang-runtime/src/tool_runner.rs b/crates/openfang-runtime/src/tool_runner.rs index f11b4858cc..edeec591bf 100644 --- a/crates/openfang-runtime/src/tool_runner.rs +++ b/crates/openfang-runtime/src/tool_runner.rs @@ -3881,6 +3881,7 @@ mod tests { None, // tts_engine None, // docker_config None, // process_manager + None, // sender_id ) .await; assert!( @@ -3910,6 +3911,7 @@ mod tests { None, // tts_engine None, // docker_config None, // process_manager + None, // sender_id ) .await; assert!(result.is_error); @@ -3936,6 +3938,7 @@ mod tests { None, // tts_engine None, // docker_config None, // process_manager + None, // sender_id ) .await; assert!(result.is_error); @@ -3962,6 +3965,7 @@ mod tests { None, // tts_engine None, // docker_config None, // process_manager + None, // sender_id ) .await; assert!(result.is_error); @@ -3988,6 +3992,7 @@ mod tests { None, // tts_engine None, // docker_config None, // process_manager + None, // sender_id ) .await; // web_search now attempts a real fetch; may succeed or fail depending on network @@ -4014,6 +4019,7 @@ mod tests { None, // tts_engine None, // docker_config None, // process_manager + None, // sender_id ) .await; assert!(result.is_error); @@ -4040,6 +4046,7 @@ mod tests { None, // tts_engine None, // docker_config None, // process_manager + None, // sender_id ) .await; assert!(result.is_error); @@ -4067,6 +4074,7 @@ mod tests { None, // tts_engine None, // docker_config None, // process_manager + None, // sender_id ) .await; assert!(result.is_error); @@ -4098,6 +4106,7 @@ mod tests { None, // tts_engine None, // docker_config None, // process_manager + None, // sender_id ) .await; // Should fail for file-not-found, NOT for permission denied @@ -4143,6 +4152,7 @@ mod tests { None, // tts_engine None, // docker_config None, // process_manager + None, // sender_id ) .await; // Should NOT be the capability-enforcement "Permission denied" — it should @@ -4178,6 +4188,7 @@ mod tests { None, // tts_engine None, // docker_config None, // process_manager + None, // sender_id ) .await; assert!(result.is_error); @@ -4347,6 +4358,7 @@ mod tests { None, // tts_engine None, // docker_config None, // process_manager + None, // sender_id ) .await; assert!(result.is_error); @@ -4392,6 +4404,7 @@ mod tests { None, // tts_engine None, // docker_config None, // process_manager + None, // sender_id ) .await; assert!(result.is_error); From 20f96312151b8ba87fd36cc316f8353acebf3468 Mon Sep 17 00:00:00 2001 From: Nideesh1 <nterapalli1@gmail.com> Date: Tue, 30 Jun 2026 14:02:17 -0400 Subject: [PATCH 24/31] chore(fmt): cargo fmt --all to satisfy CI Format check Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --- crates/openfang-api/src/channel_bridge.rs | 3 +- crates/openfang-channels/src/bridge.rs | 34 +++++++++++++------ crates/openfang-channels/src/router.rs | 8 +++-- crates/openfang-channels/src/slack.rs | 4 +-- crates/openfang-channels/src/telegram.rs | 21 ++++++++---- crates/openfang-runtime/src/agent_loop.rs | 4 ++- .../openfang-runtime/src/drivers/anthropic.rs | 6 ++-- crates/openfang-runtime/src/drivers/openai.rs | 8 +++-- crates/openfang-runtime/src/host_functions.rs | 6 ++-- crates/openfang-runtime/src/model_catalog.rs | 15 +++----- crates/openfang-types/src/message.rs | 8 +++-- 11 files changed, 72 insertions(+), 45 deletions(-) diff --git a/crates/openfang-api/src/channel_bridge.rs b/crates/openfang-api/src/channel_bridge.rs index 0125c94ba7..81a48bf0d3 100644 --- a/crates/openfang-api/src/channel_bridge.rs +++ b/crates/openfang-api/src/channel_bridge.rs @@ -1189,7 +1189,8 @@ pub async fn start_channel_bridge_with_config( let mut adapters: Vec<(Arc<dyn ChannelAdapter>, Option<String>)> = Vec::new(); // Channel keys (Debug form, e.g. "Telegram") whose bot is locked to its // single `default_agent`. Applied to the router after agent IDs resolve. - let mut exclusive_channels: std::collections::HashSet<String> = std::collections::HashSet::new(); + let mut exclusive_channels: std::collections::HashSet<String> = + std::collections::HashSet::new(); // Telegram if let Some(ref tg_config) = config.telegram { diff --git a/crates/openfang-channels/src/bridge.rs b/crates/openfang-channels/src/bridge.rs index 6f369ce45f..a05f310d61 100644 --- a/crates/openfang-channels/src/bridge.rs +++ b/crates/openfang-channels/src/bridge.rs @@ -1014,12 +1014,15 @@ async fn dispatch_message( openfang_types::config::BroadcastStrategy::Sequential => { for (name, maybe_id) in &targets { if let Some(aid) = maybe_id { - match handle.send_message_with_sender( - *aid, - &text, - Some(message.sender.platform_id.clone()), - Some(message.sender.display_name.clone()), - ).await { + match handle + .send_message_with_sender( + *aid, + &text, + Some(message.sender.platform_id.clone()), + Some(message.sender.display_name.clone()), + ) + .await + { Ok(r) => responses.push(format!("[{name}]: {r}")), Err(e) => responses.push(format!("[{name}]: Error: {e}")), } @@ -1799,7 +1802,9 @@ async fn handle_command( match handle.find_agent_by_name(agent_name).await { Ok(Some(agent_id)) => { router.set_user_default(sender.platform_id.clone(), agent_id); - handle.set_delivery_context(agent_id, channel_name, &sender.platform_id).await; + handle + .set_delivery_context(agent_id, channel_name, &sender.platform_id) + .await; format!("Now talking to agent: {agent_name}") } Ok(None) => { @@ -1807,7 +1812,9 @@ async fn handle_command( match handle.spawn_agent_by_name(agent_name).await { Ok(agent_id) => { router.set_user_default(sender.platform_id.clone(), agent_id); - handle.set_delivery_context(agent_id, channel_name, &sender.platform_id).await; + handle + .set_delivery_context(agent_id, channel_name, &sender.platform_id) + .await; format!("Spawned and connected to agent: {agent_name}") } Err(e) => { @@ -2092,8 +2099,15 @@ mod tests { }; // Select existing agent - let result = - handle_command("agent", &["coder".to_string()], &handle, &router, &sender, "test").await; + let result = handle_command( + "agent", + &["coder".to_string()], + &handle, + &router, + &sender, + "test", + ) + .await; assert!(result.contains("Now talking to agent: coder")); // Verify router was updated diff --git a/crates/openfang-channels/src/router.rs b/crates/openfang-channels/src/router.rs index d4342e8509..e6cc00f495 100644 --- a/crates/openfang-channels/src/router.rs +++ b/crates/openfang-channels/src/router.rs @@ -492,7 +492,10 @@ mod tests { Some(user_agent) ); // Lookup helpers agree. - assert_eq!(router.exclusive_agent(&ChannelType::Telegram), Some(exclusive)); + assert_eq!( + router.exclusive_agent(&ChannelType::Telegram), + Some(exclusive) + ); assert_eq!(router.exclusive_agent_str("telegram"), Some(exclusive)); assert_eq!(router.exclusive_agent(&ChannelType::Discord), None); } @@ -796,7 +799,8 @@ mod tests { assert_eq!(resolved, None); // Missing channel_id on the wire — no match (the binding is restrictive). - let resolved = router.resolve_with_channel_id(&ChannelType::Discord, "any-user", None, None); + let resolved = + router.resolve_with_channel_id(&ChannelType::Discord, "any-user", None, None); assert_eq!(resolved, None); } diff --git a/crates/openfang-channels/src/slack.rs b/crates/openfang-channels/src/slack.rs index b113e77d31..3229d9df0e 100644 --- a/crates/openfang-channels/src/slack.rs +++ b/crates/openfang-channels/src/slack.rs @@ -329,9 +329,7 @@ impl ChannelAdapter for SlackAdapter { // connection during the rotation overlap. Ack on // both, but only forward to the agent once. if is_duplicate_envelope(&seen_envelopes, envelope_id) { - debug!( - "Slack: skipping duplicate envelope_id {envelope_id}" - ); + debug!("Slack: skipping duplicate envelope_id {envelope_id}"); continue; } diff --git a/crates/openfang-channels/src/telegram.rs b/crates/openfang-channels/src/telegram.rs index cb4a5b01b2..747f651647 100644 --- a/crates/openfang-channels/src/telegram.rs +++ b/crates/openfang-channels/src/telegram.rs @@ -2051,10 +2051,7 @@ mod tests { body, ) } else { - ( - StatusCode::OK, - r#"{"ok":true,"result":true}"#.to_string(), - ) + (StatusCode::OK, r#"{"ok":true,"result":true}"#.to_string()) } } })); @@ -2131,7 +2128,10 @@ mod tests { // Two-chunk message; first POST fails. Nothing delivered → Err. let big = "a".repeat(5000); // > 4096 → split into two chunks let stub = StubServer::new(vec![ - (500, r#"{"ok":false,"error_code":500,"description":"server"}"#), + ( + 500, + r#"{"ok":false,"error_code":500,"description":"server"}"#, + ), (200, r#"{"ok":true,"result":{}}"#), ]); let base = spawn_stub_server(stub.clone()).await; @@ -2159,7 +2159,10 @@ mod tests { let big = "a".repeat(5000); let stub = StubServer::new(vec![ (200, r#"{"ok":true,"result":{}}"#), - (400, r#"{"ok":false,"error_code":400,"description":"some err"}"#), + ( + 400, + r#"{"ok":false,"error_code":400,"description":"some err"}"#, + ), ]); let base = spawn_stub_server(stub.clone()).await; let adapter = test_adapter(base); @@ -2170,7 +2173,11 @@ mod tests { result.is_ok(), "partial delivery must return Ok (best-effort), got {result:?}" ); - assert_eq!(stub.hit_count(), 2, "both chunks should have been attempted"); + assert_eq!( + stub.hit_count(), + 2, + "both chunks should have been attempted" + ); } // ----------------------------------------------------------------------- diff --git a/crates/openfang-runtime/src/agent_loop.rs b/crates/openfang-runtime/src/agent_loop.rs index b5550a20a2..28e0b6ef39 100644 --- a/crates/openfang-runtime/src/agent_loop.rs +++ b/crates/openfang-runtime/src/agent_loop.rs @@ -3206,7 +3206,9 @@ mod tests { assert_eq!(blocks.len(), 2, "must preserve thinking + text"); match &blocks[0] { ContentBlock::Thinking { - thinking, signature, .. + thinking, + signature, + .. } => { assert_eq!(thinking, "Let me reason carefully..."); assert_eq!(signature.as_deref(), Some("sig_anthropic_xyz")); diff --git a/crates/openfang-runtime/src/drivers/anthropic.rs b/crates/openfang-runtime/src/drivers/anthropic.rs index e6f10fe58e..73da4c9340 100644 --- a/crates/openfang-runtime/src/drivers/anthropic.rs +++ b/crates/openfang-runtime/src/drivers/anthropic.rs @@ -438,10 +438,8 @@ impl LlmDriver for AnthropicDriver { "thinking" => { // Some API versions ship the signature on // content_block_start instead of as a delta. - let initial_sig = block["signature"] - .as_str() - .unwrap_or("") - .to_string(); + let initial_sig = + block["signature"].as_str().unwrap_or("").to_string(); blocks.push(ContentBlockAccum::Thinking { thinking: String::new(), signature: initial_sig, diff --git a/crates/openfang-runtime/src/drivers/openai.rs b/crates/openfang-runtime/src/drivers/openai.rs index 554e14b537..bbd6a57ea2 100644 --- a/crates/openfang-runtime/src/drivers/openai.rs +++ b/crates/openfang-runtime/src/drivers/openai.rs @@ -2008,7 +2008,10 @@ mod tests { Some(OaiMessageContent::Text(t)) => t, _ => panic!("expected text content"), }; - assert_eq!(content, "answer", "visible content must not include <think>"); + assert_eq!( + content, "answer", + "visible content must not include <think>" + ); assert_eq!( msg.reasoning_content.as_deref(), Some("internal chain-of-thought"), @@ -2020,8 +2023,7 @@ mod tests { /// assistant message — preserve the legacy shape. #[test] fn test_assemble_assistant_no_thinking_is_plain() { - let driver = - OpenAIDriver::new("test".to_string(), "https://api.openai.com/v1".to_string()); + let driver = OpenAIDriver::new("test".to_string(), "https://api.openai.com/v1".to_string()); let blocks = vec![ContentBlock::Text { text: "Hi.".to_string(), provider_metadata: None, diff --git a/crates/openfang-runtime/src/host_functions.rs b/crates/openfang-runtime/src/host_functions.rs index cc7cfff8a2..a3c3c29fb4 100644 --- a/crates/openfang-runtime/src/host_functions.rs +++ b/crates/openfang-runtime/src/host_functions.rs @@ -258,7 +258,6 @@ fn host_net_fetch(state: &GuestState, params: &serde_json::Value) -> serde_json: }) } - // --------------------------------------------------------------------------- // Shell (capability-checked) // --------------------------------------------------------------------------- @@ -561,7 +560,10 @@ mod tests { assert!(web_fetch::check_ssrf("http://127.0.0.1:8080/secret", &no_allow).is_err()); assert!(web_fetch::check_ssrf("http://localhost:3000/api", &no_allow).is_err()); assert!(web_fetch::check_ssrf("http://169.254.169.254/metadata", &no_allow).is_err()); - assert!(web_fetch::check_ssrf("http://metadata.google.internal/v1/instance", &no_allow).is_err()); + assert!( + web_fetch::check_ssrf("http://metadata.google.internal/v1/instance", &no_allow) + .is_err() + ); // These were previously missing from host_functions — now covered: assert!(web_fetch::check_ssrf("http://[::1]:8080/secret", &no_allow).is_err()); assert!(web_fetch::check_ssrf("http://100.100.100.200/metadata", &no_allow).is_err()); diff --git a/crates/openfang-runtime/src/model_catalog.rs b/crates/openfang-runtime/src/model_catalog.rs index 3c5f7d5f9e..6470f4e395 100644 --- a/crates/openfang-runtime/src/model_catalog.rs +++ b/crates/openfang-runtime/src/model_catalog.rs @@ -1022,10 +1022,7 @@ fn builtin_aliases() -> HashMap<String, String> { ), ("free", "openrouter/meta-llama/llama-3.3-70b-instruct:free"), ("free-reasoning", "openrouter/deepseek/deepseek-r1:free"), - ( - "openrouter/free-coder", - "openrouter/qwen/qwen3-coder:free", - ), + ("openrouter/free-coder", "openrouter/qwen/qwen3-coder:free"), ( "openrouter/free-large", "openrouter/openai/gpt-oss-120b:free", @@ -4611,9 +4608,9 @@ mod tests { #[test] fn test_openrouter_free_alias_supports_tools() { let catalog = ModelCatalog::new(); - let entry = catalog.find_model("openrouter/free").expect( - "openrouter/free alias must resolve to a known model", - ); + let entry = catalog + .find_model("openrouter/free") + .expect("openrouter/free alias must resolve to a known model"); assert_eq!(entry.provider, "openrouter"); assert!( entry.supports_tools, @@ -4626,9 +4623,7 @@ mod tests { #[test] fn test_openrouter_free_short_alias_supports_tools() { let catalog = ModelCatalog::new(); - let entry = catalog - .find_model("free") - .expect("free alias must resolve"); + let entry = catalog.find_model("free").expect("free alias must resolve"); assert_eq!(entry.provider, "openrouter"); assert!( entry.supports_tools, diff --git a/crates/openfang-types/src/message.rs b/crates/openfang-types/src/message.rs index 67955a445d..e914aab916 100644 --- a/crates/openfang-types/src/message.rs +++ b/crates/openfang-types/src/message.rs @@ -356,7 +356,9 @@ mod tests { let restored: ContentBlock = serde_json::from_str(&serialized).unwrap(); match restored { ContentBlock::Thinking { - thinking, signature, .. + thinking, + signature, + .. } => { assert_eq!(thinking, "Let me reason about this carefully..."); assert_eq!( @@ -439,7 +441,9 @@ mod tests { assert_eq!(blocks.len(), 2); match &blocks[0] { ContentBlock::Thinking { - thinking, signature, .. + thinking, + signature, + .. } => { assert_eq!(thinking, "Internal reasoning"); assert_eq!(signature.as_deref(), Some("sig_xyz")); From 79e6c17f0fc2f7ec1d8ac52836216243e04ec0d0 Mon Sep 17 00:00:00 2001 From: Nideesh1 <nterapalli1@gmail.com> Date: Tue, 30 Jun 2026 14:07:16 -0400 Subject: [PATCH 25/31] chore(deps): bump vulnerable deps to clear cargo audit - lettre 0.11.21 -> 0.11.22 (RUSTSEC-2026-0141, critical) - quinn-proto 0.11.14 -> 0.11.15 (RUSTSEC-2026-0185, high) - rmcp 1.3.0 -> 1.4.0 (RUSTSEC-2026-0189, high) - rustls-webpki 0.103.10 -> 0.103.13 (RUSTSEC-2026-0104/0098/0099) - wasmtime 43.0.1 -> 43.0.2 (RUSTSEC-2026-0114, medium) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --- Cargo.lock | 168 ++++++++++++++++++++++++++--------------------------- 1 file changed, 84 insertions(+), 84 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index fb5aa5cc15..4ecb305846 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -139,7 +139,7 @@ version = "1.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -150,7 +150,7 @@ checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" dependencies = [ "anstyle", "once_cell_polyfill", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -893,7 +893,7 @@ version = "3.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "faf9468729b8cbcea668e36183cb69d317348c2e08e994829fb56ebfdfbaac34" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -1029,27 +1029,27 @@ dependencies = [ [[package]] name = "cranelift-assembler-x64" -version = "0.130.1" +version = "0.130.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "046d4b584c3bb9b5eb500c8f29549bec36be11000f1ba2a927cef3d1a9875691" +checksum = "adc822414b18d1f5b1b33ce1441534e311e62fef86ebb5b9d382af857d0272c9" dependencies = [ "cranelift-assembler-x64-meta", ] [[package]] name = "cranelift-assembler-x64-meta" -version = "0.130.1" +version = "0.130.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9b194a7870becb1490366fc0ae392ccd188065ff35f8391e77ac659db6fb977" +checksum = "8c646808b06f4532478d8d6057d74f15c3322f10d995d9486e7dcea405bf521a" dependencies = [ "cranelift-srcgen", ] [[package]] name = "cranelift-bforest" -version = "0.130.1" +version = "0.130.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bb6a4ab44c6b371e661846b97dab687387a60ac4e2f864e2d4257284aad9e889" +checksum = "7b5996f01a686b2349cdb379083ec5ad3e8cb8767fb2d495d3a4f2ee4163a18d" dependencies = [ "cranelift-entity", "wasmtime-internal-core", @@ -1057,9 +1057,9 @@ dependencies = [ [[package]] name = "cranelift-bitset" -version = "0.130.1" +version = "0.130.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b8b7a44150c2f471a94023482bda1902710746e4bed9f9973d60c5a94319b06d" +checksum = "523fea83273f6a985520f57788809a4de2165794d9ab00fb1254fceb4f5aa00c" dependencies = [ "serde", "serde_derive", @@ -1068,9 +1068,9 @@ dependencies = [ [[package]] name = "cranelift-codegen" -version = "0.130.1" +version = "0.130.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "01b06598133b1dd76758b8b95f8d6747c124124aade50cea96a3d88b962da9fa" +checksum = "d73d1e372730b5f64ed1a2bd9f01fe4686c8ec14a28034e3084e530c8d951878" dependencies = [ "bumpalo", "cranelift-assembler-x64", @@ -1096,9 +1096,9 @@ dependencies = [ [[package]] name = "cranelift-codegen-meta" -version = "0.130.1" +version = "0.130.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6190e2e7bcf0a678da2f715363d34ed530fedf7a2f0ab75edaefef72a70465ff" +checksum = "b0319c18165e93dc1ebf78946a8da0b1c341c95b4a39729a69574671639bdb5f" dependencies = [ "cranelift-assembler-x64-meta", "cranelift-codegen-shared", @@ -1109,24 +1109,24 @@ dependencies = [ [[package]] name = "cranelift-codegen-shared" -version = "0.130.1" +version = "0.130.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f583cf203d1aa8b79560e3b01f929bdacf9070b015eec4ea9c46e22a3f83e4a0" +checksum = "9195cd8aeecb55e401aa96b2eaa55921636e8246c127ed7908f7ef7e0d40f270" [[package]] name = "cranelift-control" -version = "0.130.1" +version = "0.130.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "803159df35cc398ae54473c150b16d6c77e92ab2948be638488de126a3328fbc" +checksum = "8976c2154b74136322befc74222ab5c7249edd7e2604f8cbef2b94975541ffb9" dependencies = [ "arbitrary", ] [[package]] name = "cranelift-entity" -version = "0.130.1" +version = "0.130.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3109e417257082d88087f5bcce677525bdaa8322b88dd7f175ed1a1fd41d546c" +checksum = "6038b3147c7982f4951150d5f96c7c06c1e7214b99d4b4a98607aadf8ded89d1" dependencies = [ "cranelift-bitset", "serde", @@ -1136,9 +1136,9 @@ dependencies = [ [[package]] name = "cranelift-frontend" -version = "0.130.1" +version = "0.130.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "14db6b0e0e4994c581092df78d837be2072578f7cb2528f96a6cf895e56dee63" +checksum = "4cbd294abe236e23cc3d907b0936226b6a8342db7636daa9c7c72be1e323420e" dependencies = [ "cranelift-codegen", "log", @@ -1148,15 +1148,15 @@ dependencies = [ [[package]] name = "cranelift-isle" -version = "0.130.1" +version = "0.130.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ec66ea5025c7317383699778282ac98741d68444f956e3b1d7b62f12b7216e67" +checksum = "b5a90b6ed3aba84189352a87badeb93b2126d3724225a42dc67fdce53d1b139c" [[package]] name = "cranelift-native" -version = "0.130.1" +version = "0.130.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "373ade56438e6232619d85678477d0a88a31b3581936e0503e61e96b546b0800" +checksum = "c3ec0cc1a54e22925eacf4fc3dc815f907734d3b377899d19d52bec04863e853" dependencies = [ "cranelift-codegen", "libc", @@ -1165,9 +1165,9 @@ dependencies = [ [[package]] name = "cranelift-srcgen" -version = "0.130.1" +version = "0.130.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ef53619d3cd5c78fd998c6d9420547af26b72e6456f94c2a8a2334cb76b42baa" +checksum = "948865622f87f30907bb46fbb081b235ae63c1896a99a83c26a003305c1fa82d" [[package]] name = "crc32fast" @@ -1555,7 +1555,7 @@ dependencies = [ "libc", "option-ext", "redox_users 0.5.2", - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -1805,7 +1805,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -2761,7 +2761,7 @@ dependencies = [ "js-sys", "log", "wasm-bindgen", - "windows-core 0.62.2", + "windows-core 0.61.2", ] [[package]] @@ -3255,9 +3255,9 @@ checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" [[package]] name = "lettre" -version = "0.11.21" +version = "0.11.22" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dabda5859ee7c06b995b9d1165aa52c39110e079ef609db97178d86aeb051fa7" +checksum = "0da65617f6cb926332d039cb578aad56178da86e128db6a1b09f4c94fa5b3349" dependencies = [ "async-trait", "base64 0.22.1", @@ -3739,7 +3739,7 @@ version = "0.50.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -4394,7 +4394,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967" dependencies = [ "libc", - "windows-sys 0.61.2", + "windows-sys 0.45.0", ] [[package]] @@ -5019,7 +5019,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "27c6023962132f4b30eb4c172c91ce92d933da334c59c23cddee82358ddafb0b" dependencies = [ "anyhow", - "itertools 0.14.0", + "itertools 0.13.0", "proc-macro2", "quote", "syn 2.0.117", @@ -5027,9 +5027,9 @@ dependencies = [ [[package]] name = "pulley-interpreter" -version = "43.0.1" +version = "43.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "010dec3755eb61b2f1051ecb3611b718460b7a74c131e474de2af20a845938af" +checksum = "7ec12fe19a9588315a49fe5704502a9c02d6a198303314b0c7c86123b06d29e5" dependencies = [ "cranelift-bitset", "log", @@ -5039,9 +5039,9 @@ dependencies = [ [[package]] name = "pulley-macros" -version = "43.0.1" +version = "43.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ad360c32e85ca4b083ac0e2b6856e8f11c3d5060dafa7d5dc57b370857fa3018" +checksum = "36f7d5ef31ebf1b46cd7e722ffef934e670d7e462f49aa01cde07b9b76dca580" dependencies = [ "proc-macro2", "quote", @@ -5109,9 +5109,9 @@ dependencies = [ [[package]] name = "quinn-proto" -version = "0.11.14" +version = "0.11.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "434b42fec591c96ef50e21e886936e66d3cc3f737104fdb9b737c40ffb94c098" +checksum = "4fcb935c5bec503c2f0e306bdd3e58bb9029dcb14fa8d9ac76e3a5256ac0763e" dependencies = [ "aws-lc-rs", "bytes", @@ -5577,9 +5577,9 @@ dependencies = [ [[package]] name = "rmcp" -version = "1.3.0" +version = "1.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2231b2c085b371c01bc90c0e6c1cab8834711b6394533375bdbf870b0166d419" +checksum = "f542f74cf247da16f19bbc87e298cd201e912314f4083e88cdd671f44f5fcb53" dependencies = [ "async-trait", "chrono", @@ -5704,7 +5704,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys 0.12.1", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -5763,7 +5763,7 @@ dependencies = [ "security-framework", "security-framework-sys", "webpki-root-certs", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -5774,9 +5774,9 @@ checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f" [[package]] name = "rustls-webpki" -version = "0.103.10" +version = "0.103.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df33b2b81ac578cabaf06b89b0631153a3f416b0a886e8a7a1707fb51abbd1ef" +checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" dependencies = [ "aws-lc-rs", "ring", @@ -6309,7 +6309,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e" dependencies = [ "libc", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -7042,10 +7042,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" dependencies = [ "fastrand", - "getrandom 0.3.4", + "getrandom 0.4.2", "once_cell", "rustix 1.1.4", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -7617,7 +7617,7 @@ checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e" dependencies = [ "memoffset", "tempfile", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -8061,9 +8061,9 @@ dependencies = [ [[package]] name = "wasmtime" -version = "43.0.1" +version = "43.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ce205cd643d661b5ba5ba4717e13730262e8cdbc8f2eacbc7b906d45c1a74026" +checksum = "efb1ed5899dde98357cfdcf647a4614498798719793898245b4b34e663addabf" dependencies = [ "addr2line", "async-trait", @@ -8114,9 +8114,9 @@ dependencies = [ [[package]] name = "wasmtime-environ" -version = "43.0.1" +version = "43.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b8b78abf3677d4a0a5db82e5015b4d085ff3a1b8b472cbb8c70d4b769f019ce" +checksum = "4172382dcc785c31d0e862c6780a18f5dd437914d22c4691351f965ef751c821" dependencies = [ "anyhow", "cpp_demangle", @@ -8145,9 +8145,9 @@ dependencies = [ [[package]] name = "wasmtime-internal-cache" -version = "43.0.1" +version = "43.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e4fd4103ba413c0da2e636f73490c6c8e446d708cbde7573703941bc3d6a448" +checksum = "4ed398988226d7aa0505ac6bb576e09532ad722d702ec4e66365d78ed695c95f" dependencies = [ "base64 0.22.1", "directories-next", @@ -8165,9 +8165,9 @@ dependencies = [ [[package]] name = "wasmtime-internal-component-macro" -version = "43.0.1" +version = "43.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0d3d6914f34be2f9d78d8ee9f422e834dfc204e71ccce697205fae95fed87892" +checksum = "ae5ec9fff073ff13b81732d56a9515d761c245750bcda09093827f84130ebc25" dependencies = [ "anyhow", "proc-macro2", @@ -8180,15 +8180,15 @@ dependencies = [ [[package]] name = "wasmtime-internal-component-util" -version = "43.0.1" +version = "43.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3751b0616b914fdd87fe1bf804694a078f321b000338e6476bc48a4d6e454f21" +checksum = "935d9ab293ba27d1ec9aa7bc1b3a43993dbe961af2a8f23f90a11e1331b4c13f" [[package]] name = "wasmtime-internal-core" -version = "43.0.1" +version = "43.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "22632b187e1b0716f1b9ac57ad29013bed33175fcb19e10bb6896126f82fac67" +checksum = "9a3820b174f477d2a7083209d1ad5353fcdb11eaea434b2137b8681029460dd3" dependencies = [ "anyhow", "hashbrown 0.16.1", @@ -8198,9 +8198,9 @@ dependencies = [ [[package]] name = "wasmtime-internal-cranelift" -version = "43.0.1" +version = "43.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b3ca07b3e0bb3429674b173b5800577719d600774dd81bff58f775c0aaa64ee" +checksum = "d1679d205caf9766c6aa309d45bb3e7c634d7725e3164404df33824b9f7c4fb7" dependencies = [ "cfg-if", "cranelift-codegen", @@ -8225,9 +8225,9 @@ dependencies = [ [[package]] name = "wasmtime-internal-fiber" -version = "43.0.1" +version = "43.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "20c8b2c9704eb1f33ead025ec16038277ccb63d0a14c31e99d5b765d7c36da55" +checksum = "f1e505254058be5b0df458d670ee42d9eafe2349d04c1296e9dc01071dc20a85" dependencies = [ "cc", "cfg-if", @@ -8240,9 +8240,9 @@ dependencies = [ [[package]] name = "wasmtime-internal-jit-debug" -version = "43.0.1" +version = "43.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d950310d07391d34369f62c48336ebb14eacbd4d6f772bb5f349c24e838e0664" +checksum = "1c2e05b345f1773e59c20e6ad7298fd6857cdea245023d88bb659c96d8f0ea72" dependencies = [ "cc", "object", @@ -8252,9 +8252,9 @@ dependencies = [ [[package]] name = "wasmtime-internal-jit-icache-coherence" -version = "43.0.1" +version = "43.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3606662c156962d096be3127b8b8ae8ee2f8be3f896dad29259ff01ddb64abfd" +checksum = "b86701b234a4643e3f111869aa792b3a05a06e02d486ee9cb6c04dae16b52dab" dependencies = [ "cfg-if", "libc", @@ -8264,9 +8264,9 @@ dependencies = [ [[package]] name = "wasmtime-internal-unwinder" -version = "43.0.1" +version = "43.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "75eef0747e52dc545b075f64fd0e0cc237ae738e641266b1970e07e2d744bc32" +checksum = "f63558d801beb83dde9b336eb4ae049019aee26627926edb32cd119d7e4c83cd" dependencies = [ "cfg-if", "cranelift-codegen", @@ -8277,9 +8277,9 @@ dependencies = [ [[package]] name = "wasmtime-internal-versioned-export-macros" -version = "43.0.1" +version = "43.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d8b0a5dab02a8fb527f547855ecc0e05f9fdc3d5bd57b8b080349408f9a6cece" +checksum = "737c4d956fc3a848541a064afb683dd2771132a6b125be5baaf95c4379aa47df" dependencies = [ "proc-macro2", "quote", @@ -8288,9 +8288,9 @@ dependencies = [ [[package]] name = "wasmtime-internal-winch" -version = "43.0.1" +version = "43.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8007342bd12ff400293a817973f7ecd6f1d9a8549a53369a9c1af357166f1f1e" +checksum = "f599b79545e3bba0b7913406055ebede5bb0dabee9ba2015ef25a9f4c9f47807" dependencies = [ "cranelift-codegen", "gimli", @@ -8305,9 +8305,9 @@ dependencies = [ [[package]] name = "wasmtime-internal-wit-bindgen" -version = "43.0.1" +version = "43.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7900c3e3c1d6e475bc225d73b02d6d5484815f260022e6964dca9558e50dd01a" +checksum = "2192a77a00b9a67800c2b4e1c70fb6abca79d6b529e53a2ef9dcdcc36090330d" dependencies = [ "anyhow", "bitflags 2.11.0", @@ -8490,7 +8490,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -8501,9 +8501,9 @@ checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" [[package]] name = "winch-codegen" -version = "43.0.1" +version = "43.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eb9f45f7172a2628c8317766e427babc0a400f9d10b1c0f0b0617c5ed5b79de6" +checksum = "52dbb0cf07b0dfe7b7a1ca8efb8f94ba98bd0fb144c411ea1665c78f0449e958" dependencies = [ "cranelift-assembler-x64", "cranelift-codegen", From 72d74094320e973379fe28f656284bdbddb1d518 Mon Sep 17 00:00:00 2001 From: Nideesh1 <nterapalli1@gmail.com> Date: Tue, 30 Jun 2026 14:21:17 -0400 Subject: [PATCH 26/31] chore(test): fix bridge default-agent routing key under CI Test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit handle_command keyed the default-agent map and session-command resolves on sender.platform_id, which is the *channel* id on Discord/Slack — so test_handle_command_agent_select_keys_on_user_id_not_platform_id failed. A prior rebase had collapsed the upstream user_id param into channel_name (see f67c4e8 / 98e1634); this restores a distinct user_id parameter (callers pass sender_user_id(message)) and keys set_user_default plus the 6 session-command resolves on it, matching the read path. channel_name is retained for exclusive-agent lookup and delivery context. No behavior change for adapters where platform_id already is the user (CLI/Telegram). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --- crates/openfang-channels/src/bridge.rs | 54 +++++++++++++++++++------- 1 file changed, 40 insertions(+), 14 deletions(-) diff --git a/crates/openfang-channels/src/bridge.rs b/crates/openfang-channels/src/bridge.rs index a05f310d61..9cc1049bd1 100644 --- a/crates/openfang-channels/src/bridge.rs +++ b/crates/openfang-channels/src/bridge.rs @@ -864,7 +864,16 @@ async fn dispatch_message( // Handle commands first (early return) if let ChannelContent::Command { ref name, ref args } = message.content { - let result = handle_command(name, args, handle, router, &message.sender, ct_str).await; + let result = handle_command( + name, + args, + handle, + router, + &message.sender, + ct_str, + sender_user_id(message), + ) + .await; send_response(adapter, &message.sender, result, thread_id, output_format).await; return; } @@ -948,7 +957,16 @@ async fn dispatch_message( }; if is_channel_command(cmd) { - let result = handle_command(cmd, &args, handle, router, &message.sender, ct_str).await; + let result = handle_command( + cmd, + &args, + handle, + router, + &message.sender, + ct_str, + sender_user_id(message), + ) + .await; send_response(adapter, &message.sender, result, thread_id, output_format).await; return; } @@ -1722,6 +1740,7 @@ async fn handle_command( router: &Arc<AgentRouter>, sender: &ChannelUser, channel_name: &str, + user_id: &str, ) -> String { // Canonicalise through the unified command registry: aliases resolve to // their canonical name and matching is case-insensitive. If the command @@ -1801,7 +1820,10 @@ async fn handle_command( let agent_name = &args[0]; match handle.find_agent_by_name(agent_name).await { Ok(Some(agent_id)) => { - router.set_user_default(sender.platform_id.clone(), agent_id); + // Key the default on user_id (the per-user routing key wired in by + // the Discord/Slack call sites via sender_user_id(message)) — not + // sender.platform_id, which is the channel ID on those adapters. + router.set_user_default(user_id.to_string(), agent_id); handle .set_delivery_context(agent_id, channel_name, &sender.platform_id) .await; @@ -1811,7 +1833,7 @@ async fn handle_command( // Try to spawn it match handle.spawn_agent_by_name(agent_name).await { Ok(agent_id) => { - router.set_user_default(sender.platform_id.clone(), agent_id); + router.set_user_default(user_id.to_string(), agent_id); handle .set_delivery_context(agent_id, channel_name, &sender.platform_id) .await; @@ -1829,7 +1851,7 @@ async fn handle_command( // Need to resolve the user's current agent let agent_id = router.resolve( &crate::types::ChannelType::CLI, - sender.platform_id.as_str(), + user_id, sender.openfang_user.as_deref(), ); match agent_id { @@ -1843,7 +1865,7 @@ async fn handle_command( "compact" => { let agent_id = router.resolve( &crate::types::ChannelType::CLI, - sender.platform_id.as_str(), + user_id, sender.openfang_user.as_deref(), ); match agent_id { @@ -1857,7 +1879,7 @@ async fn handle_command( "model" => { let agent_id = router.resolve( &crate::types::ChannelType::CLI, - sender.platform_id.as_str(), + user_id, sender.openfang_user.as_deref(), ); match agent_id { @@ -1881,7 +1903,7 @@ async fn handle_command( "stop" => { let agent_id = router.resolve( &crate::types::ChannelType::CLI, - sender.platform_id.as_str(), + user_id, sender.openfang_user.as_deref(), ); match agent_id { @@ -1895,7 +1917,7 @@ async fn handle_command( "usage" => { let agent_id = router.resolve( &crate::types::ChannelType::CLI, - sender.platform_id.as_str(), + user_id, sender.openfang_user.as_deref(), ); match agent_id { @@ -1909,7 +1931,7 @@ async fn handle_command( "think" => { let agent_id = router.resolve( &crate::types::ChannelType::CLI, - sender.platform_id.as_str(), + user_id, sender.openfang_user.as_deref(), ); match agent_id { @@ -2078,10 +2100,11 @@ mod tests { openfang_user: None, }; - let result = handle_command("agents", &[], &handle, &router, &sender, "test").await; + let result = + handle_command("agents", &[], &handle, &router, &sender, "test", "user1").await; assert!(result.contains("coder")); - let result = handle_command("help", &[], &handle, &router, &sender, "test").await; + let result = handle_command("help", &[], &handle, &router, &sender, "test", "user1").await; assert!(result.contains("/agents")); } @@ -2098,7 +2121,8 @@ mod tests { openfang_user: None, }; - // Select existing agent + // Select existing agent. Telegram-shape: platform_id IS the user, so the + // user_id routing key equals the platform_id here. let result = handle_command( "agent", &["coder".to_string()], @@ -2106,6 +2130,7 @@ mod tests { &router, &sender, "test", + "user1", ) .await; assert!(result.contains("Now talking to agent: coder")); @@ -2140,6 +2165,7 @@ mod tests { &handle, &router, &sender, + "discord", user_id, ) .await; @@ -2170,7 +2196,7 @@ mod tests { openfang_user: None, }; - let result = handle_command("agent", &[], &handle, &router, &sender, "test").await; + let result = handle_command("agent", &[], &handle, &router, &sender, "test", "user1").await; assert!(result.contains("Usage: /agent <name>")); assert!(result.contains("coder")); } From 0bf2225b023ecbdd3aad37453c8607e686f085ae Mon Sep 17 00:00:00 2001 From: Nideesh1 <nterapalli1@gmail.com> Date: Tue, 30 Jun 2026 16:07:03 -0400 Subject: [PATCH 27/31] Merge upstream RightNow-AI/openfang main into 797th Absorbs upstream's 68 commits (skills system, agent lifecycle clone/activate/ uninstall, requesty provider, security hardening: ws auth/signed/redacted) while preserving 797th's work (secure_fetch tool, sender_id threading, the user_id default-agent fix, CI-green fmt+dep bumps). Conflicts resolved in 5 files: Cargo.lock (regenerated), config.rs (additive), tool_runner.rs (combined tool arms + re-threaded secure_fetch), agent_loop.rs (kept sender_id + upstream features), kernel.rs (preserved user_id fix). Fixed 2 new upstream create_directory test call sites for the sender_id arg. Verified on rustc 1.96: build, fmt, cargo audit, clippy, and RUSTFLAGS=-D warnings cargo test --workspace all green (0 failures); the user_id regression guard test_handle_command_agent_select_keys_on_user_id_not_platform_id passes. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --- .gitignore | 2 ++ crates/openfang-runtime/src/tool_runner.rs | 2 ++ 2 files changed, 4 insertions(+) diff --git a/.gitignore b/.gitignore index de371097ad..a0134af995 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,7 @@ # Build /target +/target-clippy +/target-* **/*.rs.bk *.pdb diff --git a/crates/openfang-runtime/src/tool_runner.rs b/crates/openfang-runtime/src/tool_runner.rs index 3b7b492139..f3bb198d23 100644 --- a/crates/openfang-runtime/src/tool_runner.rs +++ b/crates/openfang-runtime/src/tool_runner.rs @@ -4367,6 +4367,7 @@ mod tests { None, // tts_engine None, // docker_config None, // process_manager + None, // sender_id ) .await; assert!(result.is_error); @@ -4429,6 +4430,7 @@ mod tests { None, // tts_engine None, // docker_config None, // process_manager + None, // sender_id ) .await; assert!( From 6df3b642d06f8a06a1e55f56b2798ca86d9974da Mon Sep 17 00:00:00 2001 From: Nideesh Terapalli <nterapalli@gmail.com> Date: Wed, 15 Jul 2026 22:45:34 +0000 Subject: [PATCH 28/31] docs(deploy): capture kamd1 manifests for openfang.pageapp.net The kamd1 deployment existed only in-cluster. deploy/rancher/ targets a different cluster, so nothing in the repo described what serves openfang.pageapp.net. Captured live and templated: Deployment, Service, PVC, Traefik IngressRoute and oauth2-proxy Middleware, plus the mounted config.toml. No secrets are committed. api_key and the image tag are ${OPENFANG_API_KEY} and ${IMAGE_TAG}; the referenced secrets are documented by key name only and must be created out-of-band. Named config.template.toml because .gitignore ignores config.toml at any depth. Captured as-is rather than modified: the IngressRoute still uses the deprecated traefik.containo.us/v1alpha1 API group, and the pod securityContext is empty. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --- deploy/kamd1/config.template.toml | 50 ++++++++ deploy/kamd1/openfang.yaml | 184 ++++++++++++++++++++++++++++++ 2 files changed, 234 insertions(+) create mode 100644 deploy/kamd1/config.template.toml create mode 100644 deploy/kamd1/openfang.yaml diff --git a/deploy/kamd1/config.template.toml b/deploy/kamd1/config.template.toml new file mode 100644 index 0000000000..d8e4cbe497 --- /dev/null +++ b/deploy/kamd1/config.template.toml @@ -0,0 +1,50 @@ +api_key = "${OPENFANG_API_KEY}" +api_listen = "0.0.0.0:4200" +usage_footer = "off" + +[channels.telegram] +allowed_users = [] +bot_token_env = "TELEGRAM_BOT_TOKEN" +default_agent = "nideesh-agent" +poll_interval_secs = 1 + +[default_model] +api_key_env = "NVIDIA_API_KEY" +base_url = "https://integrate.api.nvidia.com/v1" +model = "stepfun-ai/step-3.5-flash" +provider = "nvidia" + +[memory] +decay_rate = 0.05 +embedding_model = "nomic-embed-text:latest" +embedding_provider = "ollama" + +[provider_urls] +nvidia = "https://integrate.api.nvidia.com/v1" +ollama = "http://ollama-external.ollama.svc.cluster.local:11434/v1" + +[web] +cache_ttl_minutes = 15 +search_provider = "auto" + +[web.fetch] +ssrf_allowed_hosts = ["127.0.0.1", "localhost", "192.168.0.0/16", "ai-screen-buddy-backend.pageapp.net"] + +# secure_fetch: lets an agent attach secret HTTP headers whose values are read +# from environment variables BY NAME in Rust — the LLM never sees the secret. +# Fail-closed: an env var must be listed in allowed_secrets to be usable. +# secret_hosts pins each secret so its value is only ever sent to those hosts. +[web.fetch.secure_fetch] +allowed_secrets = ["SCREENBUDDY_API_KEY"] + +[web.fetch.secure_fetch.secret_hosts] +SCREENBUDDY_API_KEY = ["ai-screen-buddy-backend.pageapp.net"] + +# webhook_triggers: enables POST /hooks/agent (and /hooks/wake) so an external +# service (ScreenBuddy) can wake an agent on job completion. Bearer token is +# read from the env var named by token_env (value lives in openfang-secrets). +[webhook_triggers] +enabled = true +token_env = "OPENFANG_WEBHOOK_TOKEN" +max_payload_bytes = 65536 +rate_limit_per_minute = 30 diff --git a/deploy/kamd1/openfang.yaml b/deploy/kamd1/openfang.yaml new file mode 100644 index 0000000000..3078c3c614 --- /dev/null +++ b/deploy/kamd1/openfang.yaml @@ -0,0 +1,184 @@ +# openfang — kamd1 cluster (kubernetes-admin@kubernetes), namespace: openfang +# +# Serves https://openfang.pageapp.net. Captured from the live cluster. +# +# Placeholders (envsubst before apply): +# ${IMAGE_TAG} image tag, e.g. v0.1.3 +# ${OPENFANG_API_KEY} dashboard/API key -- see config.toml note below +# +# NOT included (create out-of-band, never commit): +# secret/openfang-secrets keys: NVIDIA_API_KEY, OLLAMA_API_KEY, +# OPENFANG_WEBHOOK_TOKEN, SCREENBUDDY_API_KEY, +# TELEGRAM_BOT_TOKEN +# secret/regcredpageapp harbor.pageapp.net pull creds +# secret/pageapp-net-tls TLS cert for *.pageapp.net +# +# Routing: the /hooks/ route is matched at a higher priority than the catch-all +# and intentionally carries no oauth2-proxy middleware, so webhook callbacks +# are authenticated by the application rather than by SSO. Everything else is +# routed through auth.pageapp.net. Do not reorder or drop the route priorities. +# +# NOTE: OPENFANG_API_KEY and OPENFANG_WEBHOOK_TOKEN must currently be set to +# the same value. Rotate them together, never independently. +# +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + annotations: + deployment.kubernetes.io/revision: '18' + field.cattle.io/publicEndpoints: '[{"addresses":["192.168.0.111"],"port":80,"protocol":"TCP","serviceName":"openfang:openfang","allNodes":false}]' + name: openfang + namespace: openfang +spec: + progressDeadlineSeconds: 600 + replicas: 1 + revisionHistoryLimit: 10 + selector: + matchLabels: + app: openfang + strategy: + type: Recreate + template: + metadata: + annotations: + kubectl.kubernetes.io/restartedAt: '2026-07-01T17:36:10Z' + labels: + app: openfang + spec: + containers: + - env: + - name: ENVIRONMENT_TYPE + value: PROD + - name: VERSION + value: v0.1.2 + - name: DOMAIN + value: pageapp + envFrom: + - secretRef: + name: openfang-secrets + image: harbor.pageapp.net/docker-registry/openfang:${IMAGE_TAG} + imagePullPolicy: IfNotPresent + name: openfang + ports: + - containerPort: 4200 + protocol: TCP + resources: {} + terminationMessagePath: /dev/termination-log + terminationMessagePolicy: File + volumeMounts: + - mountPath: /data + name: data + - mountPath: /data/config.toml + name: config + subPath: config.toml + dnsPolicy: ClusterFirst + imagePullSecrets: + - name: regcredpageapp + restartPolicy: Always + schedulerName: default-scheduler + securityContext: {} + terminationGracePeriodSeconds: 30 + volumes: + - name: data + persistentVolumeClaim: + claimName: openfang-data + - configMap: + defaultMode: 420 + name: openfang-config + name: config + +--- +apiVersion: v1 +kind: Service +metadata: + annotations: + field.cattle.io/publicEndpoints: '[{"addresses":["192.168.0.111"],"port":80,"protocol":"TCP","serviceName":"openfang:openfang","allNodes":false}]' + metallb.universe.tf/ip-allocated-from-pool: my-ip-pool + name: openfang + namespace: openfang +spec: + allocateLoadBalancerNodePorts: true + clusterIP: 10.100.242.228 + clusterIPs: + - 10.100.242.228 + externalTrafficPolicy: Cluster + internalTrafficPolicy: Cluster + ipFamilies: + - IPv4 + ipFamilyPolicy: SingleStack + ports: + - name: http + nodePort: 30203 + port: 80 + protocol: TCP + targetPort: 4200 + selector: + app: openfang + sessionAffinity: None + type: LoadBalancer + +--- +apiVersion: v1 +kind: PersistentVolumeClaim +metadata: + annotations: + pv.kubernetes.io/bind-completed: 'yes' + pv.kubernetes.io/bound-by-controller: 'yes' + volume.beta.kubernetes.io/storage-provisioner: driver.longhorn.io + volume.kubernetes.io/storage-provisioner: driver.longhorn.io + finalizers: + - kubernetes.io/pvc-protection + name: openfang-data + namespace: openfang +spec: + accessModes: + - ReadWriteOnce + resources: + requests: + storage: 10Gi + storageClassName: longhorn + volumeMode: Filesystem + volumeName: pvc-7b848ce6-474d-4b5d-bace-34d787716a77 + +--- +apiVersion: traefik.containo.us/v1alpha1 +kind: IngressRoute +metadata: + annotations: + kubernetes.io/ingress.class: traefik-external + name: openfang-ingress + namespace: openfang +spec: + entryPoints: + - web + - websecure + routes: + - kind: Rule + match: Host(`openfang.pageapp.net`) && PathPrefix(`/hooks/`) + priority: 100 + services: + - name: openfang + port: 80 + - kind: Rule + match: Host(`openfang.pageapp.net`) + middlewares: + - name: oauth2-proxy-middleware + priority: 10 + services: + - name: openfang + port: 80 + tls: + secretName: pageapp-net-tls + +--- +apiVersion: traefik.containo.us/v1alpha1 +kind: Middleware +metadata: + annotations: {} + name: oauth2-proxy-middleware + namespace: openfang +spec: + forwardAuth: + address: https://auth.pageapp.net/?rd=https%3A%2F%2Fopenfang.pageapp.net + trustForwardHeader: true From 438de7eff36692625ea421205611765df2e2d264 Mon Sep 17 00:00:00 2001 From: Nideesh Terapalli <nterapalli@gmail.com> Date: Wed, 15 Jul 2026 23:03:00 +0000 Subject: [PATCH 29/31] docs(deploy): strip controller-written state from kamd1 manifests The captured manifests carried fields written by controllers rather than by an operator, which made them describe one bound instance instead of desired state. Reapplying to an empty namespace would have failed. Removed: - PVC volumeName, pinned to an existing PV that exists only on kamd1 - PVC pvc-protection finalizer and bind-completed/bound-by-controller annotations - Service clusterIP/clusterIPs and the assigned nodePort, plus ipFamilies, ipFamilyPolicy and other cluster-assigned defaults - deployment.kubernetes.io/revision, kubectl restartedAt, and Rancher field.cattle.io/publicEndpoints and metallb pool annotations Also templated the VERSION env var to ${IMAGE_TAG}. It was pinned at v0.1.2 while the image ran v0.1.3, so it was reporting a stale version. Header now records the two known gaps in the live deployment left as-is: the deprecated traefik.containo.us/v1alpha1 API group, and the empty pod securityContext with no container resource limits. Verified structurally against the parsed YAML rather than by grep: all five documents load and no controller-managed field remains. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --- deploy/kamd1/openfang.yaml | 46 ++++++++++++-------------------------- 1 file changed, 14 insertions(+), 32 deletions(-) diff --git a/deploy/kamd1/openfang.yaml b/deploy/kamd1/openfang.yaml index 3078c3c614..1e2904b8ed 100644 --- a/deploy/kamd1/openfang.yaml +++ b/deploy/kamd1/openfang.yaml @@ -1,17 +1,21 @@ -# openfang — kamd1 cluster (kubernetes-admin@kubernetes), namespace: openfang +# openfang — kamd1 cluster, namespace: openfang # -# Serves https://openfang.pageapp.net. Captured from the live cluster. +# Serves https://openfang.pageapp.net. Captured from the live cluster with +# controller-written state stripped (finalizers, PVC volumeName, assigned +# clusterIP/nodePort, revision and Rancher endpoint annotations) so this +# applies cleanly to an empty namespace. # # Placeholders (envsubst before apply): # ${IMAGE_TAG} image tag, e.g. v0.1.3 -# ${OPENFANG_API_KEY} dashboard/API key -- see config.toml note below +# ${OPENFANG_API_KEY} substituted into config.template.toml # # NOT included (create out-of-band, never commit): # secret/openfang-secrets keys: NVIDIA_API_KEY, OLLAMA_API_KEY, # OPENFANG_WEBHOOK_TOKEN, SCREENBUDDY_API_KEY, # TELEGRAM_BOT_TOKEN -# secret/regcredpageapp harbor.pageapp.net pull creds -# secret/pageapp-net-tls TLS cert for *.pageapp.net +# secret/regcredpageapp harbor.pageapp.net pull creds +# secret/pageapp-net-tls TLS cert for *.pageapp.net +# configmap/openfang-config from config.template.toml # # Routing: the /hooks/ route is matched at a higher priority than the catch-all # and intentionally carries no oauth2-proxy middleware, so webhook callbacks @@ -21,13 +25,14 @@ # NOTE: OPENFANG_API_KEY and OPENFANG_WEBHOOK_TOKEN must currently be set to # the same value. Rotate them together, never independently. # +# Known gaps in the live deployment, captured as-is: +# - IngressRoute uses the deprecated traefik.containo.us/v1alpha1 API group. +# - Pod securityContext is empty and the container sets no resource limits. +# --- apiVersion: apps/v1 kind: Deployment metadata: - annotations: - deployment.kubernetes.io/revision: '18' - field.cattle.io/publicEndpoints: '[{"addresses":["192.168.0.111"],"port":80,"protocol":"TCP","serviceName":"openfang:openfang","allNodes":false}]' name: openfang namespace: openfang spec: @@ -41,8 +46,6 @@ spec: type: Recreate template: metadata: - annotations: - kubectl.kubernetes.io/restartedAt: '2026-07-01T17:36:10Z' labels: app: openfang spec: @@ -51,7 +54,7 @@ spec: - name: ENVIRONMENT_TYPE value: PROD - name: VERSION - value: v0.1.2 + value: ${IMAGE_TAG} - name: DOMAIN value: pageapp envFrom: @@ -92,24 +95,12 @@ spec: apiVersion: v1 kind: Service metadata: - annotations: - field.cattle.io/publicEndpoints: '[{"addresses":["192.168.0.111"],"port":80,"protocol":"TCP","serviceName":"openfang:openfang","allNodes":false}]' - metallb.universe.tf/ip-allocated-from-pool: my-ip-pool name: openfang namespace: openfang spec: - allocateLoadBalancerNodePorts: true - clusterIP: 10.100.242.228 - clusterIPs: - - 10.100.242.228 externalTrafficPolicy: Cluster - internalTrafficPolicy: Cluster - ipFamilies: - - IPv4 - ipFamilyPolicy: SingleStack ports: - name: http - nodePort: 30203 port: 80 protocol: TCP targetPort: 4200 @@ -122,13 +113,6 @@ spec: apiVersion: v1 kind: PersistentVolumeClaim metadata: - annotations: - pv.kubernetes.io/bind-completed: 'yes' - pv.kubernetes.io/bound-by-controller: 'yes' - volume.beta.kubernetes.io/storage-provisioner: driver.longhorn.io - volume.kubernetes.io/storage-provisioner: driver.longhorn.io - finalizers: - - kubernetes.io/pvc-protection name: openfang-data namespace: openfang spec: @@ -139,7 +123,6 @@ spec: storage: 10Gi storageClassName: longhorn volumeMode: Filesystem - volumeName: pvc-7b848ce6-474d-4b5d-bace-34d787716a77 --- apiVersion: traefik.containo.us/v1alpha1 @@ -175,7 +158,6 @@ spec: apiVersion: traefik.containo.us/v1alpha1 kind: Middleware metadata: - annotations: {} name: oauth2-proxy-middleware namespace: openfang spec: From fbab8e14d7bd94e4cda64c25501c8ab668571ba3 Mon Sep 17 00:00:00 2001 From: Nideesh Terapalli <nterapalli@gmail.com> Date: Wed, 15 Jul 2026 23:13:28 +0000 Subject: [PATCH 30/31] refactor(deploy): restructure kamd1 manifest to match house style Match the layout used by the other pageapp deployments: self-contained from Namespace down, regcred${DOMAIN} inline with ${DOCKER_CONFIG_JSON}, and every environment variable declared explicitly on the pod rather than bulk-imported. Replaces envFrom: secretRef: openfang-secrets, which pulled the whole secret in blind, with one env entry per variable. Each is now visible in the manifest next to a comment explaining what it is for. Values stay as ${PLACEHOLDER} rather than literals because this branch is public. The deploy UI substitutes them the same way it does for ${DOCKER_CONFIG_JSON} elsewhere. Also added, all absent from the earlier capture: - Namespace and PVC, so the file stands alone - POD_NAME/POD_NAMESPACE downward-API vars - strategy: Recreate, documented: the PVC is RWO and SQLite is single-writer, so two pods must never hold /data at once - imagePullPolicy: Always, matching the other deployments Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --- deploy/kamd1/openfang.yaml | 245 ++++++++++++++++++++----------------- 1 file changed, 134 insertions(+), 111 deletions(-) diff --git a/deploy/kamd1/openfang.yaml b/deploy/kamd1/openfang.yaml index 1e2904b8ed..48f251536d 100644 --- a/deploy/kamd1/openfang.yaml +++ b/deploy/kamd1/openfang.yaml @@ -1,34 +1,57 @@ -# openfang — kamd1 cluster, namespace: openfang +# openfang — kamd1, https://openfang.pageapp.net # -# Serves https://openfang.pageapp.net. Captured from the live cluster with -# controller-written state stripped (finalizers, PVC volumeName, assigned -# clusterIP/nodePort, revision and Rancher endpoint annotations) so this -# applies cleanly to an empty namespace. +# Self-contained: namespace, regcred, config, deployment, service, routing. +# Substitute placeholders at deploy time (envsubst or the deploy UI): # -# Placeholders (envsubst before apply): -# ${IMAGE_TAG} image tag, e.g. v0.1.3 -# ${OPENFANG_API_KEY} substituted into config.template.toml +# ${DOMAIN} e.g. pageapp +# ${IMAGE_TAG} e.g. v0.1.3 +# ${DOCKER_CONFIG_JSON} base64 dockerconfigjson for harbor.pageapp.net +# ${CLUSTER_NAME} +# ${NVIDIA_API_KEY} +# ${OLLAMA_API_KEY} +# ${TELEGRAM_BOT_TOKEN} +# ${SCREENBUDDY_API_KEY} desktop<->backend service key (x-api-key) +# ${OPENFANG_API_KEY} dashboard/API key +# ${OPENFANG_WEBHOOK_TOKEN} # -# NOT included (create out-of-band, never commit): -# secret/openfang-secrets keys: NVIDIA_API_KEY, OLLAMA_API_KEY, -# OPENFANG_WEBHOOK_TOKEN, SCREENBUDDY_API_KEY, -# TELEGRAM_BOT_TOKEN -# secret/regcredpageapp harbor.pageapp.net pull creds -# secret/pageapp-net-tls TLS cert for *.pageapp.net -# configmap/openfang-config from config.template.toml +# NOTE: OPENFANG_WEBHOOK_TOKEN must currently equal OPENFANG_API_KEY, and the +# ScreenBuddy backend's API_KEY must match it too — the same value authenticates +# the callback at both layers. Rotate all three together, never independently. # -# Routing: the /hooks/ route is matched at a higher priority than the catch-all -# and intentionally carries no oauth2-proxy middleware, so webhook callbacks -# are authenticated by the application rather than by SSO. Everything else is -# routed through auth.pageapp.net. Do not reorder or drop the route priorities. -# -# NOTE: OPENFANG_API_KEY and OPENFANG_WEBHOOK_TOKEN must currently be set to -# the same value. Rotate them together, never independently. -# -# Known gaps in the live deployment, captured as-is: -# - IngressRoute uses the deprecated traefik.containo.us/v1alpha1 API group. -# - Pod securityContext is empty and the container sets no resource limits. +# secret/pageapp-net-tls (TLS for *.pageapp.net) is expected to already exist. # +# Routing: the /hooks/ route is priority 100 and carries no oauth2-proxy +# middleware, so ScreenBuddy callbacks reach the pod with their Authorization +# header intact. forwardAuth would redirect them to SSO and break them. +# Everything else is priority 10 behind auth.pageapp.net. Do not reorder. +--- +apiVersion: v1 +kind: Namespace +metadata: + name: openfang +--- +apiVersion: v1 +kind: Secret +metadata: + name: regcred${DOMAIN} + namespace: openfang +type: kubernetes.io/dockerconfigjson +data: + .dockerconfigjson: ${DOCKER_CONFIG_JSON} +--- +apiVersion: v1 +kind: PersistentVolumeClaim +metadata: + name: openfang-data + namespace: openfang +spec: + accessModes: + - ReadWriteOnce + resources: + requests: + storage: 10Gi + storageClassName: longhorn + volumeMode: Filesystem --- apiVersion: apps/v1 kind: Deployment @@ -36,61 +59,78 @@ metadata: name: openfang namespace: openfang spec: - progressDeadlineSeconds: 600 replicas: 1 - revisionHistoryLimit: 10 + # Recreate, not RollingUpdate: the PVC is RWO and SQLite is single-writer, + # so two pods must never hold /data at once. + strategy: + type: Recreate selector: matchLabels: app: openfang - strategy: - type: Recreate template: metadata: labels: app: openfang spec: containers: - - env: - - name: ENVIRONMENT_TYPE - value: PROD - - name: VERSION - value: ${IMAGE_TAG} - - name: DOMAIN - value: pageapp - envFrom: - - secretRef: - name: openfang-secrets - image: harbor.pageapp.net/docker-registry/openfang:${IMAGE_TAG} - imagePullPolicy: IfNotPresent - name: openfang - ports: - - containerPort: 4200 - protocol: TCP - resources: {} - terminationMessagePath: /dev/termination-log - terminationMessagePolicy: File - volumeMounts: - - mountPath: /data - name: data - - mountPath: /data/config.toml - name: config - subPath: config.toml - dnsPolicy: ClusterFirst + - name: openfang + # Harbor by HOSTNAME, not IP (the SHARED_CICD pull-failure fix). + image: harbor.pageapp.net/docker-registry/openfang:${IMAGE_TAG} + imagePullPolicy: Always + ports: + - containerPort: 4200 + env: + - name: ENVIRONMENT_TYPE + value: "PROD" + - name: POD_NAME + valueFrom: + fieldRef: + fieldPath: metadata.name + - name: POD_NAMESPACE + valueFrom: + fieldRef: + fieldPath: metadata.namespace + - name: CLUSTER_NAME + value: ${CLUSTER_NAME} + - name: DOMAIN + value: ${DOMAIN} + - name: VERSION + value: ${IMAGE_TAG} + + # --- LLM providers --- + - name: NVIDIA_API_KEY + value: "${NVIDIA_API_KEY}" + - name: OLLAMA_API_KEY + value: "${OLLAMA_API_KEY}" + + # --- Telegram channel (long-poll; no inbound route needed) --- + - name: TELEGRAM_BOT_TOKEN + value: "${TELEGRAM_BOT_TOKEN}" + + # --- secure_fetch: value read by NAME in Rust, never seen by the LLM. + # Pinned to ai-screen-buddy-backend.pageapp.net in config.toml. + - name: SCREENBUDDY_API_KEY + value: "${SCREENBUDDY_API_KEY}" + + # --- inbound: ScreenBuddy calls POST /hooks/agent with this bearer. + # Must equal OPENFANG_API_KEY (see NOTE at top). + - name: OPENFANG_WEBHOOK_TOKEN + value: "${OPENFANG_WEBHOOK_TOKEN}" + volumeMounts: + - name: data + mountPath: /data + - name: config + mountPath: /data/config.toml + subPath: config.toml imagePullSecrets: - - name: regcredpageapp - restartPolicy: Always - schedulerName: default-scheduler - securityContext: {} - terminationGracePeriodSeconds: 30 + - name: regcred${DOMAIN} volumes: - - name: data - persistentVolumeClaim: - claimName: openfang-data - - configMap: - defaultMode: 420 - name: openfang-config - name: config - + - name: data + persistentVolumeClaim: + claimName: openfang-data + - name: config + configMap: + name: openfang-config --- apiVersion: v1 kind: Service @@ -98,62 +138,45 @@ metadata: name: openfang namespace: openfang spec: - externalTrafficPolicy: Cluster + type: LoadBalancer ports: - - name: http - port: 80 - protocol: TCP - targetPort: 4200 + - name: http + port: 80 + targetPort: 4200 + protocol: TCP selector: app: openfang - sessionAffinity: None - type: LoadBalancer - ---- -apiVersion: v1 -kind: PersistentVolumeClaim -metadata: - name: openfang-data - namespace: openfang -spec: - accessModes: - - ReadWriteOnce - resources: - requests: - storage: 10Gi - storageClassName: longhorn - volumeMode: Filesystem - --- apiVersion: traefik.containo.us/v1alpha1 kind: IngressRoute metadata: - annotations: - kubernetes.io/ingress.class: traefik-external name: openfang-ingress namespace: openfang + annotations: + kubernetes.io/ingress.class: traefik-external spec: entryPoints: - - web - - websecure + - web + - websecure routes: - - kind: Rule - match: Host(`openfang.pageapp.net`) && PathPrefix(`/hooks/`) - priority: 100 - services: - - name: openfang - port: 80 - - kind: Rule - match: Host(`openfang.pageapp.net`) - middlewares: - - name: oauth2-proxy-middleware - priority: 10 - services: - - name: openfang - port: 80 + # Priority 100, no middleware: ScreenBuddy webhook callbacks authenticate + # in-app via their own bearer token, so they must skip edge SSO. + - match: Host(`openfang.pageapp.net`) && PathPrefix(`/hooks/`) + kind: Rule + priority: 100 + services: + - name: openfang + port: 80 + - match: Host(`openfang.pageapp.net`) + kind: Rule + priority: 10 + middlewares: + - name: oauth2-proxy-middleware + services: + - name: openfang + port: 80 tls: secretName: pageapp-net-tls - --- apiVersion: traefik.containo.us/v1alpha1 kind: Middleware From d2aee4b83d27a6e6dea7318c305eea0495d5f02d Mon Sep 17 00:00:00 2001 From: Nideesh Terapalli <nterapalli@gmail.com> Date: Wed, 15 Jul 2026 23:29:29 +0000 Subject: [PATCH 31/31] deploy(797th): add manifests for openfang.797th.com Adds deploy/797th/ alongside the existing kamd1 and rancher targets. Same house layout: self-contained from Namespace down, regcred${DOMAIN} inline, one explicit env entry per variable, values left as ${PLACEHOLDER} for the deploy UI to substitute. Differs from deploy/kamd1/ only in domain: harbor.797th.com for the image, Host(`openfang.797th.com`) on both routes, 797th-com-tls, and forwardAuth against auth.797th.com. The /hooks/ route keeps priority 100 with no oauth2-proxy middleware. ScreenBuddy callbacks authenticate in-app with their own bearer token, so edge SSO would redirect them and break the callback path. Unverified against the target cluster, since it is not reachable from here: the registry host, the 797th-com-tls secret name, the presence of an oauth2-proxy at auth.797th.com, the longhorn storageclass, and the traefik-external ingress class are all assumed to match kamd1 conventions. Confirm before the first apply. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --- deploy/797th/config.template.toml | 50 ++++++++ deploy/797th/openfang.yaml | 191 ++++++++++++++++++++++++++++++ 2 files changed, 241 insertions(+) create mode 100644 deploy/797th/config.template.toml create mode 100644 deploy/797th/openfang.yaml diff --git a/deploy/797th/config.template.toml b/deploy/797th/config.template.toml new file mode 100644 index 0000000000..d8e4cbe497 --- /dev/null +++ b/deploy/797th/config.template.toml @@ -0,0 +1,50 @@ +api_key = "${OPENFANG_API_KEY}" +api_listen = "0.0.0.0:4200" +usage_footer = "off" + +[channels.telegram] +allowed_users = [] +bot_token_env = "TELEGRAM_BOT_TOKEN" +default_agent = "nideesh-agent" +poll_interval_secs = 1 + +[default_model] +api_key_env = "NVIDIA_API_KEY" +base_url = "https://integrate.api.nvidia.com/v1" +model = "stepfun-ai/step-3.5-flash" +provider = "nvidia" + +[memory] +decay_rate = 0.05 +embedding_model = "nomic-embed-text:latest" +embedding_provider = "ollama" + +[provider_urls] +nvidia = "https://integrate.api.nvidia.com/v1" +ollama = "http://ollama-external.ollama.svc.cluster.local:11434/v1" + +[web] +cache_ttl_minutes = 15 +search_provider = "auto" + +[web.fetch] +ssrf_allowed_hosts = ["127.0.0.1", "localhost", "192.168.0.0/16", "ai-screen-buddy-backend.pageapp.net"] + +# secure_fetch: lets an agent attach secret HTTP headers whose values are read +# from environment variables BY NAME in Rust — the LLM never sees the secret. +# Fail-closed: an env var must be listed in allowed_secrets to be usable. +# secret_hosts pins each secret so its value is only ever sent to those hosts. +[web.fetch.secure_fetch] +allowed_secrets = ["SCREENBUDDY_API_KEY"] + +[web.fetch.secure_fetch.secret_hosts] +SCREENBUDDY_API_KEY = ["ai-screen-buddy-backend.pageapp.net"] + +# webhook_triggers: enables POST /hooks/agent (and /hooks/wake) so an external +# service (ScreenBuddy) can wake an agent on job completion. Bearer token is +# read from the env var named by token_env (value lives in openfang-secrets). +[webhook_triggers] +enabled = true +token_env = "OPENFANG_WEBHOOK_TOKEN" +max_payload_bytes = 65536 +rate_limit_per_minute = 30 diff --git a/deploy/797th/openfang.yaml b/deploy/797th/openfang.yaml new file mode 100644 index 0000000000..cfa9d5904f --- /dev/null +++ b/deploy/797th/openfang.yaml @@ -0,0 +1,191 @@ +# openfang — https://openfang.797th.com +# +# Self-contained: namespace, regcred, deployment, service, routing. +# Substitute placeholders at deploy time (envsubst or the deploy UI): +# +# ${DOMAIN} e.g. 797th +# ${IMAGE_TAG} e.g. v0.1.3 +# ${DOCKER_CONFIG_JSON} base64 dockerconfigjson for the registry +# ${CLUSTER_NAME} +# ${NVIDIA_API_KEY} +# ${OLLAMA_API_KEY} +# ${TELEGRAM_BOT_TOKEN} +# ${SCREENBUDDY_API_KEY} desktop to backend service key (x-api-key) +# ${OPENFANG_API_KEY} dashboard/API key +# ${OPENFANG_WEBHOOK_TOKEN} +# +# NOTE: OPENFANG_WEBHOOK_TOKEN must currently equal OPENFANG_API_KEY, and the +# API_KEY on the ScreenBuddy backend must match it too. The same value +# authenticates the callback at both layers. Rotate all three together. +# +# secret/797th-com-tls (TLS for *.797th.com) is expected to already exist. +# configmap/openfang-config must be created from config.template.toml before +# this applies; the pod will not boot without /data/config.toml. +# +# Routing: the /hooks/ route is priority 100 and carries no oauth2-proxy +# middleware, so ScreenBuddy callbacks reach the pod with the Authorization +# header intact. forwardAuth would redirect them to SSO and break them. +# Everything else is priority 10 behind auth.797th.com. Do not reorder. +--- +apiVersion: v1 +kind: Namespace +metadata: + name: openfang +--- +apiVersion: v1 +kind: Secret +metadata: + name: regcred${DOMAIN} + namespace: openfang +type: kubernetes.io/dockerconfigjson +data: + .dockerconfigjson: ${DOCKER_CONFIG_JSON} +--- +apiVersion: v1 +kind: PersistentVolumeClaim +metadata: + name: openfang-data + namespace: openfang +spec: + accessModes: + - ReadWriteOnce + resources: + requests: + storage: 10Gi + storageClassName: longhorn + volumeMode: Filesystem +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: openfang + namespace: openfang +spec: + replicas: 1 + # Recreate, not RollingUpdate: the PVC is RWO and SQLite is single-writer, + # so two pods must never hold /data at once. + strategy: + type: Recreate + selector: + matchLabels: + app: openfang + template: + metadata: + labels: + app: openfang + spec: + containers: + - name: openfang + # Harbor by HOSTNAME, not IP (the SHARED_CICD pull-failure fix). + image: harbor.797th.com/docker-registry/openfang:${IMAGE_TAG} + imagePullPolicy: Always + ports: + - containerPort: 4200 + env: + - name: ENVIRONMENT_TYPE + value: "PROD" + - name: POD_NAME + valueFrom: + fieldRef: + fieldPath: metadata.name + - name: POD_NAMESPACE + valueFrom: + fieldRef: + fieldPath: metadata.namespace + - name: CLUSTER_NAME + value: ${CLUSTER_NAME} + - name: DOMAIN + value: ${DOMAIN} + - name: VERSION + value: ${IMAGE_TAG} + + # --- LLM providers --- + - name: NVIDIA_API_KEY + value: "${NVIDIA_API_KEY}" + - name: OLLAMA_API_KEY + value: "${OLLAMA_API_KEY}" + + # --- Telegram channel (long-poll; no inbound route needed) --- + - name: TELEGRAM_BOT_TOKEN + value: "${TELEGRAM_BOT_TOKEN}" + + # --- secure_fetch: value read by NAME in Rust, never seen by the + # LLM. Host pinning for this secret is set in config.toml. + - name: SCREENBUDDY_API_KEY + value: "${SCREENBUDDY_API_KEY}" + + # --- inbound: ScreenBuddy calls POST /hooks/agent with this + # bearer. Must equal OPENFANG_API_KEY (see NOTE at top). + - name: OPENFANG_WEBHOOK_TOKEN + value: "${OPENFANG_WEBHOOK_TOKEN}" + volumeMounts: + - name: data + mountPath: /data + - name: config + mountPath: /data/config.toml + subPath: config.toml + imagePullSecrets: + - name: regcred${DOMAIN} + volumes: + - name: data + persistentVolumeClaim: + claimName: openfang-data + - name: config + configMap: + name: openfang-config +--- +apiVersion: v1 +kind: Service +metadata: + name: openfang + namespace: openfang +spec: + type: LoadBalancer + ports: + - name: http + port: 80 + targetPort: 4200 + protocol: TCP + selector: + app: openfang +--- +apiVersion: traefik.containo.us/v1alpha1 +kind: IngressRoute +metadata: + name: openfang-ingress + namespace: openfang + annotations: + kubernetes.io/ingress.class: traefik-external +spec: + entryPoints: + - web + - websecure + routes: + # Priority 100, no middleware: ScreenBuddy webhook callbacks authenticate + # in-app via their own bearer token, so they must skip edge SSO. + - match: Host(`openfang.797th.com`) && PathPrefix(`/hooks/`) + kind: Rule + priority: 100 + services: + - name: openfang + port: 80 + - match: Host(`openfang.797th.com`) + kind: Rule + priority: 10 + middlewares: + - name: oauth2-proxy-middleware + services: + - name: openfang + port: 80 + tls: + secretName: 797th-com-tls +--- +apiVersion: traefik.containo.us/v1alpha1 +kind: Middleware +metadata: + name: oauth2-proxy-middleware + namespace: openfang +spec: + forwardAuth: + address: https://auth.797th.com/?rd=https%3A%2F%2Fopenfang.797th.com + trustForwardHeader: true