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
+```
+
+---
+
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: — (target )\n"
+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: # 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 = ""` 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` and wiped from memory on drop.
- `default_agent` -- The agent name (or ID) that receives messages when no specific routing applies.
+- `exclusive_agent` -- _(Telegram)_ When `true`, locks the bot to `default_agent` only: every message routes to that single agent regardless of bindings or user selection, and the multi-agent discovery/switching commands (`/agents`, `/agent `) plus the agent-list welcome are disabled. Use this for a dedicated single-purpose bot that should never expose or switch to other agents. Requires `default_agent` to be set.
- `allowed_users` -- Optional list of platform user IDs allowed to interact. Empty means allow all.
- `overrides` -- Optional per-channel behavior overrides (see [Channel Overrides](#channel-overrides) below).