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/.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 diff --git a/.gitignore b/.gitignore index 317e7b71c5..a0134af995 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,7 @@ # Build /target +/target-clippy +/target-* **/*.rs.bk *.pdb @@ -48,3 +50,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 diff --git a/Cargo.lock b/Cargo.lock index e880570247..865e86e99f 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.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -150,7 +150,7 @@ checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" dependencies = [ "anstyle", "once_cell_polyfill", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[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", @@ -4399,7 +4399,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967" dependencies = [ "libc", - "windows-sys 0.45.0", + "windows-sys 0.59.0", ] [[package]] @@ -5114,9 +5114,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", @@ -5145,7 +5145,7 @@ dependencies = [ "once_cell", "socket2 0.6.3", "tracing", - "windows-sys 0.60.2", + "windows-sys 0.59.0", ] [[package]] @@ -5582,9 +5582,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", @@ -6314,7 +6314,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e" dependencies = [ "libc", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -7622,7 +7622,7 @@ checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e" dependencies = [ "memoffset", "tempfile", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] diff --git a/Dockerfile b/Dockerfile index 1174e95085..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,10 +29,28 @@ 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/ -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 +# 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 -ENTRYPOINT ["openfang"] +ENV OPENFANG_HOME=/data \ + HOME=/data +USER 1000 +ENTRYPOINT ["/usr/local/bin/openfang-entrypoint.sh"] CMD ["start"] diff --git a/README.md b/README.md index afffa314c3..e00e10107f 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

diff --git a/agents/Dewansh/agent.toml b/agents/Dewansh/agent.toml new file mode 100644 index 0000000000..be9ea677ee --- /dev/null +++ b/agents/Dewansh/agent.toml @@ -0,0 +1,36 @@ + + +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"] +network = [] 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 = [] diff --git a/crates/openfang-api/src/channel_bridge.rs b/crates/openfang-api/src/channel_bridge.rs index 37ad72921f..e0df6fedb2 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, @@ -153,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(); @@ -1161,6 +1187,10 @@ 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 { @@ -1173,6 +1203,13 @@ pub async fn start_channel_bridge_with_config( tg_config.api_url.clone(), tg_config.thread_routes.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())); } } @@ -1779,7 +1816,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-api/src/routes.rs b/crates/openfang-api/src/routes.rs index cebb1f599a..7b569dcd78 100644 --- a/crates/openfang-api/src/routes.rs +++ b/crates/openfang-api/src/routes.rs @@ -7093,6 +7093,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 2043aeaa76..f40545e549 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()`. @@ -131,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, @@ -891,6 +910,7 @@ async fn dispatch_message( handle, router, &message.sender, + ct_str, sender_user_id(message), ) .await; @@ -1102,6 +1122,7 @@ async fn dispatch_message( handle, router, &message.sender, + ct_str, sender_user_id(message), ) .await; @@ -1150,8 +1171,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) })); } @@ -1168,7 +1191,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(*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}")), } @@ -1330,8 +1361,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(); @@ -1937,6 +1975,7 @@ async fn handle_command( handle: &Arc, router: &Arc, sender: &ChannelUser, + channel_name: &str, user_id: &str, ) -> String { // Canonicalise through the unified command registry: aliases resolve to @@ -1947,6 +1986,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(); @@ -1991,10 +2056,13 @@ 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. + // 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; format!("Now talking to agent: {agent_name}") } Ok(None) => { @@ -2002,6 +2070,9 @@ async fn handle_command( match handle.spawn_agent_by_name(agent_name).await { Ok(agent_id) => { router.set_user_default(user_id.to_string(), agent_id); + handle + .set_delivery_context(agent_id, channel_name, &sender.platform_id) + .await; format!("Spawned and connected to agent: {agent_name}") } Err(e) => { @@ -2265,10 +2336,11 @@ mod tests { openfang_user: None, }; - let result = handle_command("agents", &[], &handle, &router, &sender, "user1").await; + let result = + handle_command("agents", &[], &handle, &router, &sender, "test", "user1").await; assert!(result.contains("coder")); - let result = handle_command("help", &[], &handle, &router, &sender, "user1").await; + let result = handle_command("help", &[], &handle, &router, &sender, "test", "user1").await; assert!(result.contains("/agents")); } @@ -2285,13 +2357,15 @@ 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()], &handle, &router, &sender, + "test", "user1", ) .await; @@ -2327,6 +2401,7 @@ mod tests { &handle, &router, &sender, + "discord", user_id, ) .await; @@ -2357,7 +2432,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", "user1").await; assert!(result.contains("Usage: /agent ")); assert!(result.contains("coder")); } diff --git a/crates/openfang-channels/src/router.rs b/crates/openfang-channels/src/router.rs index 8bc141dcf9..e6cc00f495 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,45 @@ 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-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 8f59414c97..b7f3e0dbb5 100644 --- a/crates/openfang-kernel/src/kernel.rs +++ b/crates/openfang-kernel/src/kernel.rs @@ -1462,85 +1462,74 @@ impl OpenFangKernel { } } - // Issue #1140: auto-spawn agents from `~/.openfang/agents//agent.toml` - // that are present on disk but not yet in the registry. Without this, - // user-placed agent dirs never appear in `GET /api/agents` (and thus - // the chat tab's dropdown) until they are explicitly spawned via API - // or CLI. We scan the agents directory and call `spawn_agent` for any - // valid manifest whose name is not already registered (idempotent). - { - let agents_dir = kernel.config.home_dir.join("agents"); - if agents_dir.is_dir() { - let mut auto_spawned = 0usize; - if let Ok(entries) = std::fs::read_dir(&agents_dir) { - for entry in entries.flatten() { - let dir_path = entry.path(); - if !dir_path.is_dir() { - continue; - } - let toml_path = dir_path.join("agent.toml"); - if !toml_path.exists() { - continue; - } - let dir_name = match dir_path.file_name() { - Some(n) => n.to_string_lossy().to_string(), - None => continue, - }; - // Skip if an agent with this name already exists in the - // registry (was restored from DB or already spawned). - if kernel.registry.find_by_name(&dir_name).is_some() { - continue; - } - let toml_str = match std::fs::read_to_string(&toml_path) { - Ok(s) => s, + // Seed agents defined on disk into the registry. + // + // Two needs are served here: + // 1. Baked agents (seeded into /agents//agent.toml by the + // container entrypoint) must register on the FIRST boot of a fresh + // data volume (797th container/PVC deploy). The DB-restore loop + // above only *updates* agents already in the DB from their on-disk + // TOML; it never creates new ones. + // 2. User-placed agent dirs should appear in `GET /api/agents` (and + // the chat dropdown) without being explicitly spawned via API/CLI + // (upstream Issue #1140), even on an already-populated install. + // + // Both are satisfied by a ONE-TIME, marker-gated import of every on-disk + // manifest whose name is not already registered (the `existing` set, + // seeded from the current registry, makes the import idempotent per + // name). "One-time" is enforced by a marker file persisted next to the + // data, NOT by an empty registry: gating on emptiness alone — or + // re-importing every boot as a naive per-boot auto-spawn would — + // resurrects baked agents whenever an operator deletes them via the API + // but leaves the on-disk TOML in place. The marker lives on the data + // volume, so once seeding has run those deletions stay deleted. + let agents_dir = kernel.config.home_dir.join("agents"); + let seed_marker = kernel.config.home_dir.join(".agents_seeded"); + if !seed_marker.exists() && agents_dir.is_dir() { + if let Ok(dir_entries) = std::fs::read_dir(&agents_dir) { + let mut 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) => { - tracing::warn!( - agent = %dir_name, - path = %toml_path.display(), - "Failed to read agent.toml: {e}" - ); + warn!(path = %toml_path.display(), "Invalid agent TOML on disk, skipping import: {e}"); continue; } - }; - let mut manifest: openfang_types::agent::AgentManifest = - match toml::from_str(&toml_str) { - Ok(m) => m, - Err(e) => { - tracing::warn!( - agent = %dir_name, - path = %toml_path.display(), - "Invalid agent.toml, skipping auto-spawn: {e}" - ); - continue; - } - }; - // Prefer the directory name as the canonical agent name - // so the dashboard and CLI stay consistent with the - // on-disk layout, even if the manifest's `name` field - // disagrees. - if manifest.name.is_empty() { - manifest.name = dir_name.clone(); - } - match kernel.spawn_agent(manifest) { - Ok(id) => { - auto_spawned += 1; - info!( - agent = %dir_name, - id = %id, - "Auto-spawned agent from ~/.openfang/agents" - ); - } - Err(e) => { - tracing::warn!( - agent = %dir_name, - "Failed to auto-spawn agent from disk: {e}" - ); - } + }, + Err(e) => { + warn!(path = %toml_path.display(), "Failed to read agent TOML, skipping import: {e}"); + continue; } + }; + // 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)"); + 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 auto_spawned > 0 { - info!("Auto-spawned {auto_spawned} agent(s) from ~/.openfang/agents"); + // 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}"); } } } @@ -1831,6 +1820,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, @@ -2270,7 +2276,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. @@ -2378,6 +2384,7 @@ impl OpenFangKernel { ctx_window, Some(&kernel_clone.process_manager), content_blocks, + sender_id, ) .await; @@ -2853,7 +2860,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| { @@ -2969,6 +2976,7 @@ impl OpenFangKernel { ctx_window, Some(&self.process_manager), content_blocks, + sender_id, ) .await .map_err(KernelError::OpenFang)?; @@ -3831,10 +3839,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() @@ -6602,6 +6610,27 @@ 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(); @@ -7651,6 +7680,20 @@ 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-runtime/src/agent_loop.rs b/crates/openfang-runtime/src/agent_loop.rs index 615fbfdb73..6c6d207d77 100644 --- a/crates/openfang-runtime/src/agent_loop.rs +++ b/crates/openfang-runtime/src/agent_loop.rs @@ -312,6 +312,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"); @@ -948,6 +949,7 @@ pub async fn run_agent_loop( tts_engine, docker_config, process_manager, + sender_id.as_deref(), ); let result = match timeout_opt { Some(timeout) => { @@ -1540,6 +1542,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"); @@ -2157,6 +2160,7 @@ pub async fn run_agent_loop_streaming( tts_engine, docker_config, process_manager, + sender_id.as_deref(), ); let result = match timeout_opt { Some(timeout) => { @@ -3821,6 +3825,7 @@ mod tests { None, // context_window_tokens None, // process_manager None, // user_content_blocks + None, // sender_id ) .await .expect("Loop should complete without error"); @@ -3874,6 +3879,7 @@ mod tests { None, // context_window_tokens None, // process_manager None, // user_content_blocks + None, // sender_id ) .await .expect("Loop should complete without error"); @@ -3929,6 +3935,7 @@ mod tests { None, // context_window_tokens None, // process_manager None, // user_content_blocks + None, // sender_id ) .await .expect("Loop should complete without error"); @@ -3982,6 +3989,7 @@ mod tests { None, // context_window_tokens None, // process_manager None, // user_content_blocks + None, // sender_id ) .await .expect("Loop should complete without error"); @@ -4028,6 +4036,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"); @@ -4152,6 +4161,7 @@ mod tests { None, // context_window_tokens None, // process_manager None, // user_content_blocks + None, // sender_id ) .await .expect("Loop should recover via retry"); @@ -4199,6 +4209,7 @@ mod tests { None, // context_window_tokens None, // process_manager None, // user_content_blocks + None, // sender_id ) .await .expect("Loop should complete with fallback"); @@ -4254,6 +4265,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"); @@ -5230,6 +5242,7 @@ mod tests { None, // context_window_tokens None, // process_manager None, // user_content_blocks + None, // sender_id ) .await .expect("Agent loop should complete"); @@ -5300,6 +5313,7 @@ mod tests { None, None, None, + None, // sender_id ) .await .expect("Agent loop should recover nested XML tool calls"); @@ -5372,6 +5386,7 @@ mod tests { None, None, None, // user_content_blocks + None, // sender_id ) .await .expect("Normal loop should complete"); @@ -5435,6 +5450,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/kernel_handle.rs b/crates/openfang-runtime/src/kernel_handle.rs index ec57efe1f9..4c1b5b0bfc 100644 --- a/crates/openfang-runtime/src/kernel_handle.rs +++ b/crates/openfang-runtime/src/kernel_handle.rs @@ -201,6 +201,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 16695616ee..f3bb198d23 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. @@ -230,6 +231,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(""); @@ -349,13 +367,16 @@ 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_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, // 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" => { @@ -641,6 +662,21 @@ pub fn builtin_tool_definitions() -> Vec { "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(), @@ -1300,6 +1336,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(), @@ -1577,6 +1628,97 @@ async fn tool_web_fetch_legacy(input: &serde_json::Value) -> Result, + request_host: &str, + config: &openfang_types::config::SecureFetchConfig, +) -> Result, 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 { + 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 = 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 { let query = input["query"].as_str().ok_or("Missing 'query' parameter")?; @@ -2428,10 +2570,55 @@ 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 + + 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 + }); + } + + // 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 { + let delivery = &job["delivery"]; + 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", + "to": sid + }); + } + } + + kh.cron_create(agent_id, job).await } async fn tool_cron_list( @@ -2464,6 +2651,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)?; @@ -2477,16 +2665,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 { @@ -2985,6 +3183,65 @@ 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 // --------------------------------------------------------------------------- @@ -3809,12 +4066,113 @@ 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")); // 3 skill introspection tools (issue #1038) assert!(names.contains(&"skill_list")); assert!(names.contains(&"skill_describe")); assert!(names.contains(&"skill_execute")); } + // ── 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 { + 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)); + } + /// Issue #1038: skill_list, skill_describe, skill_execute work without /// touching the filesystem so global skills (outside the workspace /// sandbox) are reachable by the agent. @@ -3925,6 +4283,7 @@ mod tests { None, // tts_engine None, // docker_config None, // process_manager + None, // sender_id ) .await; assert!( @@ -3954,6 +4313,7 @@ mod tests { None, // tts_engine None, // docker_config None, // process_manager + None, // sender_id ) .await; assert!(result.is_error); @@ -3980,6 +4340,7 @@ mod tests { None, // tts_engine None, // docker_config None, // process_manager + None, // sender_id ) .await; assert!(result.is_error); @@ -4006,6 +4367,7 @@ mod tests { None, // tts_engine None, // docker_config None, // process_manager + None, // sender_id ) .await; assert!(result.is_error); @@ -4068,6 +4430,7 @@ mod tests { None, // tts_engine None, // docker_config None, // process_manager + None, // sender_id ) .await; assert!( @@ -4098,6 +4461,7 @@ mod tests { None, // tts_engine None, // docker_config None, // process_manager + None, // sender_id ) .await; assert!(result.is_error); @@ -4124,6 +4488,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 @@ -4150,6 +4515,7 @@ mod tests { None, // tts_engine None, // docker_config None, // process_manager + None, // sender_id ) .await; assert!(result.is_error); @@ -4176,6 +4542,7 @@ mod tests { None, // tts_engine None, // docker_config None, // process_manager + None, // sender_id ) .await; assert!(result.is_error); @@ -4203,6 +4570,7 @@ mod tests { None, // tts_engine None, // docker_config None, // process_manager + None, // sender_id ) .await; assert!(result.is_error); @@ -4234,6 +4602,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 @@ -4279,6 +4648,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 @@ -4314,6 +4684,7 @@ mod tests { None, // tts_engine None, // docker_config None, // process_manager + None, // sender_id ) .await; assert!(result.is_error); @@ -4483,6 +4854,7 @@ mod tests { None, // tts_engine None, // docker_config None, // process_manager + None, // sender_id ) .await; assert!(result.is_error); @@ -4528,6 +4900,7 @@ mod tests { None, // tts_engine None, // docker_config None, // process_manager + None, // sender_id ) .await; assert!(result.is_error); 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-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 = 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()) diff --git a/crates/openfang-types/src/config.rs b/crates/openfang-types/src/config.rs index 25df9b0059..c8853e7a0e 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, + /// 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, + /// 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>, +} + /// Browser automation configuration. #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(default)] @@ -1933,6 +1958,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, /// Forum topic routing: maps `message_thread_id` to an agent name. /// When a message arrives inside a Telegram forum topic whose thread id is /// listed here, the bridge dispatches it to the named agent instead of the @@ -1954,6 +1987,7 @@ impl Default for TelegramConfig { poll_interval_secs: 1, api_url: None, default_chat_id: None, + exclusive_agent: false, thread_routes: HashMap::new(), overrides: ChannelOverrides::default(), } diff --git a/crates/openfang-types/src/tool_compat.rs b/crates/openfang-types/src/tool_compat.rs index 6deee74083..c503551af3 100644 --- a/crates/openfang-types/src/tool_compat.rs +++ b/crates/openfang-types/src/tool_compat.rs @@ -63,6 +63,7 @@ pub fn is_known_openfang_tool(name: &str) -> bool { | "shell_exec" | "web_search" | "web_fetch" + | "secure_fetch" | "browser_navigate" | "memory_recall" | "memory_store" 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 diff --git a/deploy/agents/Dewansh/agent.toml b/deploy/agents/Dewansh/agent.toml new file mode 100644 index 0000000000..c7b34cc564 --- /dev/null +++ b/deploy/agents/Dewansh/agent.toml @@ -0,0 +1,36 @@ +name = "Dewansh" +description = "Research agent — web search + fetch, Telegram front-door" +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. + +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. +""" + +[capabilities] +tools = ["web_search", "web_fetch", "channel_send"] +network = [] 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/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..edc59bd026 --- /dev/null +++ b/deploy/config.default.toml @@ -0,0 +1,33 @@ +# 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 + 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. +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 + +# 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_EBAY" +default_agent = "EbayWatcher" +exclusive_agent = true +poll_interval_secs = 1 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 "$@" 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..48f251536d --- /dev/null +++ b/deploy/kamd1/openfang.yaml @@ -0,0 +1,189 @@ +# openfang — kamd1, https://openfang.pageapp.net +# +# Self-contained: namespace, regcred, config, deployment, service, routing. +# Substitute placeholders at deploy time (envsubst or the deploy UI): +# +# ${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} +# +# 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. +# +# 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 +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.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: 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.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 +metadata: + name: oauth2-proxy-middleware + namespace: openfang +spec: + forwardAuth: + address: https://auth.pageapp.net/?rd=https%3A%2F%2Fopenfang.pageapp.net + trustForwardHeader: true diff --git a/deploy/rancher/openfang.ci.yaml b/deploy/rancher/openfang.ci.yaml new file mode 100644 index 0000000000..976d7d36f6 --- /dev/null +++ b/deploy/rancher/openfang.ci.yaml @@ -0,0 +1,209 @@ +# 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 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 +# ${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 agents + 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_EBAY: ${TELEGRAM_BOT_TOKEN_EBAY} +--- +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: + # 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: + - 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: "1Gi" + requests: + cpu: "10m" + 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 + port: 4200 + initialDelaySeconds: 30 + periodSeconds: 10 + livenessProbe: + httpGet: + path: /api/health + port: 4200 + initialDelaySeconds: 60 + periodSeconds: 30 + volumes: + - name: data + persistentVolumeClaim: + claimName: openfang-data + - name: tmp + emptyDir: {} + 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: 2Gi diff --git a/deploy/rancher/openfang.yaml b/deploy/rancher/openfang.yaml new file mode 100644 index 0000000000..5fc50e4bba --- /dev/null +++ b/deploy/rancher/openfang.yaml @@ -0,0 +1,131 @@ +# 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: <your-storage-class> # 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: + # 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 = "<value>"` 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 + # First boot seeds agents + config from disk before the API is up. + initialDelaySeconds: 30 + periodSeconds: 10 + livenessProbe: + httpGet: + path: /api/health + port: 4200 + initialDelaySeconds: 60 + periodSeconds: 30 + resources: + requests: + cpu: "250m" + memory: "256Mi" + limits: + cpu: "1" + memory: "1Gi" + volumes: + - name: data + persistentVolumeClaim: + claimName: openfang-data + - name: tmp + emptyDir: {} +--- +apiVersion: v1 +kind: Service +metadata: + name: openfang + labels: + app: openfang +spec: + type: ClusterIP + selector: + app: openfang + ports: + - name: http + port: 4200 + targetPort: 4200 diff --git a/docs/channel-adapters.md b/docs/channel-adapters.md index e6534b8ff8..7e0048da7a 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<String>` 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 <name>`) 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).