From 80cea4f461e38aeb20eb758e49f74c0d9b57b25f Mon Sep 17 00:00:00 2001 From: Chih-Kang Lin Date: Thu, 7 May 2026 03:56:48 +0800 Subject: [PATCH 001/100] docs(helm): document supported values and add usage examples (#280) * docs(helm): document supported chart values * docs(helm): clarify fullnameOverride example * docs(helm): fix --set-literal for botToken, add missing stt/reactions fields --------- Co-authored-by: Chih-Kang Lin <8790142+chihkang@users.noreply.github.com> Co-authored-by: masami-agent --- README.md | 2 + charts/openab/README.md | 96 +++++++++++++++++++++++++++++++++++++++ charts/openab/values.yaml | 10 ++++ 3 files changed, 108 insertions(+) create mode 100644 charts/openab/README.md diff --git a/README.md b/README.md index c8a9d3d8e..4dd3d4f21 100644 --- a/README.md +++ b/README.md @@ -129,6 +129,8 @@ helm install openab openab/openab \ --set-string 'agents.kiro.slack.allowedChannels[0]=C0123456789' ``` +For additional Helm values such as `fullnameOverride`, `nameOverride`, `envFrom`, and `agentsMd`, see [charts/openab/README.md](charts/openab/README.md). + ### 3. Authenticate (first time only) ```bash diff --git a/charts/openab/README.md b/charts/openab/README.md new file mode 100644 index 000000000..1ef465392 --- /dev/null +++ b/charts/openab/README.md @@ -0,0 +1,96 @@ +# openab Helm Chart + +This chart deploys one or more OpenAB agents on Kubernetes. + +## Common Values + +This page highlights commonly used values and deployment patterns. For the complete list of supported options and defaults, run `helm show values openab/openab` or inspect [`values.yaml`](values.yaml). + +### Release naming + +| Value | Description | Default | +|-------|-------------|---------| +| `nameOverride` | Override the chart name portion used in generated resource names. For per-agent resource names, use `agents..nameOverride`. | `""` | +| `fullnameOverride` | Override the full generated release name for chart resources. Useful when deploying multiple instances with predictable names. | `""` | + +### Agent values + +Each agent lives under `agents.`. + +| Value | Description | Default | +|-------|-------------|---------| +| `discord.botToken` | Discord bot token for the agent. | `""` | +| `discord.allowedChannels` | Channel allowlist. Use `--set-string` for Discord IDs. | `["YOUR_CHANNEL_ID"]` | +| `discord.allowedUsers` | User allowlist. Empty = allow all users by default. Use `--set-string` for Discord IDs. | `[]` | +| `discord.allowDm` | Whether the Discord bot responds to direct messages. | `false` | +| `discord.allowBotMessages` | Controls whether bot messages can trigger replies. | `"off"` | +| `discord.trustedBotIds` | Optional bot ID allowlist when bot-message replies are enabled. | `[]` | +| `slack.enabled` | Enable the Slack adapter for the agent. | `false` | +| `slack.botToken` | Slack Bot User OAuth token. | `""` | +| `slack.appToken` | Slack App-Level token for Socket Mode. | `""` | +| `slack.allowedChannels` | Slack channel allowlist. Empty means allow all channels by default. | `[]` | +| `slack.allowedUsers` | Slack user allowlist. Empty means allow all users by default. | `[]` | +| `nameOverride` | Override this agent's generated resource name. | `""` | +| `workingDir` | Working directory and HOME inside the container. | `"/home/agent"` | +| `env` | Inline environment variables passed to the agent process. | `{}` | +| `envFrom` | Additional environment sources from existing Secrets or ConfigMaps. | `[]` | +| `pool.maxSessions` | Maximum concurrent ACP sessions for the agent. | `10` | +| `pool.sessionTtlHours` | Idle session TTL in hours. | `24` | +| `reactions.enabled` | Enable status reactions. | `true` | +| `reactions.removeAfterReply` | Remove status reactions after the agent replies. | `false` | +| `reactions.toolDisplay` | Tool display verbosity: `full`, `compact`, or `none`. | `"full"` | +| `stt.enabled` | Enable voice-message speech-to-text. | `false` | +| `stt.apiKey` | API key for the speech-to-text provider. | `""` | +| `stt.model` | STT model name. | `"whisper-large-v3-turbo"` | +| `stt.baseUrl` | STT API base URL. | `"https://api.groq.com/openai/v1"` | +| `gateway.enabled` | Enable the gateway config block for webhook-based platforms. | `false` | +| `gateway.deploy` | Deploy the gateway Deployment and Service. | `true` | +| `cron.usercronEnabled` | Enable user-provided cron configuration. | `false` | +| `cronjobs` | Config-driven scheduled messages for an agent. | `[]` | +| `persistence.enabled` | Enable persistent storage for auth and settings. | `true` | +| `persistence.existingClaim` | Reuse an existing PVC instead of creating one. | `""` | +| `agentsMd` | Contents of `AGENTS.md` mounted into the working directory. | `""` | +| `extraInitContainers` | Additional init containers for the agent pod. | `[]` | +| `extraContainers` | Additional sidecar containers for the agent pod. | `[]` | +| `extraVolumeMounts` | Additional volume mounts for the main agent container. | `[]` | +| `extraVolumes` | Additional volumes for the agent pod. | `[]` | + +## Examples + +### Override generated names + +```bash +helm install prod openab/openab \ + --set fullnameOverride=my-openab \ + --set-literal agents.kiro.discord.botToken="$DISCORD_BOT_TOKEN" \ + --set-string 'agents.kiro.discord.allowedChannels[0]=YOUR_CHANNEL_ID' +``` + +This makes generated resource names use `my-openab` (for example `my-openab-kiro`) instead of the default `prod-openab`. + +### Load credentials with `envFrom` + +```yaml +agents: + kiro: + envFrom: + - secretRef: + name: openab-agent-secrets + - configMapRef: + name: openab-agent-config +``` + +This is useful for credentials such as `GH_TOKEN` without storing them directly in Helm values. + +### Provide `AGENTS.md` with `--set-file` + +```bash +helm install openab openab/openab \ + --set-literal agents.kiro.discord.botToken="$DISCORD_BOT_TOKEN" \ + --set-string 'agents.kiro.discord.allowedChannels[0]=YOUR_CHANNEL_ID' \ + --set-file agents.kiro.agentsMd=./AGENTS.md +``` + +### Discord ID precision warning + +Discord IDs must be set with `--set-string`, not `--set`. Otherwise Helm may coerce them into numbers and lose precision. diff --git a/charts/openab/values.yaml b/charts/openab/values.yaml index 4314d2df2..5da5da2cf 100644 --- a/charts/openab/values.yaml +++ b/charts/openab/values.yaml @@ -4,6 +4,13 @@ image: tag: "" pullPolicy: IfNotPresent +# Chart-level name override used in generated resource names. +# For per-agent overrides, use agents..nameOverride. +nameOverride: "" + +# Override the full release name used in generated resource names. +fullnameOverride: "" + podSecurityContext: runAsNonRoot: true runAsUser: 1000 @@ -115,6 +122,7 @@ agents: # allowedUsers: [] # workingDir: /home/agent # env: {} + # # Load env vars from existing Secrets or ConfigMaps, e.g. GH_TOKEN. # envFrom: [] # secretEnv: [] # pool: @@ -205,6 +213,7 @@ agents: # maxBatchTokens: 24000 workingDir: /home/agent env: {} + # Load env vars from existing Secrets or ConfigMaps, e.g. GH_TOKEN. envFrom: [] secretEnv: [] # list of {name, secretName, secretKey} — rendered as valueFrom.secretKeyRef; keys auto-added to inherit_env pool: @@ -311,6 +320,7 @@ agents: existingClaim: "" # set to reuse an existing PVC (skips PVC creation) storageClass: "" size: 1Gi # defaults to 1Gi if not set + # Mount a custom AGENTS.md file. Useful with --set-file. # ⚠️ When set, this ConfigMap mount shadows any file at the same path on the PVC. # The PVC file is NOT deleted but becomes invisible to the agent. Remove agentsMd to restore. agentsMd: "" From 7084636441138644649d908ff517be6608e5c107 Mon Sep 17 00:00:00 2001 From: Tony Lee Date: Thu, 7 May 2026 04:01:26 +0800 Subject: [PATCH 002/100] feat: echo STT transcripts to thread before agent reply (#571) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: STT transcript echo to thread (Discord + Slack) When STT transcribes a voice message, optionally post the transcript back to the thread (no mentions) before the agent reply so users can verify what was heard. Default is OFF — opt in via [stt] echo_transcript = true. - New config: [stt] echo_transcript (default false, opt-in) - New helper: stt::post_echo with platform-agnostic ChatAdapter handle — future LINE/Telegram/Teams adapters get echo for free - Format: > 🎤 per clip, all in one thread message - Failure: > 🎤 (transcription failed) line + ⚠️ reaction on the user msg - Helm: agents..stt.echoTranscript (camelCase) wired through configmap - Docs: docs/stt.md and docs/config-reference.md updated Rebased on top of #567 (gateway config rendering). Tests: 133/133 cargo. helm-unittest: 28/28. Clippy --all-targets -D warnings clean. * fix: close unclosed test fn delimiter + cargo fmt --------- Co-authored-by: obrutjack --- charts/openab/templates/configmap.yaml | 3 + charts/openab/values.yaml | 3 + docs/config-reference.md | 1 + docs/stt.md | 9 + src/acp/connection.rs | 80 +++-- src/acp/mod.rs | 2 +- src/acp/pool.rs | 42 ++- src/acp/protocol.rs | 81 +++-- src/adapter.rs | 10 +- src/bot_turns.rs | 21 +- src/config.rs | 213 ++++++++++--- src/cron.rs | 208 +++++++++---- src/discord.rs | 394 +++++++++++++++++-------- src/dispatch.rs | 108 +++++-- src/error_display.rs | 17 +- src/gateway.rs | 28 +- src/main.rs | 94 ++++-- src/markdown.rs | 9 +- src/media.rs | 66 +++-- src/reactions.rs | 77 +++-- src/setup/config.rs | 14 +- src/setup/validate.rs | 13 +- src/setup/wizard.rs | 93 +++--- src/slack.rs | 209 ++++++++----- src/stt.rs | 297 ++++++++++++++++++- src/timestamp.rs | 20 +- 26 files changed, 1569 insertions(+), 543 deletions(-) diff --git a/charts/openab/templates/configmap.yaml b/charts/openab/templates/configmap.yaml index 12576a8e0..32106c2bd 100644 --- a/charts/openab/templates/configmap.yaml +++ b/charts/openab/templates/configmap.yaml @@ -167,6 +167,9 @@ data: api_key = "${STT_API_KEY}" model = {{ ($cfg.stt).model | default "whisper-large-v3-turbo" | toJson }} base_url = {{ ($cfg.stt).baseUrl | default "https://api.groq.com/openai/v1" | toJson }} + {{- if hasKey ($cfg.stt | default dict) "echoTranscript" }} + echo_transcript = {{ ($cfg.stt).echoTranscript }} + {{- end }} {{- end }} {{- if ($cfg.gateway).enabled }} {{- if not ($cfg.gateway).url }} diff --git a/charts/openab/values.yaml b/charts/openab/values.yaml index 5da5da2cf..2935637df 100644 --- a/charts/openab/values.yaml +++ b/charts/openab/values.yaml @@ -231,6 +231,9 @@ agents: apiKey: "" model: "whisper-large-v3-turbo" baseUrl: "https://api.groq.com/openai/v1" + # Echo the transcribed text back to the thread before the agent reply + # so users can verify STT accuracy. Default: false (opt-in). + echoTranscript: false gateway: enabled: false # set to true + provide url to enable the [gateway] config block deploy: true # set to false to skip Gateway Deployment/Service (config-only mode) diff --git a/docs/config-reference.md b/docs/config-reference.md index 622dd7d3a..9ddaf40f7 100644 --- a/docs/config-reference.md +++ b/docs/config-reference.md @@ -204,6 +204,7 @@ Speech-to-text transcription for voice messages. Uses an OpenAI-compatible `/aud | `api_key` | string | `""` | API key for the STT service. When empty and `base_url` contains `groq.com`, the `GROQ_API_KEY` environment variable is used automatically. For local servers, use `api_key = "not-needed"`. | | `model` | string | `"whisper-large-v3-turbo"` | Model name to use for transcription. | | `base_url` | string | `"https://api.groq.com/openai/v1"` | Base URL of the STT API. Any OpenAI-compatible `/audio/transcriptions` endpoint works. | +| `echo_transcript` | bool | `false` | When set to `true` and STT runs, post a `> 🎤 ` message to the thread before the agent reply so users can verify what was heard. Failures show `(transcription failed)` and add a ⚠️ reaction to the original message. | --- diff --git a/docs/stt.md b/docs/stt.md index 5ee8fa488..202f96789 100644 --- a/docs/stt.md +++ b/docs/stt.md @@ -50,6 +50,7 @@ enabled = true # default: false api_key = "${GROQ_API_KEY}" # required for cloud providers model = "whisper-large-v3-turbo" # default base_url = "https://api.groq.com/openai/v1" # default +echo_transcript = true # default: false (opt-in) ``` | Field | Required | Default | Description | @@ -58,6 +59,7 @@ base_url = "https://api.groq.com/openai/v1" # default | `api_key` | no* | — | API key for the STT provider. *Auto-detected from `GROQ_API_KEY` env var if not set. For local servers, use any non-empty string (e.g. `"not-needed"`). | | `model` | no | `whisper-large-v3-turbo` | Whisper model name. Varies by provider. | | `base_url` | no | `https://api.groq.com/openai/v1` | OpenAI-compatible API base URL. | +| `echo_transcript` | no | `false` | When set to `true` and STT runs, post a `> 🎤 ` message to the thread before the agent reply so users can verify what was heard. Failures show `(transcription failed)` and add a ⚠️ reaction to the original message. | ## Deployment Options @@ -147,6 +149,13 @@ helm upgrade openab openab/openab \ --set agents.kiro.stt.baseUrl=https://api.groq.com/openai/v1 ``` +```bash +helm upgrade openab openab/openab \ + --set agents.kiro.stt.enabled=true \ + --set agents.kiro.stt.apiKey=gsk_xxx \ + --set agents.kiro.stt.echoTranscript=true # opt in to transcript echo +``` + ## Disabling STT Omit the `[stt]` section entirely, or set: diff --git a/src/acp/connection.rs b/src/acp/connection.rs index f49c0f503..c1b36a472 100644 --- a/src/acp/connection.rs +++ b/src/acp/connection.rs @@ -1,4 +1,6 @@ -use crate::acp::protocol::{ConfigOption, JsonRpcMessage, JsonRpcRequest, JsonRpcResponse, parse_config_options}; +use crate::acp::protocol::{ + parse_config_options, ConfigOption, JsonRpcMessage, JsonRpcRequest, JsonRpcResponse, +}; use anyhow::{anyhow, Result}; use serde_json::{json, Value}; use std::collections::HashMap; @@ -10,7 +12,6 @@ use tokio::sync::{mpsc, oneshot, Mutex}; use tokio::task::JoinHandle; use tracing::{debug, error, info}; - /// Pick the most permissive selectable permission option from ACP options. fn pick_best_option(options: &[Value]) -> Option { let mut fallback: Option<&Value> = None; @@ -187,20 +188,39 @@ impl AcpConnection { // Preserve the real HOME so agents can find OAuth/auth files (~/.codex, // ~/.claude, ~/.config/gh, etc.). working_dir is already set via // current_dir() above and is not necessarily the user's home directory. - cmd.env("HOME", std::env::var("HOME").unwrap_or_else(|_| working_dir.into())); - cmd.env("PATH", std::env::var("PATH").unwrap_or_else(|_| "/usr/local/bin:/usr/bin:/bin".into())); + cmd.env( + "HOME", + std::env::var("HOME").unwrap_or_else(|_| working_dir.into()), + ); + cmd.env( + "PATH", + std::env::var("PATH").unwrap_or_else(|_| "/usr/local/bin:/usr/bin:/bin".into()), + ); #[cfg(unix)] { - cmd.env("USER", std::env::var("USER").unwrap_or_else(|_| "agent".into())); + cmd.env( + "USER", + std::env::var("USER").unwrap_or_else(|_| "agent".into()), + ); } #[cfg(windows)] { // Windows requires SystemRoot for DLL loading and basic OS functionality. // USERPROFILE is the Windows equivalent of HOME. - cmd.env("USERPROFILE", std::env::var("USERPROFILE").unwrap_or_else(|_| working_dir.into())); - cmd.env("USERNAME", std::env::var("USERNAME").unwrap_or_else(|_| "agent".into())); - if let Ok(v) = std::env::var("SystemRoot") { cmd.env("SystemRoot", v); } - if let Ok(v) = std::env::var("SystemDrive") { cmd.env("SystemDrive", v); } + cmd.env( + "USERPROFILE", + std::env::var("USERPROFILE").unwrap_or_else(|_| working_dir.into()), + ); + cmd.env( + "USERNAME", + std::env::var("USERNAME").unwrap_or_else(|_| "agent".into()), + ); + if let Ok(v) = std::env::var("SystemRoot") { + cmd.env("SystemRoot", v); + } + if let Ok(v) = std::env::var("SystemDrive") { + cmd.env("SystemDrive", v); + } } for (k, v) in env { cmd.env(k, expand_env(v)); @@ -223,8 +243,7 @@ impl AcpConnection { let mut proc = cmd .spawn() .map_err(|e| anyhow!("failed to spawn {command}: {e}"))?; - let child_pgid = proc.id() - .and_then(|pid| i32::try_from(pid).ok()); + let child_pgid = proc.id().and_then(|pid| i32::try_from(pid).ok()); let stdout = proc.stdout.take().ok_or_else(|| anyhow!("no stdout"))?; let stdin = proc.stdin.take().ok_or_else(|| anyhow!("no stdin"))?; @@ -403,19 +422,22 @@ impl AcpConnection { .and_then(|c| c.get("loadSession")) .and_then(|v| v.as_bool()) .unwrap_or(false); - info!(agent = agent_name, load_session = self.supports_load_session, "initialized"); + info!( + agent = agent_name, + load_session = self.supports_load_session, + "initialized" + ); Ok(()) } pub async fn session_new(&mut self, cwd: &str) -> Result { let resp = self - .send_request( - "session/new", - Some(json!({"cwd": cwd, "mcpServers": []})), - ) + .send_request("session/new", Some(json!({"cwd": cwd, "mcpServers": []}))) .await?; - let session_id = resp.result.as_ref() + let session_id = resp + .result + .as_ref() .and_then(|r| r.get("sessionId")) .and_then(|s| s.as_str()) .ok_or_else(|| anyhow!("no sessionId in session/new response"))? @@ -434,7 +456,11 @@ impl AcpConnection { /// Set a config option (e.g. model, mode) via ACP session/set_config_option. /// Returns the updated list of all config options. - pub async fn set_config_option(&mut self, config_id: &str, value: &str) -> Result> { + pub async fn set_config_option( + &mut self, + config_id: &str, + value: &str, + ) -> Result> { let session_id = self .acp_session_id .as_ref() @@ -462,7 +488,10 @@ impl AcpConnection { Err(_) => { // Fall back: send as a slash command (e.g. "/model claude-sonnet-4") let cmd = format!("/{config_id} {value}"); - info!(cmd, "set_config_option not supported, falling back to prompt"); + info!( + cmd, + "set_config_option not supported, falling back to prompt" + ); let _resp = self .send_request( "session/prompt", @@ -503,10 +532,7 @@ impl AcpConnection { let id = self.next_id(); // Convert content blocks to JSON - let prompt_json: Vec = content_blocks - .iter() - .map(|b| b.to_json()) - .collect(); + let prompt_json: Vec = content_blocks.iter().map(|b| b.to_json()).collect(); let req = JsonRpcRequest::new( id, @@ -572,11 +598,15 @@ impl AcpConnection { #[cfg(unix)] { // Stage 1: SIGTERM the process group - unsafe { libc::kill(-pgid, libc::SIGTERM); } + unsafe { + libc::kill(-pgid, libc::SIGTERM); + } // Stage 2: SIGKILL after brief grace (std::thread survives runtime shutdown) std::thread::spawn(move || { std::thread::sleep(std::time::Duration::from_millis(1500)); - unsafe { libc::kill(-pgid, libc::SIGKILL); } + unsafe { + libc::kill(-pgid, libc::SIGKILL); + } }); } #[cfg(not(unix))] diff --git a/src/acp/mod.rs b/src/acp/mod.rs index c67cad827..f7d0141e2 100644 --- a/src/acp/mod.rs +++ b/src/acp/mod.rs @@ -2,6 +2,6 @@ pub mod connection; pub mod pool; pub mod protocol; +pub use connection::ContentBlock; pub use pool::SessionPool; pub use protocol::{classify_notification, AcpEvent}; -pub use connection::ContentBlock; diff --git a/src/acp/pool.rs b/src/acp/pool.rs index a146abb0f..6ccd3631d 100644 --- a/src/acp/pool.rs +++ b/src/acp/pool.rs @@ -32,12 +32,7 @@ pub struct SessionPool { mapping_path: PathBuf, } -type EvictionCandidate = ( - String, - Arc>, - Instant, - Option, -); +type EvictionCandidate = (String, Arc>, Instant, Option); fn remove_if_same_handle( map: &mut HashMap>>, @@ -54,10 +49,7 @@ fn remove_if_same_handle( } } -fn get_or_insert_gate( - map: &mut HashMap>>, - key: &str, -) -> Arc> { +fn get_or_insert_gate(map: &mut HashMap>>, key: &str) -> Arc> { map.entry(key.to_string()) .or_insert_with(|| Arc::new(Mutex::new(()))) .clone() @@ -104,7 +96,9 @@ impl SessionPool { } }; let tmp = self.mapping_path.with_extension("json.tmp"); - if let Err(e) = std::fs::write(&tmp, &data).and_then(|_| std::fs::rename(&tmp, &self.mapping_path)) { + if let Err(e) = + std::fs::write(&tmp, &data).and_then(|_| std::fs::rename(&tmp, &self.mapping_path)) + { warn!(path = %self.mapping_path.display(), error = %e, "failed to persist thread mapping"); } } @@ -157,7 +151,12 @@ impl SessionPool { skipped_locked_candidates += 1; continue; }; - let candidate = (key, conn_handle, conn.last_active, conn.acp_session_id.clone()); + let candidate = ( + key, + conn_handle, + conn.last_active, + conn.acp_session_id.clone(), + ); match &eviction_candidate { Some((_, _, oldest_last_active, _)) if candidate.2 >= *oldest_last_active => {} _ => eviction_candidate = Some(candidate), @@ -250,7 +249,9 @@ impl SessionPool { state.active.insert(thread_id.to_string(), new_conn); self.save_mapping(&state.suspended); if !cancel_session_id.is_empty() { - state.cancel_handles.insert(thread_id.to_string(), (cancel_handle, cancel_session_id)); + state + .cancel_handles + .insert(thread_id.to_string(), (cancel_handle, cancel_session_id)); } Ok(()) } @@ -260,7 +261,9 @@ impl SessionPool { where F: for<'a> FnOnce( &'a mut AcpConnection, - ) -> std::pin::Pin> + Send + 'a>>, + ) -> std::pin::Pin< + Box> + Send + 'a>, + >, { let conn = { let state = self.state.read().await; @@ -311,7 +314,10 @@ impl SessionPool { pub async fn cancel_session(&self, thread_id: &str) -> Result<()> { let (stdin, session_id) = { let state = self.state.read().await; - state.cancel_handles.get(thread_id).cloned() + state + .cancel_handles + .get(thread_id) + .cloned() .ok_or_else(|| anyhow!("no session for thread {thread_id}"))? }; let data = serde_json::to_string(&serde_json::json!({ @@ -414,7 +420,11 @@ impl SessionPool { // awaiting a connection lock). let snapshot: Vec<(String, Arc>)> = { let state = self.state.read().await; - state.active.iter().map(|(k, v)| (k.clone(), Arc::clone(v))).collect() + state + .active + .iter() + .map(|(k, v)| (k.clone(), Arc::clone(v))) + .collect() }; let mut session_ids: Vec<(String, String)> = Vec::new(); diff --git a/src/acp/protocol.rs b/src/acp/protocol.rs index 25cfb9374..40dfdf070 100644 --- a/src/acp/protocol.rs +++ b/src/acp/protocol.rs @@ -14,7 +14,12 @@ pub struct JsonRpcRequest { impl JsonRpcRequest { pub fn new(id: u64, method: impl Into, params: Option) -> Self { - Self { jsonrpc: "2.0", id, method: method.into(), params } + Self { + jsonrpc: "2.0", + id, + method: method.into(), + params, + } } } @@ -27,7 +32,11 @@ pub struct JsonRpcResponse { impl JsonRpcResponse { pub fn new(id: u64, result: Value) -> Self { - Self { jsonrpc: "2.0", id, result } + Self { + jsonrpc: "2.0", + id, + result, + } } } @@ -95,17 +104,26 @@ pub fn parse_config_options(result: &Value) -> Vec { let mut options = Vec::new(); if let Some(models) = result.get("models") { - let current = models.get("currentModelId").and_then(|v| v.as_str()).unwrap_or(""); + let current = models + .get("currentModelId") + .and_then(|v| v.as_str()) + .unwrap_or(""); if let Some(available) = models.get("availableModels").and_then(|v| v.as_array()) { let values: Vec = available .iter() .filter_map(|m| { - let id = m.get("modelId").or_else(|| m.get("id")).and_then(|v| v.as_str())?; + let id = m + .get("modelId") + .or_else(|| m.get("id")) + .and_then(|v| v.as_str())?; let name = m.get("name").and_then(|v| v.as_str()).unwrap_or(id); Some(ConfigOptionValue { value: id.to_string(), name: name.to_string(), - description: m.get("description").and_then(|v| v.as_str()).map(String::from), + description: m + .get("description") + .and_then(|v| v.as_str()) + .map(String::from), }) }) .collect(); @@ -124,7 +142,10 @@ pub fn parse_config_options(result: &Value) -> Vec { } if let Some(modes) = result.get("modes") { - let current = modes.get("currentModeId").and_then(|v| v.as_str()).unwrap_or(""); + let current = modes + .get("currentModeId") + .and_then(|v| v.as_str()) + .unwrap_or(""); if let Some(available) = modes.get("availableModes").and_then(|v| v.as_array()) { let values: Vec = available .iter() @@ -134,7 +155,10 @@ pub fn parse_config_options(result: &Value) -> Vec { Some(ConfigOptionValue { value: id.to_string(), name: name.to_string(), - description: m.get("description").and_then(|v| v.as_str()).map(String::from), + description: m + .get("description") + .and_then(|v| v.as_str()) + .map(String::from), }) }) .collect(); @@ -161,9 +185,18 @@ pub fn parse_config_options(result: &Value) -> Vec { pub enum AcpEvent { Text(String), Thinking, - ToolStart { id: String, title: String }, - ToolDone { id: String, title: String, status: String }, - ConfigUpdate { options: Vec }, + ToolStart { + id: String, + title: String, + }, + ToolDone { + id: String, + title: String, + status: String, + }, + ConfigUpdate { + options: Vec, + }, Status, } @@ -190,18 +223,32 @@ pub fn classify_notification(msg: &JsonRpcMessage) -> Option { let text = update.get("content")?.get("text")?.as_str()?; Some(AcpEvent::Text(text.to_string())) } - "agent_thought_chunk" => { - Some(AcpEvent::Thinking) - } + "agent_thought_chunk" => Some(AcpEvent::Thinking), "tool_call" => { - let title = update.get("title").and_then(|v| v.as_str()).unwrap_or("").to_string(); + let title = update + .get("title") + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(); Some(AcpEvent::ToolStart { id: tool_id, title }) } "tool_call_update" => { - let title = update.get("title").and_then(|v| v.as_str()).unwrap_or("").to_string(); - let status = update.get("status").and_then(|v| v.as_str()).unwrap_or("").to_string(); + let title = update + .get("title") + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(); + let status = update + .get("status") + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(); if status == "completed" || status == "failed" { - Some(AcpEvent::ToolDone { id: tool_id, title, status }) + Some(AcpEvent::ToolDone { + id: tool_id, + title, + status, + }) } else { Some(AcpEvent::ToolStart { id: tool_id, title }) } diff --git a/src/adapter.rs b/src/adapter.rs index 89b2ae4db..106cd47bd 100644 --- a/src/adapter.rs +++ b/src/adapter.rs @@ -306,7 +306,15 @@ impl AdapterRouter { reactions: Arc, other_bot_present: bool, ) -> Result<()> { - self.stream_prompt_blocks(adapter, thread_key, content_blocks, thread_channel, reactions, other_bot_present).await + self.stream_prompt_blocks( + adapter, + thread_key, + content_blocks, + thread_channel, + reactions, + other_bot_present, + ) + .await } /// Drive one ACP turn with the given pre-packed ContentBlocks. diff --git a/src/bot_turns.rs b/src/bot_turns.rs index 9f031d145..92ff7e728 100644 --- a/src/bot_turns.rs +++ b/src/bot_turns.rs @@ -34,7 +34,10 @@ pub struct BotTurnTracker { impl BotTurnTracker { pub fn new(soft_limit: u32) -> Self { - Self { soft_limit, counts: HashMap::new() } + Self { + soft_limit, + counts: HashMap::new(), + } } pub fn on_bot_message(&mut self, thread_id: &str) -> TurnResult { @@ -307,12 +310,18 @@ mod tests { assert_eq!(t.classify_bot_message("t1"), TurnAction::Continue); assert!(matches!( t.classify_bot_message("t1"), - TurnAction::WarnAndStop { severity: TurnSeverity::Soft, .. }, + TurnAction::WarnAndStop { + severity: TurnSeverity::Soft, + .. + }, )); assert_eq!(t.classify_bot_message("t2"), TurnAction::Continue); assert!(matches!( t.classify_bot_message("t2"), - TurnAction::WarnAndStop { severity: TurnSeverity::Soft, .. }, + TurnAction::WarnAndStop { + severity: TurnSeverity::Soft, + .. + }, )); } @@ -333,7 +342,11 @@ mod tests { assert_eq!(t.classify_bot_message("t1"), TurnAction::Continue); assert!(matches!( t.classify_bot_message("t1"), - TurnAction::WarnAndStop { severity: TurnSeverity::Soft, turns: 2, .. }, + TurnAction::WarnAndStop { + severity: TurnSeverity::Soft, + turns: 2, + .. + }, )); } } diff --git a/src/config.rs b/src/config.rs index 574b26607..dd56484d2 100644 --- a/src/config.rs +++ b/src/config.rs @@ -57,7 +57,10 @@ impl<'de> Deserialize<'de> for AllowBots { "off" | "none" | "false" => Ok(Self::Off), "mentions" => Ok(Self::Mentions), "all" | "true" => Ok(Self::All), - other => Err(serde::de::Error::unknown_variant(other, &["off", "mentions", "all"])), + other => Err(serde::de::Error::unknown_variant( + other, + &["off", "mentions", "all"], + )), } } } @@ -102,6 +105,10 @@ pub struct SttConfig { pub model: String, #[serde(default = "default_stt_base_url")] pub base_url: String, + /// Echo the transcribed text back to the thread (no mentions) before + /// dispatching the prompt to the agent. Lets users verify STT accuracy. + #[serde(default = "default_echo_transcript")] + pub echo_transcript: bool, } impl Default for SttConfig { @@ -111,12 +118,20 @@ impl Default for SttConfig { api_key: String::new(), model: default_stt_model(), base_url: default_stt_base_url(), + echo_transcript: default_echo_transcript(), } } } -fn default_stt_model() -> String { "whisper-large-v3-turbo".into() } -fn default_stt_base_url() -> String { "https://api.groq.com/openai/v1".into() } +fn default_stt_model() -> String { + "whisper-large-v3-turbo".into() +} +fn default_stt_base_url() -> String { + "https://api.groq.com/openai/v1".into() +} +fn default_echo_transcript() -> bool { + false +} #[derive(Debug, Deserialize)] pub struct DiscordConfig { @@ -161,9 +176,15 @@ pub struct DiscordConfig { pub max_batch_tokens: usize, } -fn default_max_bot_turns() -> u32 { 100 } -fn default_max_buffered_messages() -> usize { 10 } -fn default_max_batch_tokens() -> usize { 24_000 } +fn default_max_bot_turns() -> u32 { + 100 +} +fn default_max_buffered_messages() -> usize { + 10 +} +fn default_max_batch_tokens() -> usize { + 24_000 +} /// Controls whether the bot responds to user messages in threads without @mention. /// @@ -188,7 +209,10 @@ impl<'de> Deserialize<'de> for AllowUsers { "involved" => Ok(Self::Involved), "mentions" => Ok(Self::Mentions), "multibot_mentions" => Ok(Self::MultibotMentions), - other => Err(serde::de::Error::unknown_variant(other, &["involved", "mentions", "multibot-mentions"])), + other => Err(serde::de::Error::unknown_variant( + other, + &["involved", "mentions", "multibot-mentions"], + )), } } } @@ -315,9 +339,15 @@ pub struct CronJobConfig { pub timezone: String, } -fn default_cron_platform() -> String { "discord".into() } -fn default_cron_sender() -> String { "openab-cron".into() } -fn default_cron_timezone() -> String { "UTC".into() } +fn default_cron_platform() -> String { + "discord".into() +} +fn default_cron_sender() -> String { + "openab-cron".into() +} +fn default_cron_timezone() -> String { + "UTC".into() +} /// Controls how tool calls are rendered in chat messages. /// @@ -339,7 +369,10 @@ impl<'de> Deserialize<'de> for ToolDisplay { "full" => Ok(Self::Full), "compact" => Ok(Self::Compact), "none" | "off" | "hidden" => Ok(Self::None), - other => Err(serde::de::Error::unknown_variant(other, &["full", "compact", "none"])), + other => Err(serde::de::Error::unknown_variant( + other, + &["full", "compact", "none"], + )), } } } @@ -392,28 +425,63 @@ pub struct ReactionTiming { // --- defaults --- -fn default_working_dir() -> String { "/tmp".into() } -fn default_max_sessions() -> usize { 10 } -fn default_ttl_hours() -> u64 { 4 } -fn default_true() -> bool { true } - -fn emoji_queued() -> String { "👀".into() } -fn emoji_thinking() -> String { "🤔".into() } -fn emoji_tool() -> String { "🔥".into() } -fn emoji_coding() -> String { "👨‍💻".into() } -fn emoji_web() -> String { "⚡".into() } -fn emoji_done() -> String { "🆗".into() } -fn emoji_error() -> String { "😱".into() } - -fn default_debounce_ms() -> u64 { 700 } -fn default_stall_soft_ms() -> u64 { 10_000 } -fn default_stall_hard_ms() -> u64 { 30_000 } -fn default_done_hold_ms() -> u64 { 1_500 } -fn default_error_hold_ms() -> u64 { 2_500 } +fn default_working_dir() -> String { + "/tmp".into() +} +fn default_max_sessions() -> usize { + 10 +} +fn default_ttl_hours() -> u64 { + 4 +} +fn default_true() -> bool { + true +} + +fn emoji_queued() -> String { + "👀".into() +} +fn emoji_thinking() -> String { + "🤔".into() +} +fn emoji_tool() -> String { + "🔥".into() +} +fn emoji_coding() -> String { + "👨‍💻".into() +} +fn emoji_web() -> String { + "⚡".into() +} +fn emoji_done() -> String { + "🆗".into() +} +fn emoji_error() -> String { + "😱".into() +} + +fn default_debounce_ms() -> u64 { + 700 +} +fn default_stall_soft_ms() -> u64 { + 10_000 +} +fn default_stall_hard_ms() -> u64 { + 30_000 +} +fn default_done_hold_ms() -> u64 { + 1_500 +} +fn default_error_hold_ms() -> u64 { + 2_500 +} impl Default for PoolConfig { fn default() -> Self { - Self { max_sessions: default_max_sessions(), session_ttl_hours: default_ttl_hours() } + Self { + max_sessions: default_max_sessions(), + session_ttl_hours: default_ttl_hours(), + } } } @@ -432,8 +500,13 @@ impl Default for ReactionsConfig { impl Default for ReactionEmojis { fn default() -> Self { Self { - queued: emoji_queued(), thinking: emoji_thinking(), tool: emoji_tool(), - coding: emoji_coding(), web: emoji_web(), done: emoji_done(), error: emoji_error(), + queued: emoji_queued(), + thinking: emoji_thinking(), + tool: emoji_tool(), + coding: emoji_coding(), + web: emoji_web(), + done: emoji_done(), + error: emoji_error(), } } } @@ -441,8 +514,10 @@ impl Default for ReactionEmojis { impl Default for ReactionTiming { fn default() -> Self { Self { - debounce_ms: default_debounce_ms(), stall_soft_ms: default_stall_soft_ms(), - stall_hard_ms: default_stall_hard_ms(), done_hold_ms: default_done_hold_ms(), + debounce_ms: default_debounce_ms(), + stall_soft_ms: default_stall_soft_ms(), + stall_hard_ms: default_stall_hard_ms(), + done_hold_ms: default_done_hold_ms(), error_hold_ms: default_error_hold_ms(), } } @@ -516,16 +591,31 @@ fn parse_config(raw: &str, source: &str) -> anyhow::Result { // and max_batch_tokens > 0 (otherwise the consumer's token-cap check forces every // batch to size 1 — functionally per-message via a confusing path). if let Some(ref d) = config.discord { - anyhow::ensure!(d.max_buffered_messages > 0, "discord.max_buffered_messages must be > 0"); - anyhow::ensure!(d.max_batch_tokens > 0, "discord.max_batch_tokens must be > 0"); + anyhow::ensure!( + d.max_buffered_messages > 0, + "discord.max_buffered_messages must be > 0" + ); + anyhow::ensure!( + d.max_batch_tokens > 0, + "discord.max_batch_tokens must be > 0" + ); } if let Some(ref s) = config.slack { - anyhow::ensure!(s.max_buffered_messages > 0, "slack.max_buffered_messages must be > 0"); + anyhow::ensure!( + s.max_buffered_messages > 0, + "slack.max_buffered_messages must be > 0" + ); anyhow::ensure!(s.max_batch_tokens > 0, "slack.max_batch_tokens must be > 0"); } if let Some(ref g) = config.gateway { - anyhow::ensure!(g.max_buffered_messages > 0, "gateway.max_buffered_messages must be > 0"); - anyhow::ensure!(g.max_batch_tokens > 0, "gateway.max_batch_tokens must be > 0"); + anyhow::ensure!( + g.max_buffered_messages > 0, + "gateway.max_buffered_messages must be > 0" + ); + anyhow::ensure!( + g.max_batch_tokens > 0, + "gateway.max_batch_tokens must be > 0" + ); } Ok(config) @@ -586,7 +676,10 @@ command = "echo" fn parse_invalid_toml_returns_error() { let result = parse_config("not valid toml {{{}}", "test"); assert!(result.is_err()); - assert!(result.unwrap_err().to_string().contains("failed to parse config from test")); + assert!(result + .unwrap_err() + .to_string() + .contains("failed to parse config from test")); } #[test] @@ -608,7 +701,10 @@ command = "echo" async fn load_config_from_url_invalid_host() { let result = load_config_from_url("https://invalid.test.example/config.toml").await; assert!(result.is_err()); - assert!(result.unwrap_err().to_string().contains("failed to fetch remote config")); + assert!(result + .unwrap_err() + .to_string() + .contains("failed to fetch remote config")); } #[test] @@ -630,7 +726,10 @@ command = "echo" assert!(gw.allow_all_channels.is_none()); // resolve_allow_all: empty lists → allow all assert!(resolve_allow_all(gw.allow_all_users, &gw.allowed_users)); - assert!(resolve_allow_all(gw.allow_all_channels, &gw.allowed_channels)); + assert!(resolve_allow_all( + gw.allow_all_channels, + &gw.allowed_channels + )); } #[test] @@ -652,7 +751,10 @@ command = "echo" assert_eq!(gw.allowed_channels, vec!["C1"]); // resolve_allow_all: non-empty lists → restricted assert!(!resolve_allow_all(gw.allow_all_users, &gw.allowed_users)); - assert!(!resolve_allow_all(gw.allow_all_channels, &gw.allowed_channels)); + assert!(!resolve_allow_all( + gw.allow_all_channels, + &gw.allowed_channels + )); } #[test] @@ -764,4 +866,29 @@ command = "echo" // explicit flag overrides non-empty list assert!(resolve_allow_all(gw.allow_all_users, &gw.allowed_users)); } + + #[test] + fn stt_echo_transcript_defaults_to_false() { + let cfg = SttConfig::default(); + assert!( + !cfg.echo_transcript, + "echo_transcript should default to false" + ); + } + + #[test] + fn stt_echo_transcript_respects_explicit_false() { + let toml = r#" +[agent] +command = "echo" + +[stt] +enabled = true +api_key = "test" +echo_transcript = false +"#; + let cfg = parse_config(toml, "test").unwrap(); + assert!(cfg.stt.enabled); + assert!(!cfg.stt.echo_transcript); + } } diff --git a/src/cron.rs b/src/cron.rs index 1aec16217..9f39f9b57 100644 --- a/src/cron.rs +++ b/src/cron.rs @@ -1,4 +1,4 @@ -use crate::adapter::{AdapterRouter, ChatAdapter, ChannelRef, SenderContext}; +use crate::adapter::{AdapterRouter, ChannelRef, ChatAdapter, SenderContext}; use crate::config::CronJobConfig; use crate::format; use chrono::{Timelike, Utc}; @@ -24,9 +24,7 @@ pub fn parse_cron_expr(expr: &str) -> Result { /// schedule has an event at exactly that minute. pub fn should_fire(schedule: &Schedule, tz: Tz) -> bool { let now = Utc::now().with_timezone(&tz); - let minute_start = now - .with_second(0).unwrap() - .with_nanosecond(0).unwrap(); + let minute_start = now.with_second(0).unwrap().with_nanosecond(0).unwrap(); let query_from = minute_start - chrono::Duration::seconds(1); schedule .after(&query_from) @@ -39,20 +37,35 @@ pub fn should_fire(schedule: &Schedule, tz: Tz) -> bool { const VALID_PLATFORMS: &[&str] = &["discord", "slack"]; /// Validate all cronjob configs (fail-fast on bad cron expressions or timezones). -pub fn validate_cronjobs(cronjobs: &[CronJobConfig], configured_platforms: &[&str]) -> anyhow::Result<()> { +pub fn validate_cronjobs( + cronjobs: &[CronJobConfig], + configured_platforms: &[&str], +) -> anyhow::Result<()> { for (i, job) in cronjobs.iter().enumerate() { - if !job.enabled { continue; } + if !job.enabled { + continue; + } parse_cron_expr(&job.schedule).map_err(|e| { - anyhow::anyhow!("cronjobs[{i}]: invalid cron expression {:?}: {e}", job.schedule) + anyhow::anyhow!( + "cronjobs[{i}]: invalid cron expression {:?}: {e}", + job.schedule + ) })?; job.timezone.parse::().map_err(|e| { anyhow::anyhow!("cronjobs[{i}]: invalid timezone {:?}: {e}", job.timezone) })?; if !VALID_PLATFORMS.contains(&job.platform.as_str()) { - anyhow::bail!("cronjobs[{i}]: unknown platform {:?} (expected one of: {VALID_PLATFORMS:?})", job.platform); + anyhow::bail!( + "cronjobs[{i}]: unknown platform {:?} (expected one of: {VALID_PLATFORMS:?})", + job.platform + ); } if !configured_platforms.contains(&job.platform.as_str()) { - anyhow::bail!("cronjobs[{i}]: platform {:?} is not configured — add [{}] to config.toml", job.platform, job.platform); + anyhow::bail!( + "cronjobs[{i}]: platform {:?} is not configured — add [{}] to config.toml", + job.platform, + job.platform + ); } } Ok(()) @@ -183,7 +196,9 @@ pub async fn run_scheduler( if baseline_jobs.is_empty() && usercron_jobs.is_empty() { if usercron_path.is_some() { - info!("no cronjobs yet, but usercron_path is set — scheduler will watch for cronjob.toml"); + info!( + "no cronjobs yet, but usercron_path is set — scheduler will watch for cronjob.toml" + ); } else { debug!("no cronjobs configured, scheduler not started"); return; @@ -191,14 +206,23 @@ pub async fn run_scheduler( } let total = baseline_jobs.len() + usercron_jobs.len(); - info!(baseline = baseline_jobs.len(), usercron = usercron_jobs.len(), total, "cron scheduler started"); + info!( + baseline = baseline_jobs.len(), + usercron = usercron_jobs.len(), + total, + "cron scheduler started" + ); let in_flight: Arc>> = Arc::new(Mutex::new(HashSet::new())); // Align to next minute boundary let now = Utc::now(); let secs_into_minute = now.timestamp() % 60; - let align_delay = if secs_into_minute == 0 { 0 } else { 60 - secs_into_minute as u64 }; + let align_delay = if secs_into_minute == 0 { + 0 + } else { + 60 - secs_into_minute as u64 + }; if align_delay > 0 { debug!(align_secs = align_delay, "aligning to next minute boundary"); tokio::time::sleep(std::time::Duration::from_secs(align_delay)).await; @@ -301,7 +325,10 @@ async fn fire_cronjob( adapters: &HashMap>, in_flight: Arc>>, ) { - let _guard = InFlightGuard { idx, set: in_flight }; + let _guard = InFlightGuard { + idx, + set: in_flight, + }; let adapter = match adapters.get(&job.platform) { Some(a) => a.clone(), @@ -319,7 +346,13 @@ async fn fire_cronjob( origin_event_id: None, }; - let trigger_msg = match adapter.send_message(&thread_channel, &format!("🕐 [{}]: {}", job.sender_name, job.message)).await { + let trigger_msg = match adapter + .send_message( + &thread_channel, + &format!("🕐 [{}]: {}", job.sender_name, job.message), + ) + .await + { Ok(msg) => msg, Err(e) => { error!(channel = %job.channel, error = %e, "failed to send cron message"); @@ -331,11 +364,19 @@ async fn fire_cronjob( thread_channel.clone() } else { let thread_name = format::shorten_thread_name(&job.message); - match adapter.create_thread(&thread_channel, &trigger_msg, &thread_name).await { + match adapter + .create_thread(&thread_channel, &trigger_msg, &thread_name) + .await + { Ok(ch) => ch, Err(e) => { error!(channel = %job.channel, error = %e, "failed to create cron thread"); - let _ = adapter.send_message(&thread_channel, &format!("⚠️ cronjob: failed to create thread: {e}")).await; + let _ = adapter + .send_message( + &thread_channel, + &format!("⚠️ cronjob: failed to create thread: {e}"), + ) + .await; return; } } @@ -347,8 +388,15 @@ async fn fire_cronjob( sender_name: job.sender_name.clone(), display_name: job.sender_name.clone(), channel: job.platform.clone(), - channel_id: reply_channel.parent_id.as_deref().unwrap_or(&reply_channel.channel_id).to_string(), - thread_id: reply_channel.thread_id.clone().or(Some(reply_channel.channel_id.clone())), + channel_id: reply_channel + .parent_id + .as_deref() + .unwrap_or(&reply_channel.channel_id) + .to_string(), + thread_id: reply_channel + .thread_id + .clone() + .or(Some(reply_channel.channel_id.clone())), is_bot: true, timestamp: Some(Utc::now().to_rfc3339()), }; @@ -361,18 +409,23 @@ async fn fire_cronjob( }; if let Err(e) = router - .handle_message(&adapter, crate::adapter::MessageContext { - thread_channel: reply_channel.clone(), - sender_json, - prompt: job.message.clone(), - extra_blocks: vec![], - trigger_msg, - other_bot_present: false, - }) + .handle_message( + &adapter, + crate::adapter::MessageContext { + thread_channel: reply_channel.clone(), + sender_json, + prompt: job.message.clone(), + extra_blocks: vec![], + trigger_msg, + other_bot_present: false, + }, + ) .await { error!("cron handle_message error: {e}"); - let _ = adapter.send_message(&reply_channel, &format!("⚠️ cronjob error: {e}")).await; + let _ = adapter + .send_message(&reply_channel, &format!("⚠️ cronjob error: {e}")) + .await; } } @@ -502,12 +555,16 @@ thread_id = "789" fn load_usercron_valid_file() { let dir = tempfile::tempdir().unwrap(); let path = dir.path().join("cronjob.toml"); - std::fs::write(&path, r#" + std::fs::write( + &path, + r#" [[jobs]] schedule = "* * * * *" channel = "123" message = "ping" -"#).unwrap(); +"#, + ) + .unwrap(); let jobs = load_usercron_file(&path, &["discord"]); assert_eq!(jobs.len(), 1); assert_eq!(jobs[0].message, "ping"); @@ -526,7 +583,9 @@ message = "ping" fn load_usercron_skips_invalid_entries() { let dir = tempfile::tempdir().unwrap(); let path = dir.path().join("cronjob.toml"); - std::fs::write(&path, r#" + std::fs::write( + &path, + r#" [[jobs]] schedule = "* * * * *" channel = "123" @@ -536,7 +595,9 @@ message = "good" schedule = "bad cron" channel = "456" message = "bad" -"#).unwrap(); +"#, + ) + .unwrap(); let jobs = load_usercron_file(&path, &["discord"]); assert_eq!(jobs.len(), 1); assert_eq!(jobs[0].message, "good"); @@ -546,7 +607,9 @@ message = "bad" fn load_usercron_skips_unconfigured_platform() { let dir = tempfile::tempdir().unwrap(); let path = dir.path().join("cronjob.toml"); - std::fs::write(&path, r#" + std::fs::write( + &path, + r#" [[jobs]] schedule = "* * * * *" channel = "123" @@ -557,7 +620,9 @@ schedule = "* * * * *" channel = "456" message = "slack job" platform = "slack" -"#).unwrap(); +"#, + ) + .unwrap(); // Only discord configured let jobs = load_usercron_file(&path, &["discord"]); assert_eq!(jobs.len(), 1); @@ -569,9 +634,14 @@ platform = "slack" #[test] fn validate_cronjobs_valid_passes() { let jobs = vec![CronJobConfig { - enabled: true, schedule: "0 9 * * 1-5".into(), channel: "123".into(), - message: "hi".into(), platform: "discord".into(), sender_name: "test".into(), - thread_id: None, timezone: "UTC".into(), + enabled: true, + schedule: "0 9 * * 1-5".into(), + channel: "123".into(), + message: "hi".into(), + platform: "discord".into(), + sender_name: "test".into(), + thread_id: None, + timezone: "UTC".into(), }]; assert!(validate_cronjobs(&jobs, &["discord"]).is_ok()); } @@ -579,9 +649,14 @@ platform = "slack" #[test] fn validate_cronjobs_invalid_cron_fails() { let jobs = vec![CronJobConfig { - enabled: true, schedule: "bad".into(), channel: "123".into(), - message: "hi".into(), platform: "discord".into(), sender_name: "test".into(), - thread_id: None, timezone: "UTC".into(), + enabled: true, + schedule: "bad".into(), + channel: "123".into(), + message: "hi".into(), + platform: "discord".into(), + sender_name: "test".into(), + thread_id: None, + timezone: "UTC".into(), }]; let err = validate_cronjobs(&jobs, &["discord"]).unwrap_err(); assert!(err.to_string().contains("invalid cron expression")); @@ -590,9 +665,14 @@ platform = "slack" #[test] fn validate_cronjobs_invalid_timezone_fails() { let jobs = vec![CronJobConfig { - enabled: true, schedule: "* * * * *".into(), channel: "123".into(), - message: "hi".into(), platform: "discord".into(), sender_name: "test".into(), - thread_id: None, timezone: "Mars/Olympus".into(), + enabled: true, + schedule: "* * * * *".into(), + channel: "123".into(), + message: "hi".into(), + platform: "discord".into(), + sender_name: "test".into(), + thread_id: None, + timezone: "Mars/Olympus".into(), }]; let err = validate_cronjobs(&jobs, &["discord"]).unwrap_err(); assert!(err.to_string().contains("invalid timezone")); @@ -601,9 +681,14 @@ platform = "slack" #[test] fn validate_cronjobs_unknown_platform_fails() { let jobs = vec![CronJobConfig { - enabled: true, schedule: "* * * * *".into(), channel: "123".into(), - message: "hi".into(), platform: "telegram".into(), sender_name: "test".into(), - thread_id: None, timezone: "UTC".into(), + enabled: true, + schedule: "* * * * *".into(), + channel: "123".into(), + message: "hi".into(), + platform: "telegram".into(), + sender_name: "test".into(), + thread_id: None, + timezone: "UTC".into(), }]; let err = validate_cronjobs(&jobs, &["discord"]).unwrap_err(); assert!(err.to_string().contains("unknown platform")); @@ -612,9 +697,14 @@ platform = "slack" #[test] fn validate_cronjobs_unconfigured_platform_fails() { let jobs = vec![CronJobConfig { - enabled: true, schedule: "* * * * *".into(), channel: "123".into(), - message: "hi".into(), platform: "slack".into(), sender_name: "test".into(), - thread_id: None, timezone: "UTC".into(), + enabled: true, + schedule: "* * * * *".into(), + channel: "123".into(), + message: "hi".into(), + platform: "slack".into(), + sender_name: "test".into(), + thread_id: None, + timezone: "UTC".into(), }]; let err = validate_cronjobs(&jobs, &["discord"]).unwrap_err(); assert!(err.to_string().contains("not configured")); @@ -623,9 +713,14 @@ platform = "slack" #[test] fn validate_cronjobs_disabled_with_invalid_cron_passes() { let jobs = vec![CronJobConfig { - enabled: false, schedule: "bad".into(), channel: "123".into(), - message: "hi".into(), platform: "discord".into(), sender_name: "test".into(), - thread_id: None, timezone: "UTC".into(), + enabled: false, + schedule: "bad".into(), + channel: "123".into(), + message: "hi".into(), + platform: "discord".into(), + sender_name: "test".into(), + thread_id: None, + timezone: "UTC".into(), }]; assert!(validate_cronjobs(&jobs, &["discord"]).is_ok()); } @@ -633,9 +728,14 @@ platform = "slack" #[test] fn validate_cronjobs_enabled_with_invalid_cron_still_fails() { let jobs = vec![CronJobConfig { - enabled: true, schedule: "bad".into(), channel: "123".into(), - message: "hi".into(), platform: "discord".into(), sender_name: "test".into(), - thread_id: None, timezone: "UTC".into(), + enabled: true, + schedule: "bad".into(), + channel: "123".into(), + message: "hi".into(), + platform: "discord".into(), + sender_name: "test".into(), + thread_id: None, + timezone: "UTC".into(), }]; assert!(validate_cronjobs(&jobs, &["discord"]).is_err()); } diff --git a/src/discord.rs b/src/discord.rs index 13987deae..a8b27be26 100644 --- a/src/discord.rs +++ b/src/discord.rs @@ -1,23 +1,27 @@ -use crate::acp::ContentBlock; use crate::acp::protocol::ConfigOption; -use crate::adapter::{AdapterRouter, ChatAdapter, ChannelRef, MessageRef, SenderContext}; +use crate::acp::ContentBlock; +use crate::adapter::{AdapterRouter, ChannelRef, ChatAdapter, MessageRef, SenderContext}; use crate::bot_turns::{BotTurnTracker, TurnAction, TurnSeverity}; use crate::config::{AllowBots, AllowUsers, SttConfig}; use crate::format; use crate::media; use async_trait::async_trait; -use std::sync::LazyLock; -use serenity::builder::{CreateActionRow, CreateButton, CreateCommand, CreateInteractionResponse, CreateInteractionResponseMessage, CreateSelectMenu, CreateSelectMenuKind, CreateSelectMenuOption, CreateThread, EditMessage}; -use serenity::model::application::ButtonStyle; +use serenity::builder::{ + CreateActionRow, CreateButton, CreateCommand, CreateInteractionResponse, + CreateInteractionResponseMessage, CreateSelectMenu, CreateSelectMenuKind, + CreateSelectMenuOption, CreateThread, EditMessage, +}; use serenity::http::Http; +use serenity::model::application::ButtonStyle; use serenity::model::application::{Command, ComponentInteractionDataKind, Interaction}; use serenity::model::channel::{AutoArchiveDuration, Message, MessageType, ReactionType}; use serenity::model::gateway::Ready; use serenity::model::id::{ChannelId, MessageId, UserId}; use serenity::prelude::*; use std::collections::{HashMap, HashSet}; +use std::sync::LazyLock; use std::sync::{Arc, OnceLock}; -use tracing::{debug, error, info}; +use tracing::{debug, error, info, warn}; /// Hard cap on consecutive bot messages in a channel or thread. /// Prevents runaway loops between multiple bots in "all" mode. @@ -57,7 +61,11 @@ impl ChatAdapter for DiscordAdapter { 2000 } - async fn send_message(&self, channel: &ChannelRef, content: &str) -> anyhow::Result { + async fn send_message( + &self, + channel: &ChannelRef, + content: &str, + ) -> anyhow::Result { let ch_id: u64 = Self::resolve_channel(channel).parse()?; let msg = ChannelId::new(ch_id).say(&self.http, content).await?; Ok(MessageRef { @@ -181,11 +189,15 @@ impl Handler { // Check positive caches let cached_involved = { let cache = self.participated_threads.lock().await; - cache.get(&key).is_some_and(|ts| ts.elapsed() < self.session_ttl) + cache + .get(&key) + .is_some_and(|ts| ts.elapsed() < self.session_ttl) }; let cached_multibot = { let cache = self.multibot_threads.lock().await; - cache.get(&key).is_some_and(|ts| ts.elapsed() < self.session_ttl) + cache + .get(&key) + .is_some_and(|ts| ts.elapsed() < self.session_ttl) }; // Both cached → skip fetch entirely @@ -212,7 +224,10 @@ impl Handler { }; let involved = cached_involved || messages.iter().any(|m| m.author.id == bot_id); - let other_bot_present = cached_multibot || messages.iter().any(|m| m.author.bot && m.author.id != bot_id); + let other_bot_present = cached_multibot + || messages + .iter() + .any(|m| m.author.bot && m.author.id != bot_id); if involved && !cached_involved { let mut cache = self.participated_threads.lock().await; @@ -277,7 +292,11 @@ impl EventHandler for Handler { match tracker.classify_bot_message(&thread_key) { TurnAction::Continue => {} TurnAction::SilentStop => return, - TurnAction::WarnAndStop { severity, turns, user_message } => { + TurnAction::WarnAndStop { + severity, + turns, + user_message, + } => { match severity { TurnSeverity::Hard => tracing::warn!( channel_id = %msg.channel_id, @@ -350,28 +369,36 @@ impl EventHandler for Handler { return; } - let adapter = self.adapter.get_or_init(|| { - Arc::new(DiscordAdapter::new(ctx.http.clone())) - }).clone(); + let adapter = self + .adapter + .get_or_init(|| Arc::new(DiscordAdapter::new(ctx.http.clone()))) + .clone(); let channel_id = msg.channel_id.get(); let in_allowed_channel = self.allow_all_channels || self.allowed_channels.contains(&channel_id); - let is_mentioned = msg.mentions_user_id(bot_id) - || msg.content.contains(&format!("<@{}>", bot_id)); + let is_mentioned = + msg.mentions_user_id(bot_id) || msg.content.contains(&format!("<@{}>", bot_id)); // Bot message gating (from upstream #321) if msg.author.bot { match self.allow_bot_messages { AllowBots::Off => return, - AllowBots::Mentions => if !is_mentioned { return; }, + AllowBots::Mentions => { + if !is_mentioned { + return; + } + } AllowBots::All => { let cap = MAX_CONSECUTIVE_BOT_TURNS as usize; let limit = std::cmp::min(MAX_CONSECUTIVE_BOT_TURNS, 100) as u8; - let history = ctx.cache.channel_messages(msg.channel_id) + let history = ctx + .cache + .channel_messages(msg.channel_id) .map(|msgs| { - let mut recent: Vec<_> = msgs.iter() + let mut recent: Vec<_> = msgs + .iter() .filter(|(mid, _)| **mid < msg.id) .map(|(_, m)| m.clone()) .collect(); @@ -384,8 +411,14 @@ impl EventHandler for Handler { let recent = if let Some(cached) = history { cached } else { - match msg.channel_id - .messages(&ctx.http, serenity::builder::GetMessages::new().before(msg.id).limit(limit)) + match msg + .channel_id + .messages( + &ctx.http, + serenity::builder::GetMessages::new() + .before(msg.id) + .limit(limit), + ) .await { Ok(msgs) => msgs, @@ -396,17 +429,20 @@ impl EventHandler for Handler { } }; - let consecutive_bot = recent.iter() + let consecutive_bot = recent + .iter() .take_while(|m| m.author.bot && m.author.id != bot_id) .count(); if consecutive_bot >= cap { tracing::warn!(channel_id = %msg.channel_id, cap, "bot turn cap reached, ignoring"); return; } - }, + } } - if !self.trusted_bot_ids.is_empty() && !self.trusted_bot_ids.contains(&msg.author.id.get()) { + if !self.trusted_bot_ids.is_empty() + && !self.trusted_bot_ids.contains(&msg.author.id.get()) + { tracing::debug!(bot_id = %msg.author.id, "bot not in trusted_bot_ids, ignoring"); return; } @@ -415,7 +451,11 @@ impl EventHandler for Handler { // Thread detection: single to_channel() call for both allowed and // non-allowed channels. Uses thread_metadata (not parent_id) to // identify threads — see detect_thread() doc comments for rationale. - let (in_thread, bot_owns_thread, thread_parent_id, is_dm) = match msg.channel_id.to_channel(&ctx.http).await { + let (in_thread, bot_owns_thread, thread_parent_id, is_dm) = match msg + .channel_id + .to_channel(&ctx.http) + .await + { Ok(serenity::model::channel::Channel::Guild(gc)) => { let parent = gc.parent_id.map(|id| id.get().to_string()); let result = detect_thread( @@ -436,7 +476,12 @@ impl EventHandler for Handler { bot_owns = ?result.1, "thread check" ); - (result.0, result.1.unwrap_or(false), if result.0 { parent } else { None }, false) + ( + result.0, + result.1.unwrap_or(false), + if result.0 { parent } else { None }, + false, + ) } Ok(serenity::model::channel::Channel::Private(_)) => { tracing::debug!(channel_id = %msg.channel_id, "DM channel"); @@ -513,7 +558,12 @@ impl EventHandler for Handler { } } - if is_denied_user(msg.author.bot, self.allow_all_users, &self.allowed_users, msg.author.id.get()) { + if is_denied_user( + msg.author.bot, + self.allow_all_users, + &self.allowed_users, + msg.author.id.get(), + ) { tracing::info!(user_id = %msg.author.id, "denied user, ignoring"); let msg_ref = discord_msg_ref(&msg); let _ = adapter.add_reaction(&msg_ref, "🚫").await; @@ -544,6 +594,7 @@ impl EventHandler for Handler { // Build extra content blocks from attachments (audio → STT, text → inline, image → encode) let mut extra_blocks = Vec::new(); + let mut echo_entries: Vec = Vec::new(); let mut text_file_bytes: u64 = 0; let mut text_file_count: u32 = 0; const TEXT_TOTAL_CAP: u64 = 1024 * 1024; // 1 MB total for all text file attachments @@ -554,25 +605,38 @@ impl EventHandler for Handler { if media::is_audio_mime(mime) { if self.stt_config.enabled { let mime_clean = mime.split(';').next().unwrap_or(mime).trim(); - if let Some(transcript) = media::download_and_transcribe( + match media::download_and_transcribe( &attachment.url, &attachment.filename, mime_clean, u64::from(attachment.size), &self.stt_config, None, - ).await { - debug!(filename = %attachment.filename, chars = transcript.len(), "voice transcript injected"); - extra_blocks.insert(0, ContentBlock::Text { - text: format!("[Voice message transcript]: {transcript}"), - }); + ) + .await + { + Some(transcript) => { + debug!(filename = %attachment.filename, chars = transcript.len(), "voice transcript injected"); + extra_blocks.insert( + 0, + ContentBlock::Text { + text: format!("[Voice message transcript]: {transcript}"), + }, + ); + echo_entries.push(crate::stt::EchoEntry::Success(transcript)); + } + None => { + warn!(filename = %attachment.filename, "STT failed for voice attachment"); + echo_entries.push(crate::stt::EchoEntry::Failed); + } } } else { tracing::warn!(filename = %attachment.filename, "skipping audio attachment (STT disabled)"); let msg_ref = discord_msg_ref(&msg); let _ = adapter.add_reaction(&msg_ref, "🎤").await; } - } else if media::is_text_file(&attachment.filename, attachment.content_type.as_deref()) { + } else if media::is_text_file(&attachment.filename, attachment.content_type.as_deref()) + { if text_file_count >= TEXT_FILE_COUNT_CAP { tracing::warn!(filename = %attachment.filename, count = text_file_count, "text file count cap reached, skipping"); continue; @@ -588,7 +652,9 @@ impl EventHandler for Handler { &attachment.filename, u64::from(attachment.size), None, - ).await { + ) + .await + { text_file_bytes += actual_bytes; text_file_count += 1; debug!(filename = %attachment.filename, "adding text file attachment"); @@ -600,7 +666,9 @@ impl EventHandler for Handler { &attachment.filename, u64::from(attachment.size), None, - ).await { + ) + .await + { debug!(url = %attachment.url, filename = %attachment.filename, "adding image attachment"); extra_blocks.push(block); } @@ -649,15 +717,24 @@ impl EventHandler for Handler { } let dispatcher = self.dispatcher.clone(); + let stt_cfg = self.stt_config.clone(); tokio::spawn(async move { + // Best-effort echo before the agent reply so the user can verify STT. + crate::stt::post_echo( + &adapter, + &thread_channel, + &trigger_msg, + &echo_entries, + &stt_cfg, + ) + .await; + let sender_id = sender.sender_id.clone(); let sender_name = sender.sender_name.clone(); let sender_json = serde_json::to_string(&sender).unwrap(); - let thread_key = - dispatcher.key("discord", &thread_channel.channel_id, &sender_id); - let estimated_tokens = - crate::dispatch::estimate_tokens(&prompt, &extra_blocks); + let thread_key = dispatcher.key("discord", &thread_channel.channel_id, &sender_id); + let estimated_tokens = crate::dispatch::estimate_tokens(&prompt, &extra_blocks); let buf_msg = crate::dispatch::BufferedMessage { sender_json, sender_name, @@ -682,16 +759,12 @@ impl EventHandler for Handler { // Build the shared command list once. let commands = vec![ - CreateCommand::new("models") - .description("Select the AI model for this session"), - CreateCommand::new("agents") - .description("Select the agent mode for this session"), - CreateCommand::new("cancel") - .description("Cancel the current operation"), + CreateCommand::new("models").description("Select the AI model for this session"), + CreateCommand::new("agents").description("Select the agent mode for this session"), + CreateCommand::new("cancel").description("Cancel the current operation"), CreateCommand::new("cancel-all") .description("Cancel current operation and drop all buffered messages"), - CreateCommand::new("reset") - .description("Reset the conversation session"), + CreateCommand::new("reset").description("Reset the conversation session"), ]; // Register global commands (works in DMs + all guilds after propagation). @@ -704,10 +777,7 @@ impl EventHandler for Handler { // Also register per-guild for instant availability (global can take up to 1h). for guild in &ready.guilds { let guild_id = guild.id; - if let Err(e) = guild_id - .set_commands(&ctx.http, commands.clone()) - .await - { + if let Err(e) = guild_id.set_commands(&ctx.http, commands.clone()).await { tracing::warn!(%guild_id, error = %e, "failed to register guild slash commands"); } else { info!(%guild_id, "registered guild slash commands"); @@ -718,10 +788,12 @@ impl EventHandler for Handler { async fn interaction_create(&self, ctx: Context, interaction: Interaction) { match interaction { Interaction::Command(cmd) if cmd.data.name == "models" => { - self.handle_config_command(&ctx, &cmd, "model", "model").await; + self.handle_config_command(&ctx, &cmd, "model", "model") + .await; } Interaction::Command(cmd) if cmd.data.name == "agents" => { - self.handle_config_command(&ctx, &cmd, "agent", "agent").await; + self.handle_config_command(&ctx, &cmd, "agent", "agent") + .await; } Interaction::Command(cmd) if cmd.data.name == "cancel" => { self.handle_cancel_command(&ctx, &cmd).await; @@ -743,19 +815,26 @@ impl EventHandler for Handler { } } - // --- Slash command & interaction handlers --- impl Handler { /// Build a Discord select menu from ACP configOptions with the given category. /// Paginates options in pages of 25 (Discord limit). The current selection is /// always placed first so it appears on page 0. - fn build_config_select(options: &[ConfigOption], category: &str, page: usize) -> Option { - let opt = options.iter().find(|o| o.category.as_deref() == Some(category))?; + fn build_config_select( + options: &[ConfigOption], + category: &str, + page: usize, + ) -> Option { + let opt = options + .iter() + .find(|o| o.category.as_deref() == Some(category))?; // Put current selection first so it always lands on page 0, // then fill remaining slots in original order. - let sorted: Vec<_> = opt.options.iter() + let sorted: Vec<_> = opt + .options + .iter() .filter(|o| o.value == opt.current_value) .chain(opt.options.iter().filter(|o| o.value != opt.current_value)) .collect(); @@ -780,13 +859,20 @@ impl Handler { return None; } - let current_name = opt.options.iter() + let current_name = opt + .options + .iter() .find(|o| o.value == opt.current_value) .map(|o| o.name.as_str()) .unwrap_or(&opt.current_value); let total_pages = sorted.len().div_ceil(SELECT_MENU_PAGE_SIZE); let placeholder = if total_pages > 1 { - format!("Current: {} (page {}/{})", current_name, page + 1, total_pages) + format!( + "Current: {} (page {}/{})", + current_name, + page + 1, + total_pages + ) } else { format!("Current: {}", current_name) }; @@ -794,14 +880,20 @@ impl Handler { Some( CreateSelectMenu::new( format!("acp_config_{}", opt.id), - CreateSelectMenuKind::String { options: menu_options }, + CreateSelectMenuKind::String { + options: menu_options, + }, ) - .placeholder(placeholder) + .placeholder(placeholder), ) } /// Build ◀/▶ pagination buttons. Returns None when only one page exists. - fn build_pagination_buttons(category: &str, page: usize, total_pages: usize) -> Option { + fn build_pagination_buttons( + category: &str, + page: usize, + total_pages: usize, + ) -> Option { if total_pages <= 1 { return None; } @@ -822,12 +914,20 @@ impl Handler { /// Build the full component rows (select menu + optional pagination) for a config category. /// When `page` is `None`, auto-selects the page containing the current value. - fn build_config_components(options: &[ConfigOption], category: &str, page: Option) -> Option> { - let opt = options.iter().find(|o| o.category.as_deref() == Some(category))?; + fn build_config_components( + options: &[ConfigOption], + category: &str, + page: Option, + ) -> Option> { + let opt = options + .iter() + .find(|o| o.category.as_deref() == Some(category))?; let total_pages = opt.options.len().div_ceil(SELECT_MENU_PAGE_SIZE); let page = match page { Some(p) => p.min(total_pages.saturating_sub(1)), - None => opt.options.iter() + None => opt + .options + .iter() .position(|o| o.value == opt.current_value) .map(|i| i / SELECT_MENU_PAGE_SIZE) .unwrap_or(0), @@ -884,7 +984,9 @@ impl Handler { }; let response = CreateInteractionResponse::Message( - CreateInteractionResponseMessage::new().content(msg).ephemeral(true), + CreateInteractionResponseMessage::new() + .content(msg) + .ephemeral(true), ); if let Err(e) = cmd.create_response(&ctx.http, response).await { tracing::error!(error = %e, "failed to respond to /cancel command"); @@ -910,12 +1012,16 @@ impl Handler { let msg = match (cancel_result, dropped) { (Ok(()), 0) => "🛑 Cancel signal sent.".to_string(), (Ok(()), _) => "🛑 Cancel signal sent. Buffered messages cleared.".to_string(), - (Err(_), 0) => "⚠️ Nothing to cancel — no active session and no buffered messages.".to_string(), + (Err(_), 0) => { + "⚠️ Nothing to cancel — no active session and no buffered messages.".to_string() + } (Err(_), _) => "🛑 Buffered messages cleared. No active session to cancel.".to_string(), }; let response = CreateInteractionResponse::Message( - CreateInteractionResponseMessage::new().content(msg).ephemeral(true), + CreateInteractionResponseMessage::new() + .content(msg) + .ephemeral(true), ); if let Err(e) = cmd.create_response(&ctx.http, response).await { tracing::error!(error = %e, "failed to respond to /cancel-all command"); @@ -944,11 +1050,16 @@ impl Handler { Err(_) if dropped > 0 => { format!("🔄 Dropped {dropped} buffered message(s). No active session to reset.") } - Err(_) => "⚠️ No active session to reset. Start a conversation first by @mentioning the bot.".to_string(), + Err(_) => { + "⚠️ No active session to reset. Start a conversation first by @mentioning the bot." + .to_string() + } }; let response = CreateInteractionResponse::Message( - CreateInteractionResponseMessage::new().content(msg).ephemeral(true), + CreateInteractionResponseMessage::new() + .content(msg) + .ephemeral(true), ); if let Err(e) = cmd.create_response(&ctx.http, response).await { tracing::error!(error = %e, "failed to respond to /reset command"); @@ -972,12 +1083,10 @@ impl Handler { } let selected_value = match &comp.data.kind { - ComponentInteractionDataKind::StringSelect { values } => { - match values.first() { - Some(v) => v.clone(), - None => return, - } - } + ComponentInteractionDataKind::StringSelect { values } => match values.first() { + Some(v) => v.clone(), + None => return, + }, _ => return, }; @@ -1006,7 +1115,9 @@ impl Handler { }; let response = CreateInteractionResponse::UpdateMessage( - CreateInteractionResponseMessage::new().content(response_msg).components(vec![]), + CreateInteractionResponseMessage::new() + .content(response_msg) + .components(vec![]), ); if let Err(e) = comp.create_response(&ctx.http, response).await { @@ -1100,7 +1211,10 @@ async fn get_or_create_thread( origin_event_id: None, }; let trigger_ref = discord_msg_ref(msg); - match adapter.create_thread(&parent, &trigger_ref, &thread_name).await { + match adapter + .create_thread(&parent, &trigger_ref, &thread_name) + .await + { Ok(ch) => Ok(ch), Err(e) if is_thread_already_exists_error(&e) => { // Another bot won the race from the same trigger message. Discord @@ -1110,9 +1224,9 @@ async fn get_or_create_thread( .channel_id .message(&ctx.http, msg.id) .await - .map_err(|fe| anyhow::anyhow!( - "thread_already_exists (race), but refetch failed: {fe}" - ))?; + .map_err(|fe| { + anyhow::anyhow!("thread_already_exists (race), but refetch failed: {fe}") + })?; let existing = refreshed.thread.ok_or_else(|| { anyhow::anyhow!( "thread_already_exists (race), but message has no thread after refetch" @@ -1147,9 +1261,8 @@ fn is_thread_already_exists_error(err: &anyhow::Error) -> bool { msg.contains("160004") || msg.contains("already been created") } -static ROLE_MENTION_RE: LazyLock = LazyLock::new(|| { - regex::Regex::new(r"<@&\d+>").unwrap() -}); +static ROLE_MENTION_RE: LazyLock = + LazyLock::new(|| regex::Regex::new(r"<@&\d+>").unwrap()); fn resolve_mentions(content: &str, bot_id: UserId) -> String { // 1. Strip the bot's own trigger mention @@ -1236,7 +1349,12 @@ fn detect_thread( /// Returns `true` if the author should be denied by the user allowlist. /// Bot authors skip this check — they are gated by `allow_bot_messages` + `trusted_bot_ids`. -fn is_denied_user(is_bot: bool, allow_all_users: bool, allowed_users: &HashSet, user_id: u64) -> bool { +fn is_denied_user( + is_bot: bool, + allow_all_users: bool, + allowed_users: &HashSet, + user_id: u64, +) -> bool { !is_bot && !allow_all_users && !allowed_users.contains(&user_id) } @@ -1290,7 +1408,7 @@ fn should_process_user_message( #[cfg(test)] mod tests { use super::*; - use crate::bot_turns::{HARD_BOT_TURN_LIMIT, TurnResult}; + use crate::bot_turns::{TurnResult, HARD_BOT_TURN_LIMIT}; // --- resolve_mentions tests --- @@ -1376,10 +1494,10 @@ mod tests { fn multibot_mentions_single_bot_thread_no_mention() { assert!(should_process_user_message( AllowUsers::MultibotMentions, - false, // is_mentioned - true, // in_thread - true, // involved - false, // other_bot_present + false, // is_mentioned + true, // in_thread + true, // involved + false, // other_bot_present )); } @@ -1391,10 +1509,10 @@ mod tests { fn multibot_mentions_multi_bot_thread_no_mention() { assert!(!should_process_user_message( AllowUsers::MultibotMentions, - false, // is_mentioned - true, // in_thread - true, // involved - true, // other_bot_present ← another bot posted + false, // is_mentioned + true, // in_thread + true, // involved + true, // other_bot_present ← another bot posted )); } @@ -1405,10 +1523,10 @@ mod tests { fn multibot_mentions_multi_bot_thread_with_mention() { assert!(should_process_user_message( AllowUsers::MultibotMentions, - true, // is_mentioned - true, // in_thread - true, // involved - true, // other_bot_present + true, // is_mentioned + true, // in_thread + true, // involved + true, // other_bot_present )); } @@ -1419,10 +1537,10 @@ mod tests { fn multibot_mentions_main_channel_no_mention() { assert!(!should_process_user_message( AllowUsers::MultibotMentions, - false, // is_mentioned - false, // in_thread (main channel) - false, // involved - false, // other_bot_present + false, // is_mentioned + false, // in_thread (main channel) + false, // involved + false, // other_bot_present )); } @@ -1433,10 +1551,10 @@ mod tests { fn multibot_mentions_not_involved() { assert!(!should_process_user_message( AllowUsers::MultibotMentions, - false, // is_mentioned - true, // in_thread - false, // involved ← bot hasn't posted here - false, // other_bot_present + false, // is_mentioned + true, // in_thread + false, // involved ← bot hasn't posted here + false, // other_bot_present )); } @@ -1447,10 +1565,10 @@ mod tests { fn involved_mode_ignores_multibot() { assert!(should_process_user_message( AllowUsers::Involved, - false, // is_mentioned - true, // in_thread - true, // involved - true, // other_bot_present ← ignored in involved mode + false, // is_mentioned + true, // in_thread + true, // involved + true, // other_bot_present ← ignored in involved mode )); } @@ -1461,10 +1579,10 @@ mod tests { fn mentions_mode_always_requires_mention() { assert!(!should_process_user_message( AllowUsers::Mentions, - false, // is_mentioned - true, // in_thread - true, // involved - false, // other_bot_present + false, // is_mentioned + true, // in_thread + true, // involved + false, // other_bot_present )); } @@ -1518,7 +1636,15 @@ mod tests { /// In-thread message: channel_id = parent, thread_id = thread channel ID. #[test] fn build_sender_context_in_thread() { - let ctx = build_sender_context("user1", "alice", "Alice", "thread_ch", Some("parent_ch"), false, "2026-05-01T00:00:00Z"); + let ctx = build_sender_context( + "user1", + "alice", + "Alice", + "thread_ch", + Some("parent_ch"), + false, + "2026-05-01T00:00:00Z", + ); assert_eq!(ctx.channel_id, "parent_ch"); assert_eq!(ctx.thread_id, Some("thread_ch".to_string())); assert_eq!(ctx.channel, "discord"); @@ -1529,7 +1655,15 @@ mod tests { /// Non-thread message: channel_id = message channel, thread_id = None. #[test] fn build_sender_context_not_in_thread() { - let ctx = build_sender_context("user1", "alice", "Alice", "main_ch", None, false, "2026-05-01T00:00:00Z"); + let ctx = build_sender_context( + "user1", + "alice", + "Alice", + "main_ch", + None, + false, + "2026-05-01T00:00:00Z", + ); assert_eq!(ctx.channel_id, "main_ch"); assert_eq!(ctx.thread_id, None); } @@ -1537,7 +1671,15 @@ mod tests { /// Bot sender: is_bot flag propagated correctly. #[test] fn build_sender_context_bot_sender() { - let ctx = build_sender_context("bot1", "mybot", "MyBot", "ch", Some("parent"), true, "2026-05-01T00:00:00Z"); + let ctx = build_sender_context( + "bot1", + "mybot", + "MyBot", + "ch", + Some("parent"), + true, + "2026-05-01T00:00:00Z", + ); assert!(ctx.is_bot); assert_eq!(ctx.channel_id, "parent"); assert_eq!(ctx.thread_id, Some("ch".to_string())); @@ -1704,8 +1846,12 @@ mod tests { let category_id: u64 = 200; let allowed = HashSet::from([category_id]); // Category child: has parent_id (the category) but NO thread_metadata. - let (in_thread, _) = detect_thread(false, Some(category_id), None, 1000, &allowed, false, false); - assert!(!in_thread, "category child must not match allowed_channels via parent_id"); + let (in_thread, _) = + detect_thread(false, Some(category_id), None, 1000, &allowed, false, false); + assert!( + !in_thread, + "category child must not match allowed_channels via parent_id" + ); } // --- Per-thread streaming tests (#534) --- @@ -1825,10 +1971,10 @@ mod tests { // because is_mentioned=false and in_thread=false. assert!(!should_process_user_message( AllowUsers::Involved, - false, // is_mentioned (DMs don't have @mention) - false, // in_thread (DMs are not threads) - false, // involved - false, // other_bot_present + false, // is_mentioned (DMs don't have @mention) + false, // in_thread (DMs are not threads) + false, // involved + false, // other_bot_present )); } diff --git a/src/dispatch.rs b/src/dispatch.rs index 1667521cd..a3fbec886 100644 --- a/src/dispatch.rs +++ b/src/dispatch.rs @@ -17,8 +17,8 @@ use anyhow::Result; use async_trait::async_trait; use tracing::{debug, error, info, info_span, warn}; -use crate::adapter::{AdapterRouter, ChannelRef, ChatAdapter, MessageRef}; use crate::acp::ContentBlock; +use crate::adapter::{AdapterRouter, ChannelRef, ChatAdapter, MessageRef}; use crate::config::ReactionsConfig; use crate::error_display::format_user_error; use crate::reactions::StatusReactionController; @@ -196,9 +196,19 @@ pub fn dispatch_params( ) -> (usize, BatchGrouping, Duration) { use crate::config::MessageProcessingMode; match mode { - MessageProcessingMode::Message => (1, BatchGrouping::Thread, PER_MESSAGE_CONSUMER_IDLE_TIMEOUT), - MessageProcessingMode::Thread => (max_buffered, BatchGrouping::Thread, DEFAULT_CONSUMER_IDLE_TIMEOUT), - MessageProcessingMode::Lane => (max_buffered, BatchGrouping::Lane, DEFAULT_CONSUMER_IDLE_TIMEOUT), + MessageProcessingMode::Message => { + (1, BatchGrouping::Thread, PER_MESSAGE_CONSUMER_IDLE_TIMEOUT) + } + MessageProcessingMode::Thread => ( + max_buffered, + BatchGrouping::Thread, + DEFAULT_CONSUMER_IDLE_TIMEOUT, + ), + MessageProcessingMode::Lane => ( + max_buffered, + BatchGrouping::Lane, + DEFAULT_CONSUMER_IDLE_TIMEOUT, + ), } } @@ -394,7 +404,10 @@ impl Dispatcher { let _ = adapter .send_message( &thread_channel, - &format!("⚠️ {}", format_user_error("dispatch consumer exited unexpectedly")), + &format!( + "⚠️ {}", + format_user_error("dispatch consumer exited unexpectedly") + ), ) .await; return Err(DispatchError::ConsumerDead); @@ -740,11 +753,8 @@ mod tests { #[test] fn pack_arrival_event_single() { - let blocks = AdapterRouter::pack_arrival_event( - r#"{"schema":"openab.sender.v1"}"#, - "hello", - vec![], - ); + let blocks = + AdapterRouter::pack_arrival_event(r#"{"schema":"openab.sender.v1"}"#, "hello", vec![]); // sender_context delimiter + prompt = 2 blocks assert_eq!(blocks.len(), 2); if let ContentBlock::Text { text } = &blocks[0] { @@ -765,14 +775,23 @@ mod tests { #[test] fn pack_arrival_event_with_extra_blocks() { let extra = vec![ - ContentBlock::Text { text: "[Voice transcript]: hi".into() }, - ContentBlock::Image { media_type: "image/png".into(), data: "abc".into() }, + ContentBlock::Text { + text: "[Voice transcript]: hi".into(), + }, + ContentBlock::Image { + media_type: "image/png".into(), + data: "abc".into(), + }, ]; let blocks = AdapterRouter::pack_arrival_event("{}", "prompt", extra); // delimiter + transcript + prompt + image = 4 blocks assert_eq!(blocks.len(), 4); - assert!(matches!(&blocks[0], ContentBlock::Text { text } if text.contains(""))); - assert!(matches!(&blocks[1], ContentBlock::Text { text } if text.contains("Voice transcript"))); + assert!( + matches!(&blocks[0], ContentBlock::Text { text } if text.contains("")) + ); + assert!( + matches!(&blocks[1], ContentBlock::Text { text } if text.contains("Voice transcript")) + ); assert!(matches!(&blocks[2], ContentBlock::Text { text } if text == "prompt")); assert!(matches!(&blocks[3], ContentBlock::Image { .. })); } @@ -781,8 +800,16 @@ mod tests { fn pack_arrival_event_batch_n2() { // Two arrival events concatenated → 2 (header + prompt) pairs = 4 blocks. let mut all: Vec = Vec::new(); - all.extend(AdapterRouter::pack_arrival_event(r#"{"ts":"T1"}"#, "msg1", vec![])); - all.extend(AdapterRouter::pack_arrival_event(r#"{"ts":"T2"}"#, "msg2", vec![])); + all.extend(AdapterRouter::pack_arrival_event( + r#"{"ts":"T1"}"#, + "msg1", + vec![], + )); + all.extend(AdapterRouter::pack_arrival_event( + r#"{"ts":"T2"}"#, + "msg2", + vec![], + )); assert_eq!(all.len(), 4); if let ContentBlock::Text { text } = &all[0] { assert!(text.contains(r#""ts":"T1""#)); @@ -1095,7 +1122,7 @@ mod tests { insert_dummy_handle(&d, "discord:T1:userA"); insert_dummy_handle(&d, "discord:T1:userB"); insert_dummy_handle(&d, "discord:T2:userA"); // different thread - insert_dummy_handle(&d, "slack:T1:userA"); // different platform + insert_dummy_handle(&d, "slack:T1:userA"); // different platform d.cancel_buffered_thread("discord", "T1"); let map = d.per_thread.lock().unwrap(); assert!(!map.contains_key("discord:T1:userA")); @@ -1268,11 +1295,18 @@ mod tests { #[async_trait] impl ChatAdapter for MockChatAdapter { - fn platform(&self) -> &'static str { "mock" } - fn message_limit(&self) -> usize { 2000 } + fn platform(&self) -> &'static str { + "mock" + } + fn message_limit(&self) -> usize { + 2000 + } async fn send_message(&self, channel: &ChannelRef, _content: &str) -> Result { - Ok(MessageRef { channel: channel.clone(), message_id: "mock-msg".into() }) + Ok(MessageRef { + channel: channel.clone(), + message_id: "mock-msg".into(), + }) } async fn create_thread( @@ -1284,9 +1318,15 @@ mod tests { Ok(channel.clone()) } - async fn add_reaction(&self, _msg: &MessageRef, _emoji: &str) -> Result<()> { Ok(()) } - async fn remove_reaction(&self, _msg: &MessageRef, _emoji: &str) -> Result<()> { Ok(()) } - fn use_streaming(&self, _other_bot_present: bool) -> bool { false } + async fn add_reaction(&self, _msg: &MessageRef, _emoji: &str) -> Result<()> { + Ok(()) + } + async fn remove_reaction(&self, _msg: &MessageRef, _emoji: &str) -> Result<()> { + Ok(()) + } + fn use_streaming(&self, _other_bot_present: bool) -> bool { + false + } } fn make_channel(thread: &str) -> ChannelRef { @@ -1301,7 +1341,8 @@ mod tests { fn make_msg(prompt: &str, tokens: usize) -> BufferedMessage { BufferedMessage { - sender_json: r#"{"schema":"openab.sender.v1","sender_id":"u","sender_name":"u"}"#.into(), + sender_json: r#"{"schema":"openab.sender.v1","sender_id":"u","sender_name":"u"}"# + .into(), sender_name: "u".into(), prompt: prompt.into(), extra_blocks: vec![], @@ -1403,7 +1444,10 @@ mod tests { )); // Wait enough for the timeout branch + a tick for the task to finish. tokio::time::sleep(Duration::from_millis(150)).await; - assert!(consumer.is_finished(), "consumer should exit after idle timeout"); + assert!( + consumer.is_finished(), + "consumer should exit after idle timeout" + ); // No dispatches should have been recorded. assert!(mock.calls().is_empty()); drop(tx); @@ -1417,7 +1461,13 @@ mod tests { // whose consumer is still parked but whose rx has been dropped. let mock = Arc::new(MockDispatchTarget::new()); let target: Arc = mock.clone(); - let d = Dispatcher::with_idle_timeout(target, 10, 24_000, BatchGrouping::Thread, DEFAULT_CONSUMER_IDLE_TIMEOUT); + let d = Dispatcher::with_idle_timeout( + target, + 10, + 24_000, + BatchGrouping::Thread, + DEFAULT_CONSUMER_IDLE_TIMEOUT, + ); let adapter: Arc = Arc::new(MockChatAdapter); let key = "mock:T".to_string(); @@ -1444,7 +1494,11 @@ mod tests { tokio::time::sleep(Duration::from_millis(50)).await; let calls = mock.calls(); - assert_eq!(calls.len(), 1, "fresh consumer should have dispatched the retry"); + assert_eq!( + calls.len(), + 1, + "fresh consumer should have dispatched the retry" + ); // pack_arrival_event with no extra_blocks → delimiter + prompt = 2 blocks. assert_eq!(calls[0].block_count, 2); diff --git a/src/error_display.rs b/src/error_display.rs index 40f1479a2..b4e3a8509 100644 --- a/src/error_display.rs +++ b/src/error_display.rs @@ -16,18 +16,25 @@ pub fn format_user_error(message: &str) -> String { if let Some(start) = msg_lower.find("timeout waiting for ") { let rest = &message[start + "timeout waiting for ".len()..]; let method = rest.split_whitespace().next().unwrap_or("request"); - return format!("**Request Timeout**\nTimeout waiting for {}, please try again.", method); + return format!( + "**Request Timeout**\nTimeout waiting for {}, please try again.", + method + ); } - return "**Request Timeout**\nTimeout waiting for a response, please try again.".to_string(); + return "**Request Timeout**\nTimeout waiting for a response, please try again." + .to_string(); } if msg_lower.contains("connection closed") || msg_lower.contains("channel closed") { - return "**Connection Lost**\nThe connection to the agent was lost, please try again.".to_string(); + return "**Connection Lost**\nThe connection to the agent was lost, please try again." + .to_string(); } if msg_lower.contains("failed to spawn") || msg_lower.contains("no such file") { - return "**Agent Not Found**\nCould not start the agent — please check your configuration.".to_string(); + return "**Agent Not Found**\nCould not start the agent — please check your configuration." + .to_string(); } if msg_lower.contains("pool exhausted") { - return "**Service Busy**\nAll agent sessions are in use, please try again shortly.".to_string(); + return "**Service Busy**\nAll agent sessions are in use, please try again shortly." + .to_string(); } if msg_lower.contains("invalid api key") || msg_lower.contains("unauthorized") { return "**Unauthorized**\nPlease check your API key configuration.".to_string(); diff --git a/src/gateway.rs b/src/gateway.rs index 8aed6aabc..d8fa967cf 100644 --- a/src/gateway.rs +++ b/src/gateway.rs @@ -107,12 +107,16 @@ struct GatewayResponse { // --- GatewayAdapter: ChatAdapter over WebSocket --- type PendingRequests = Arc>>>; -type SharedWsTx = Arc, +type SharedWsTx = Arc< + Mutex< + futures_util::stream::SplitSink< + tokio_tungstenite::WebSocketStream< + tokio_tungstenite::MaybeTlsStream, + >, + Message, + >, >, - Message, ->>>; +>; pub struct GatewayAdapter { ws_tx: SharedWsTx, @@ -263,10 +267,7 @@ async fn handle_config_command( Err(e) => Some(format!("❌ Failed to switch: {e}")), }; } else { - return Some(format!( - "⚠️ Invalid number. Use 1–{}.", - all_values.len() - )); + return Some(format!("⚠️ Invalid number. Use 1–{}.", all_values.len())); } } // Exact match on value or name @@ -548,8 +549,12 @@ pub async fn run_gateway_adapter( let (ws_tx, mut ws_rx) = ws_stream.split(); let ws_tx: SharedWsTx = Arc::new(Mutex::new(ws_tx)); let pending: PendingRequests = Arc::new(Mutex::new(HashMap::new())); - let adapter: Arc = - Arc::new(GatewayAdapter::new(ws_tx.clone(), pending.clone(), platform, streaming)); + let adapter: Arc = Arc::new(GatewayAdapter::new( + ws_tx.clone(), + pending.clone(), + platform, + streaming, + )); let slash_ws_tx = ws_tx.clone(); // for fire-and-forget slash command responses let mut tasks: tokio::task::JoinSet<()> = tokio::task::JoinSet::new(); @@ -792,4 +797,3 @@ pub async fn run_gateway_adapter( backoff_secs = (backoff_secs * 2).min(MAX_BACKOFF); } // outer reconnect loop } - diff --git a/src/main.rs b/src/main.rs index 04a0937ff..3cfce2dbf 100644 --- a/src/main.rs +++ b/src/main.rs @@ -7,13 +7,13 @@ mod discord; mod dispatch; mod error_display; mod format; +mod gateway; mod markdown; mod media; mod reactions; mod setup; mod slack; mod stt; -mod gateway; mod timestamp; use adapter::AdapterRouter; @@ -85,7 +85,9 @@ async fn main() -> anyhow::Result<()> { ) .init(); - let cmd = Cli::parse().command.unwrap_or(Commands::Run { config: None }); + let cmd = Cli::parse() + .command + .unwrap_or(Commands::Run { config: None }); let config_arg = match cmd { Commands::Setup { output } => { @@ -117,7 +119,9 @@ async fn main() -> anyhow::Result<()> { ); if cfg.discord.is_none() && cfg.slack.is_none() && cfg.gateway.is_none() { - anyhow::bail!("no adapter configured — add [discord], [slack], and/or [gateway] to config.toml"); + anyhow::bail!( + "no adapter configured — add [discord], [slack], and/or [gateway] to config.toml" + ); } let pool = Arc::new(acp::SessionPool::new(cfg.agent, cfg.pool.max_sessions)); @@ -139,7 +143,11 @@ async fn main() -> anyhow::Result<()> { info!(model = %cfg.stt.model, base_url = %cfg.stt.base_url, "STT enabled"); } - let router = Arc::new(AdapterRouter::new(pool.clone(), cfg.reactions, cfg.markdown.tables)); + let router = Arc::new(AdapterRouter::new( + pool.clone(), + cfg.reactions, + cfg.markdown.tables, + )); // Shutdown signal for Slack adapter let (shutdown_tx, shutdown_rx) = tokio::sync::watch::channel(false); @@ -166,25 +174,36 @@ async fn main() -> anyhow::Result<()> { }); // Pre-build shared adapters for cron scheduler (avoids duplicate Http clients / rate-limit buckets) - let shared_discord_adapter: Option> = cfg.discord.as_ref().map(|dc| { - let http = Arc::new(serenity::http::Http::new(&dc.bot_token)); - Arc::new(discord::DiscordAdapter::new(http)) as Arc - }); + let shared_discord_adapter: Option> = + cfg.discord.as_ref().map(|dc| { + let http = Arc::new(serenity::http::Http::new(&dc.bot_token)); + Arc::new(discord::DiscordAdapter::new(http)) as Arc + }); let session_ttl_dur = std::time::Duration::from_secs(ttl_secs); let shared_slack_adapter: Option> = cfg.slack.as_ref().map(|s| { - Arc::new(slack::SlackAdapter::new(s.bot_token.clone(), session_ttl_dur, s.allow_bot_messages)) + Arc::new(slack::SlackAdapter::new( + s.bot_token.clone(), + session_ttl_dur, + s.allow_bot_messages, + )) }); // Validate cronjob config at startup (fail-fast on bad cron expressions or timezones) let mut configured_platforms: Vec<&str> = Vec::new(); - if cfg.discord.is_some() { configured_platforms.push("discord"); } - if cfg.slack.is_some() { configured_platforms.push("slack"); } + if cfg.discord.is_some() { + configured_platforms.push("discord"); + } + if cfg.slack.is_some() { + configured_platforms.push("slack"); + } cron::validate_cronjobs(&cfg.cron.jobs, &configured_platforms)?; // Spawn Slack adapter (background task) let slack_handle = if let Some(slack_cfg) = cfg.slack { - let allow_all_channels = config::resolve_allow_all(slack_cfg.allow_all_channels, &slack_cfg.allowed_channels); - let allow_all_users = config::resolve_allow_all(slack_cfg.allow_all_users, &slack_cfg.allowed_users); + let allow_all_channels = + config::resolve_allow_all(slack_cfg.allow_all_channels, &slack_cfg.allowed_channels); + let allow_all_users = + config::resolve_allow_all(slack_cfg.allow_all_users, &slack_cfg.allowed_users); if !allow_all_channels && slack_cfg.allowed_channels.is_empty() { warn!("allow_all_channels=false with empty allowed_channels for Slack — bot will deny all channels"); } @@ -201,7 +220,9 @@ async fn main() -> anyhow::Result<()> { let stt = cfg.stt.clone(); let max_bot_turns = slack_cfg.max_bot_turns; let slack_shutdown_rx = shutdown_rx.clone(); - let adapter = shared_slack_adapter.clone().expect("shared_slack_adapter must exist when slack config is present"); + let adapter = shared_slack_adapter + .clone() + .expect("shared_slack_adapter must exist when slack config is present"); // Dispatcher is the sole serialization path for all modes. Message = cap 1 // (each message dispatches alone, FIFO). Thread / Lane = configured cap; // grouping decides whether senders share a buffer or get their own lane. @@ -264,15 +285,23 @@ async fn main() -> anyhow::Result<()> { platform: gw_cfg.platform, token: gw_cfg.token, bot_username: gw_cfg.bot_username, - allow_all_channels: config::resolve_allow_all(gw_cfg.allow_all_channels, &gw_cfg.allowed_channels), + allow_all_channels: config::resolve_allow_all( + gw_cfg.allow_all_channels, + &gw_cfg.allowed_channels, + ), allowed_channels: gw_cfg.allowed_channels, - allow_all_users: config::resolve_allow_all(gw_cfg.allow_all_users, &gw_cfg.allowed_users), + allow_all_users: config::resolve_allow_all( + gw_cfg.allow_all_users, + &gw_cfg.allowed_users, + ), allowed_users: gw_cfg.allowed_users, streaming: gw_cfg.streaming, }; let gw_router = router.clone(); Some(tokio::spawn(async move { - if let Err(e) = gateway::run_gateway_adapter(params, shutdown_rx, gw_dispatcher, gw_router).await { + if let Err(e) = + gateway::run_gateway_adapter(params, shutdown_rx, gw_dispatcher, gw_router).await + { error!("gateway adapter error: {e}"); } })) @@ -311,10 +340,19 @@ async fn main() -> anyhow::Result<()> { if let Some(ref a) = shared_slack_adapter { cron_adapters.insert("slack".into(), a.clone() as Arc); } - let cron_platforms: Vec = configured_platforms.iter().map(|s| s.to_string()).collect(); + let cron_platforms: Vec = + configured_platforms.iter().map(|s| s.to_string()).collect(); info!(baseline = cronjobs.len(), usercron = ?usercron_path, "starting cron scheduler"); Some(tokio::spawn(async move { - cron::run_scheduler(cronjobs, usercron_path, cron_platforms, cron_router, cron_adapters, shutdown_rx).await; + cron::run_scheduler( + cronjobs, + usercron_path, + cron_platforms, + cron_router, + cron_adapters, + shutdown_rx, + ) + .await; })) } else { None @@ -322,15 +360,20 @@ async fn main() -> anyhow::Result<()> { // Run Discord adapter (foreground, blocking) or wait for ctrl_c if let Some(discord_cfg) = cfg.discord { - let allow_all_channels = config::resolve_allow_all(discord_cfg.allow_all_channels, &discord_cfg.allowed_channels); - let allow_all_users = config::resolve_allow_all(discord_cfg.allow_all_users, &discord_cfg.allowed_users); + let allow_all_channels = config::resolve_allow_all( + discord_cfg.allow_all_channels, + &discord_cfg.allowed_channels, + ); + let allow_all_users = + config::resolve_allow_all(discord_cfg.allow_all_users, &discord_cfg.allowed_users); let allowed_channels = parse_id_set(&discord_cfg.allowed_channels, "discord.allowed_channels")?; if !allow_all_channels && allowed_channels.is_empty() { warn!("allow_all_channels=false with empty allowed_channels for Discord — bot will deny all channels"); } let allowed_users = parse_id_set(&discord_cfg.allowed_users, "discord.allowed_users")?; - let trusted_bot_ids = parse_id_set(&discord_cfg.trusted_bot_ids, "discord.trusted_bot_ids")?; + let trusted_bot_ids = + parse_id_set(&discord_cfg.trusted_bot_ids, "discord.trusted_bot_ids")?; info!( allow_all_channels, allow_all_users, @@ -371,7 +414,9 @@ async fn main() -> anyhow::Result<()> { multibot_threads: tokio::sync::Mutex::new(std::collections::HashMap::new()), session_ttl: std::time::Duration::from_secs(ttl_secs), max_bot_turns: discord_cfg.max_bot_turns, - bot_turns: tokio::sync::Mutex::new(bot_turns::BotTurnTracker::new(discord_cfg.max_bot_turns)), + bot_turns: tokio::sync::Mutex::new(bot_turns::BotTurnTracker::new( + discord_cfg.max_bot_turns, + )), allow_dm: discord_cfg.allow_dm, dispatcher: discord_dispatcher, }; @@ -503,7 +548,8 @@ mod tests { #[test] fn cli_run_with_remote_url() { - let cli = Cli::try_parse_from(["openab", "run", "-c", "https://example.com/config.toml"]).unwrap(); + let cli = Cli::try_parse_from(["openab", "run", "-c", "https://example.com/config.toml"]) + .unwrap(); match cli.command.unwrap() { Commands::Run { config } => assert!(config.unwrap().starts_with("https://")), _ => panic!("expected Run"), diff --git a/src/markdown.rs b/src/markdown.rs index 6b0aa5331..32398cc25 100644 --- a/src/markdown.rs +++ b/src/markdown.rs @@ -330,9 +330,7 @@ Some text after. // The table is inside a ``` block — backtick wrapping must be stripped. assert!(result.contains("value"), "cell content should be present"); // Only the fence markers themselves should contain backticks. - let inner = result - .trim_start_matches("```\n") - .trim_end_matches("```\n"); + let inner = result.trim_start_matches("```\n").trim_end_matches("```\n"); assert!( !inner.contains('`'), "no backticks should appear inside the code fence: {result:?}" @@ -343,6 +341,9 @@ Some text after. fn bullets_mode_keeps_backticks_in_code_cells() { let md = "| col |\n|-----|\n| `value` |\n"; let result = convert_tables(md, TableMode::Bullets); - assert!(result.contains("`value`"), "backticks should be kept in bullets mode"); + assert!( + result.contains("`value`"), + "backticks should be kept in bullets mode" + ); } } diff --git a/src/media.rs b/src/media.rs index 5e0c057f3..aa56e5f49 100644 --- a/src/media.rs +++ b/src/media.rs @@ -71,7 +71,10 @@ pub async fn download_and_encode_image( let response = match req.send().await { Ok(resp) => resp, - Err(e) => { error!(url, error = %e, "download failed"); return None; } + Err(e) => { + error!(url, error = %e, "download failed"); + return None; + } }; if !response.status().is_success() { error!(url, status = %response.status(), "HTTP error downloading image"); @@ -79,11 +82,18 @@ pub async fn download_and_encode_image( } let bytes = match response.bytes().await { Ok(b) => b, - Err(e) => { error!(url, error = %e, "read failed"); return None; } + Err(e) => { + error!(url, error = %e, "read failed"); + return None; + } }; if bytes.len() as u64 > MAX_SIZE { - error!(filename, size = bytes.len(), "downloaded image exceeds limit"); + error!( + filename, + size = bytes.len(), + "downloaded image exceeds limit" + ); return None; } @@ -142,14 +152,20 @@ pub async fn download_and_transcribe( } let bytes = resp.bytes().await.ok()?.to_vec(); - crate::stt::transcribe(&HTTP_CLIENT, stt_config, bytes, filename.to_string(), mime_type).await + crate::stt::transcribe( + &HTTP_CLIENT, + stt_config, + bytes, + filename.to_string(), + mime_type, + ) + .await } /// Resize image so longest side <= IMAGE_MAX_DIMENSION_PX, then encode as JPEG. /// GIFs are passed through unchanged to preserve animation. pub fn resize_and_compress(raw: &[u8]) -> Result<(Vec, String), image::ImageError> { - let reader = ImageReader::new(Cursor::new(raw)) - .with_guessed_format()?; + let reader = ImageReader::new(Cursor::new(raw)).with_guessed_format()?; let format = reader.format(); @@ -184,16 +200,23 @@ pub fn is_audio_mime(mime: &str) -> bool { /// Extensions recognised as text-based files that can be inlined into the prompt. const TEXT_EXTENSIONS: &[&str] = &[ - "txt", "csv", "log", "md", "json", "jsonl", "yaml", "yml", "toml", "xml", - "rs", "py", "js", "ts", "jsx", "tsx", "go", "java", "c", "cpp", "h", "hpp", - "rb", "sh", "bash", "zsh", "fish", "ps1", "bat", "sql", "html", "css", - "scss", "less", "ini", "cfg", "conf", "env", + "txt", "csv", "log", "md", "json", "jsonl", "yaml", "yml", "toml", "xml", "rs", "py", "js", + "ts", "jsx", "tsx", "go", "java", "c", "cpp", "h", "hpp", "rb", "sh", "bash", "zsh", "fish", + "ps1", "bat", "sql", "html", "css", "scss", "less", "ini", "cfg", "conf", "env", ]; /// Exact filenames (no extension) recognised as text files. const TEXT_FILENAMES: &[&str] = &[ - "dockerfile", "makefile", "justfile", "rakefile", "gemfile", - "procfile", "vagrantfile", ".gitignore", ".dockerignore", ".editorconfig", + "dockerfile", + "makefile", + "justfile", + "rakefile", + "gemfile", + "procfile", + "vagrantfile", + ".gitignore", + ".dockerignore", + ".editorconfig", ]; /// MIME types recognised as text-based (beyond `text/*`). @@ -268,7 +291,11 @@ pub async fn download_and_read_text_file( // Defense-in-depth: verify actual download size if actual_size > MAX_SIZE { - tracing::warn!(filename, size = actual_size, "downloaded text file exceeds 512KB limit, skipping"); + tracing::warn!( + filename, + size = actual_size, + "downloaded text file exceeds 512KB limit, skipping" + ); return None; } @@ -348,16 +375,19 @@ mod tests { let png = make_png(3000, 2000); let (compressed, _) = resize_and_compress(&png).unwrap(); - assert!(compressed.len() < png.len(), "compressed {} should be < original {}", compressed.len(), png.len()); + assert!( + compressed.len() < png.len(), + "compressed {} should be < original {}", + compressed.len(), + png.len() + ); } #[test] fn gif_passes_through_unchanged() { let gif: Vec = vec![ - 0x47, 0x49, 0x46, 0x38, 0x39, 0x61, - 0x01, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00, - 0x2C, 0x00, 0x00, 0x00, 0x00, 0x01, 0x00, 0x01, 0x00, 0x00, - 0x02, 0x02, 0x44, 0x01, 0x00, + 0x47, 0x49, 0x46, 0x38, 0x39, 0x61, 0x01, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0x2C, + 0x00, 0x00, 0x00, 0x00, 0x01, 0x00, 0x01, 0x00, 0x00, 0x02, 0x02, 0x44, 0x01, 0x00, 0x3B, ]; let (output, mime) = resize_and_compress(&gif).unwrap(); diff --git a/src/reactions.rs b/src/reactions.rs index 8638d86fc..6e68f90b6 100644 --- a/src/reactions.rs +++ b/src/reactions.rs @@ -5,7 +5,13 @@ use tokio::sync::Mutex; use tokio::time::Duration; const CODING_TOKENS: &[&str] = &["exec", "process", "read", "write", "edit", "bash", "shell"]; -const WEB_TOKENS: &[&str] = &["web_search", "web_fetch", "web-search", "web-fetch", "browser"]; +const WEB_TOKENS: &[&str] = &[ + "web_search", + "web_fetch", + "web-search", + "web-fetch", + "browser", +]; fn classify_tool<'a>(name: &str, emojis: &'a ReactionEmojis) -> &'a str { let n = name.to_lowercase(); @@ -60,19 +66,25 @@ impl StatusReactionController { } pub async fn set_queued(&self) { - if !self.enabled { return; } + if !self.enabled { + return; + } let emoji = { self.inner.lock().await.emojis.queued.clone() }; self.apply_immediate(&emoji).await; } pub async fn set_thinking(&self) { - if !self.enabled { return; } + if !self.enabled { + return; + } let emoji = { self.inner.lock().await.emojis.thinking.clone() }; self.schedule_debounced(&emoji).await; } pub async fn set_tool(&self, tool_name: &str) { - if !self.enabled { return; } + if !self.enabled { + return; + } let emoji = { let inner = self.inner.lock().await; classify_tool(tool_name, &inner.emojis).to_string() @@ -81,7 +93,9 @@ impl StatusReactionController { } pub async fn set_done(&self) { - if !self.enabled { return; } + if !self.enabled { + return; + } let emoji = { self.inner.lock().await.emojis.done.clone() }; self.finish(&emoji).await; // Add a random mood face @@ -92,18 +106,25 @@ impl StatusReactionController { } pub async fn set_error(&self) { - if !self.enabled { return; } + if !self.enabled { + return; + } let emoji = { self.inner.lock().await.emojis.error.clone() }; self.finish(&emoji).await; } pub async fn clear(&self) { - if !self.enabled { return; } + if !self.enabled { + return; + } let mut inner = self.inner.lock().await; cancel_timers(&mut inner); let current = inner.current.clone(); if !current.is_empty() { - let _ = inner.adapter.remove_reaction(&inner.message, ¤t).await; + let _ = inner + .adapter + .remove_reaction(&inner.message, ¤t) + .await; inner.current.clear(); } } @@ -142,7 +163,9 @@ impl StatusReactionController { inner.debounce_handle = Some(tokio::spawn(async move { tokio::time::sleep(Duration::from_millis(debounce_ms)).await; let mut inner = ctrl.lock().await; - if inner.finished { return; } + if inner.finished { + return; + } let old = inner.current.clone(); inner.current = emoji.clone(); let adapter = inner.adapter.clone(); @@ -159,7 +182,9 @@ impl StatusReactionController { async fn finish(&self, emoji: &str) { let mut inner = self.inner.lock().await; - if inner.finished { return; } + if inner.finished { + return; + } inner.finished = true; cancel_timers(&mut inner); @@ -182,8 +207,12 @@ impl StatusReactionController { } fn reset_stall_timers_inner(&self, inner: &mut Inner) { - if let Some(h) = inner.stall_soft_handle.take() { h.abort(); } - if let Some(h) = inner.stall_hard_handle.take() { h.abort(); } + if let Some(h) = inner.stall_soft_handle.take() { + h.abort(); + } + if let Some(h) = inner.stall_hard_handle.take() { + h.abort(); + } let soft_ms = inner.timing.stall_soft_ms; let hard_ms = inner.timing.stall_hard_ms; @@ -194,7 +223,9 @@ impl StatusReactionController { async move { tokio::time::sleep(Duration::from_millis(soft_ms)).await; let mut inner = ctrl.lock().await; - if inner.finished { return; } + if inner.finished { + return; + } let old = inner.current.clone(); inner.current = "🥱".to_string(); let adapter = inner.adapter.clone(); @@ -210,7 +241,9 @@ impl StatusReactionController { inner.stall_hard_handle = Some(tokio::spawn(async move { tokio::time::sleep(Duration::from_millis(hard_ms)).await; let mut inner = ctrl.lock().await; - if inner.finished { return; } + if inner.finished { + return; + } let old = inner.current.clone(); inner.current = "😨".to_string(); let adapter = inner.adapter.clone(); @@ -225,11 +258,19 @@ impl StatusReactionController { } fn cancel_debounce(inner: &mut Inner) { - if let Some(h) = inner.debounce_handle.take() { h.abort(); } + if let Some(h) = inner.debounce_handle.take() { + h.abort(); + } } fn cancel_timers(inner: &mut Inner) { - if let Some(h) = inner.debounce_handle.take() { h.abort(); } - if let Some(h) = inner.stall_soft_handle.take() { h.abort(); } - if let Some(h) = inner.stall_hard_handle.take() { h.abort(); } + if let Some(h) = inner.debounce_handle.take() { + h.abort(); + } + if let Some(h) = inner.stall_soft_handle.take() { + h.abort(); + } + if let Some(h) = inner.stall_hard_handle.take() { + h.abort(); + } } diff --git a/src/setup/config.rs b/src/setup/config.rs index 21d65e7e1..c0e7d604d 100644 --- a/src/setup/config.rs +++ b/src/setup/config.rs @@ -85,10 +85,7 @@ pub fn generate_config( }, agent: { let (command, args): (&str, Vec) = match agent_command { - "kiro" => ( - "kiro-cli", - vec!["acp".into(), "--trust-all-tools".into()], - ), + "kiro" => ("kiro-cli", vec!["acp".into(), "--trust-all-tools".into()]), "claude" => ("claude-agent-acp", vec![]), "codex" => ("codex-acp", vec![]), "gemini" => ("gemini", vec!["--acp".into()]), @@ -152,14 +149,7 @@ mod tests { #[test] fn test_generate_config_kiro_working_dir() { - let config = generate_config( - "tok", - "kiro", - vec!["ch".to_string()], - "/home/agent", - 10, - 24, - ); + let config = generate_config("tok", "kiro", vec!["ch".to_string()], "/home/agent", 10, 24); assert!(config.contains(r#"working_dir = "/home/agent""#)); assert!(config.contains("acp")); assert!(config.contains("--trust-all-tools")); diff --git a/src/setup/validate.rs b/src/setup/validate.rs index 247b1b9af..527a1a385 100644 --- a/src/setup/validate.rs +++ b/src/setup/validate.rs @@ -5,10 +5,15 @@ pub fn validate_bot_token(token: &str) -> anyhow::Result<()> { if token.is_empty() { anyhow::bail!("Token cannot be empty"); } - if !token - .chars() - .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '.' || c == '_' || c == '/' || c == '*' || c == '=') - { + if !token.chars().all(|c| { + c.is_ascii_alphanumeric() + || c == '-' + || c == '.' + || c == '_' + || c == '/' + || c == '*' + || c == '=' + }) { anyhow::bail!( "Token must only contain ASCII letters, numbers, dash, period, underscore, slash, or equals" ); diff --git a/src/setup/wizard.rs b/src/setup/wizard.rs index e8751172d..f5a789609 100644 --- a/src/setup/wizard.rs +++ b/src/setup/wizard.rs @@ -154,7 +154,11 @@ fn print_box(lines: &[&str]) { .unwrap_or(60); let width = width.clamp(60, 76); println!(); - cprintln!(C.cyan, "{}", "╔".to_string() + &BORDER.to_string().repeat(width + 2) + "╗"); + cprintln!( + C.cyan, + "{}", + "╔".to_string() + &BORDER.to_string().repeat(width + 2) + "╗" + ); for line in lines { let padded = format!(" {: anyhow::Result> { println!(); if guilds.is_empty() { - cprintln!( - C.yellow, - " No servers found. Enter channel IDs manually." - ); + cprintln!(C.yellow, " No servers found. Enter channel IDs manually."); let input = prompt(" Channel ID(s), comma-separated"); let ids: Vec = input .split(',') @@ -342,21 +347,11 @@ fn section_channels(client: &DiscordClient) -> anyhow::Result> { return Ok(ids); } - let channel_names: Vec = channels - .iter() - .map(|(_, n, _)| format!("#{}", n)) - .collect(); - let channel_names_refs: Vec<&str> = channel_names - .iter() - .map(|s| s.as_str()) - .collect(); + let channel_names: Vec = channels.iter().map(|(_, n, _)| format!("#{}", n)).collect(); + let channel_names_refs: Vec<&str> = channel_names.iter().map(|s| s.as_str()).collect(); - let selected = - prompt_checklist(" Select channels (by number):", &channel_names_refs); - let selected_ids: Vec = selected - .iter() - .map(|&i| channels[i].0.clone()) - .collect(); + let selected = prompt_checklist(" Select channels (by number):", &channel_names_refs); + let selected_ids: Vec = selected.iter().map(|&i| channels[i].0.clone()).collect(); println!(); cprintln!(C.green, " Selected {} channel(s)", selected_ids.len()); @@ -408,12 +403,7 @@ fn section_agent() -> (String, String, bool) { let working_dir = prompt_default(" Working directory", default_dir); - cprintln!( - C.green, - " Agent: {} | Working dir: {}", - agent, - working_dir - ); + cprintln!(C.green, " Agent: {} | Working dir: {}", agent, working_dir); println!(); (agent.to_string(), working_dir, is_local) @@ -428,9 +418,7 @@ fn section_pool() -> (usize, u64) { cprintln!(C.bold, "--- Step 4: Session Pool ---"); println!(); - let max_sessions: usize = prompt_default(" Max sessions", "10") - .parse() - .unwrap_or(10); + let max_sessions: usize = prompt_default(" Max sessions", "10").parse().unwrap_or(10); let ttl_hours: u64 = prompt_default(" Session TTL (hours)", "24") .parse() .unwrap_or(24); @@ -457,9 +445,7 @@ fn section_preview_and_save(config_content: &str, output_path: &PathBuf) -> anyh println!("{}", mask_bot_token(config_content)); println!(); - if output_path.exists() - && !prompt_yes_no(" File exists. Overwrite?", false) - { + if output_path.exists() && !prompt_yes_no(" File exists. Overwrite?", false) { println!(" Saving cancelled."); return Ok(()); } @@ -517,7 +503,10 @@ fn print_next_steps(agent: &str, output_path: &Path, is_local: bool) { if is_local { match agent { "kiro" => { - cprintln!(C.cyan, " 1. Install kiro-cli (see https://kiro.dev for installer)"); + cprintln!( + C.cyan, + " 1. Install kiro-cli (see https://kiro.dev for installer)" + ); cprintln!(C.cyan, " 2. Authenticate:"); println!(" kiro-cli login --use-device-flow"); } @@ -536,7 +525,10 @@ fn print_next_steps(agent: &str, output_path: &Path, is_local: bool) { "gemini" => { cprintln!(C.cyan, " 1. Install Gemini CLI:"); println!(" npm install -g @google/gemini-cli"); - cprintln!(C.cyan, " 2. Authenticate via Google OAuth, or set GEMINI_API_KEY in config.toml"); + cprintln!( + C.cyan, + " 2. Authenticate via Google OAuth, or set GEMINI_API_KEY in config.toml" + ); } _ => {} } @@ -552,22 +544,28 @@ fn print_next_steps(agent: &str, output_path: &Path, is_local: bool) { println!(); cprintln!(C.cyan, " 1. Deploy with Helm (or your preferred method):"); println!(" helm install openab openab/openab \\"); - println!(" --set agents.{}.discord.botToken=\"$BOT_TOKEN\"", agent); + println!( + " --set agents.{}.discord.botToken=\"$BOT_TOKEN\"", + agent + ); println!(); - cprintln!(C.cyan, " 2. Authenticate inside the pod (first time only):"); + cprintln!( + C.cyan, + " 2. Authenticate inside the pod (first time only):" + ); match agent { "kiro" => println!( " kubectl exec -it deployment/openab-kiro -- kiro-cli login --use-device-flow" ), - "claude" => println!( - " kubectl exec -it deployment/openab-claude -- claude auth login" - ), + "claude" => { + println!(" kubectl exec -it deployment/openab-claude -- claude auth login") + } "codex" => println!( " kubectl exec -it deployment/openab-codex -- codex login --device-auth" ), - "gemini" => println!( - " Set GEMINI_API_KEY via secret, or exec into the pod for OAuth" - ), + "gemini" => { + println!(" Set GEMINI_API_KEY via secret, or exec into the pod for OAuth") + } _ => {} } println!(); @@ -605,10 +603,7 @@ pub fn run_setup(output_path: Option) -> anyhow::Result<()> { println!(); let bot_token = prompt_password(" Bot Token (or press Enter to skip)"); if bot_token.is_empty() { - cprintln!( - C.yellow, - " Skipped. Set bot_token manually in config.toml" - ); + cprintln!(C.yellow, " Skipped. Set bot_token manually in config.toml"); println!(); cprintln!( C.green, @@ -632,11 +627,7 @@ pub fn run_setup(output_path: Option) -> anyhow::Result<()> { vec![] } Err(e) => { - cprintln!( - C.yellow, - " Channel fetch failed: {}. Enter manually.", - e - ); + cprintln!(C.yellow, " Channel fetch failed: {}. Enter manually.", e); let input = prompt(" Channel ID(s), comma-separated"); let ids: Vec = input .split(',') diff --git a/src/slack.rs b/src/slack.rs index 979db52b9..74d460625 100644 --- a/src/slack.rs +++ b/src/slack.rs @@ -1,5 +1,5 @@ use crate::acp::ContentBlock; -use crate::adapter::{ChatAdapter, ChannelRef, MessageRef, SenderContext}; +use crate::adapter::{ChannelRef, ChatAdapter, MessageRef, SenderContext}; use crate::bot_turns::{BotTurnTracker, TurnAction, TurnSeverity}; use crate::config::{AllowBots, AllowUsers, SttConfig}; use crate::media; @@ -70,7 +70,11 @@ pub struct SlackAdapter { } impl SlackAdapter { - pub fn new(bot_token: String, session_ttl: std::time::Duration, _allow_bot_messages: AllowBots) -> Self { + pub fn new( + bot_token: String, + session_ttl: std::time::Duration, + _allow_bot_messages: AllowBots, + ) -> Self { Self { client: reqwest::Client::new(), bot_token, @@ -93,20 +97,28 @@ impl SlackAdapter { /// depend on fetching thread history. Idempotent. async fn note_other_bot_in_thread(&self, thread_ts: &str) { let mut cache = self.multibot_threads.lock().await; - cache.entry(thread_ts.to_string()).or_insert_with(tokio::time::Instant::now); + cache + .entry(thread_ts.to_string()) + .or_insert_with(tokio::time::Instant::now); enforce_cache_bounds(&mut cache, self.session_ttl); } /// Get the bot's own Slack user ID (cached after first call). async fn get_bot_user_id(&self) -> Option<&str> { - self.bot_user_id.get_or_try_init(|| async { - let resp = self.api_post("auth.test", serde_json::json!({})).await - .map_err(|e| anyhow!("auth.test failed: {e}"))?; - resp["user_id"] - .as_str() - .map(|s| s.to_string()) - .ok_or_else(|| anyhow!("no user_id in auth.test response")) - }).await.ok().map(|s| s.as_str()) + self.bot_user_id + .get_or_try_init(|| async { + let resp = self + .api_post("auth.test", serde_json::json!({})) + .await + .map_err(|e| anyhow!("auth.test failed: {e}"))?; + resp["user_id"] + .as_str() + .map(|s| s.to_string()) + .ok_or_else(|| anyhow!("no user_id in auth.test response")) + }) + .await + .ok() + .map(|s| s.as_str()) } async fn api_post(&self, method: &str, body: serde_json::Value) -> Result { @@ -160,10 +172,7 @@ impl SlackAdapter { } let resp = self - .api_post( - "users.info", - serde_json::json!({ "user": user_id }), - ) + .api_post("users.info", serde_json::json!({ "user": user_id })) .await .ok()?; let user = resp.get("user")?; @@ -176,9 +185,7 @@ impl SlackAdapter { .get("real_name") .and_then(|v| v.as_str()) .filter(|s| !s.is_empty()); - let name = user - .get("name") - .and_then(|v| v.as_str()); + let name = user.get("name").and_then(|v| v.as_str()); let resolved = display.or(real).or(name)?.to_string(); // Cache the result @@ -204,15 +211,12 @@ impl SlackAdapter { .api_post("bots.info", serde_json::json!({ "bot": bot_id })) .await .ok()?; - let user_id = resp.get("bot")? - .get("user_id")? - .as_str()? - .to_string(); - - self.bot_id_cache.lock().await.insert( - bot_id.to_string(), - user_id.clone(), - ); + let user_id = resp.get("bot")?.get("user_id")?.as_str()?.to_string(); + + self.bot_id_cache + .lock() + .await + .insert(bot_id.to_string(), user_id.clone()); Some(user_id) } @@ -226,11 +230,15 @@ impl SlackAdapter { async fn bot_participated_in_thread(&self, channel: &str, thread_ts: &str) -> (bool, bool) { let cached_involved = { let cache = self.participated_threads.lock().await; - cache.get(thread_ts).is_some_and(|ts| ts.elapsed() < self.session_ttl) + cache + .get(thread_ts) + .is_some_and(|ts| ts.elapsed() < self.session_ttl) }; let cached_multibot = { let cache = self.multibot_threads.lock().await; - cache.get(thread_ts).is_some_and(|ts| ts.elapsed() < self.session_ttl) + cache + .get(thread_ts) + .is_some_and(|ts| ts.elapsed() < self.session_ttl) }; // Eager multibot detection from message events populates the cache @@ -266,7 +274,9 @@ impl SlackAdapter { return (false, false); } }; - let Some(messages) = json["messages"].as_array() else { return (false, false) }; + let Some(messages) = json["messages"].as_array() else { + return (false, false); + }; let parent_mentions_bot = messages .first() @@ -278,8 +288,8 @@ impl SlackAdapter { let involved = parent_mentions_bot || bot_posted; let other_bot_present = cached_multibot || messages.iter().any(|m| { - let is_bot_msg = m["bot_id"].is_string() - || m["subtype"].as_str() == Some("bot_message"); + let is_bot_msg = + m["bot_id"].is_string() || m["subtype"].as_str() == Some("bot_message"); is_bot_msg && m["user"].as_str() != Some(bot_id) }); @@ -356,7 +366,6 @@ impl ChatAdapter for SlackAdapter { }) } - async fn create_thread( &self, channel: &ChannelRef, @@ -375,15 +384,16 @@ impl ChatAdapter for SlackAdapter { async fn add_reaction(&self, msg: &MessageRef, emoji: &str) -> Result<()> { let name = unicode_to_slack_emoji(emoji); - match self.api_post( - "reactions.add", - serde_json::json!({ - "channel": msg.channel.channel_id, - "timestamp": msg.message_id, - "name": name, - }), - ) - .await + match self + .api_post( + "reactions.add", + serde_json::json!({ + "channel": msg.channel.channel_id, + "timestamp": msg.message_id, + "name": name, + }), + ) + .await { Ok(_) => Ok(()), Err(e) if e.to_string().contains("already_reacted") => Ok(()), @@ -393,15 +403,16 @@ impl ChatAdapter for SlackAdapter { async fn remove_reaction(&self, msg: &MessageRef, emoji: &str) -> Result<()> { let name = unicode_to_slack_emoji(emoji); - match self.api_post( - "reactions.remove", - serde_json::json!({ - "channel": msg.channel.channel_id, - "timestamp": msg.message_id, - "name": name, - }), - ) - .await + match self + .api_post( + "reactions.remove", + serde_json::json!({ + "channel": msg.channel.channel_id, + "timestamp": msg.message_id, + "name": name, + }), + ) + .await { Ok(_) => Ok(()), Err(e) if e.to_string().contains("no_reaction") => Ok(()), @@ -867,8 +878,8 @@ async fn handle_message( Some(u) => u.to_string(), None => return, }; - let is_bot_msg = event["bot_id"].is_string() - || event["subtype"].as_str() == Some("bot_message"); + let is_bot_msg = + event["bot_id"].is_string() || event["subtype"].as_str() == Some("bot_message"); let text = match event["text"].as_str() { Some(t) => t.to_string(), None => return, @@ -920,6 +931,7 @@ async fn handle_message( const TEXT_FILE_COUNT_CAP: u32 = 5; let mut extra_blocks = Vec::new(); + let mut echo_entries: Vec = Vec::new(); let mut text_file_bytes: u64 = 0; let mut text_file_count: u32 = 0; @@ -938,18 +950,34 @@ async fn handle_message( if media::is_audio_mime(mimetype) { if stt_config.enabled { - if let Some(transcript) = media::download_and_transcribe( + match media::download_and_transcribe( url, filename, mimetype, size, stt_config, Some(bot_token), - ).await { - debug!(filename, chars = transcript.len(), "voice transcript injected"); - extra_blocks.insert(0, ContentBlock::Text { - text: format!("[Voice message transcript]: {transcript}"), - }); + ) + .await + { + Some(transcript) => { + debug!( + filename, + chars = transcript.len(), + "voice transcript injected" + ); + extra_blocks.insert( + 0, + ContentBlock::Text { + text: format!("[Voice message transcript]: {transcript}"), + }, + ); + echo_entries.push(crate::stt::EchoEntry::Success(transcript)); + } + None => { + warn!(filename, "STT failed for voice attachment"); + echo_entries.push(crate::stt::EchoEntry::Failed); + } } } else { debug!(filename, "skipping audio attachment (STT disabled)"); @@ -967,7 +995,11 @@ async fn handle_message( } } else if media::is_text_file(filename, Some(mimetype)) { if text_file_count >= TEXT_FILE_COUNT_CAP { - debug!(filename, count = text_file_count, "text file count cap reached, skipping"); + debug!( + filename, + count = text_file_count, + "text file count cap reached, skipping" + ); continue; } // Pre-check with Slack-reported size as a fast path when the @@ -976,15 +1008,16 @@ async fn handle_message( // authoritative cap check happens after download using // `actual_bytes`. if size > 0 && text_file_bytes + size > TEXT_TOTAL_CAP { - debug!(filename, total = text_file_bytes, "text attachments total exceeds 1MB cap, skipping remaining"); + debug!( + filename, + total = text_file_bytes, + "text attachments total exceeds 1MB cap, skipping remaining" + ); continue; } - if let Some((block, actual_bytes)) = media::download_and_read_text_file( - url, - filename, - size, - Some(bot_token), - ).await { + if let Some((block, actual_bytes)) = + media::download_and_read_text_file(url, filename, size, Some(bot_token)).await + { if text_file_bytes + actual_bytes > TEXT_TOTAL_CAP { debug!( filename, @@ -1005,7 +1038,9 @@ async fn handle_message( filename, size, Some(bot_token), - ).await { + ) + .await + { debug!(filename, "adding image attachment"); extra_blocks.push(block); } @@ -1065,9 +1100,23 @@ async fn handle_message( let adapter_dyn: Arc = adapter.clone(); let other_bot_present = { let cache = adapter.multibot_threads.lock().await; - thread_channel.thread_id.as_deref() - .is_some_and(|ts| cache.get(ts).is_some_and(|inst| inst.elapsed() < adapter.session_ttl)) + thread_channel.thread_id.as_deref().is_some_and(|ts| { + cache + .get(ts) + .is_some_and(|inst| inst.elapsed() < adapter.session_ttl) + }) }; + + // Best-effort echo before the agent reply so the user can verify STT. + crate::stt::post_echo( + &adapter_dyn, + &thread_channel, + &trigger_msg, + &echo_entries, + stt_config, + ) + .await; + let thread_id = thread_channel .thread_id .as_deref() @@ -1157,12 +1206,12 @@ fn markdown_to_mrkdwn(text: &str) -> String { LazyLock::new(|| regex::Regex::new(r"```\w+\n").unwrap()); // Order: bold first (** → placeholder), then italic (* → _), then restore bold - let text = BOLD_RE.replace_all(text, "\x01$1\x02"); // **bold** → \x01bold\x02 - let text = ITALIC_RE.replace_all(&text, "_${1}_"); // *italic* → _italic_ - // Restore bold: \x01bold\x02 → *bold* + let text = BOLD_RE.replace_all(text, "\x01$1\x02"); // **bold** → \x01bold\x02 + let text = ITALIC_RE.replace_all(&text, "_${1}_"); // *italic* → _italic_ + // Restore bold: \x01bold\x02 → *bold* let text = text.replace(['\x01', '\x02'], "*"); - let text = LINK_RE.replace_all(&text, "<$2|$1>"); // [text](url) → - let text = HEADING_RE.replace_all(&text, "*$1*"); // # heading → *heading* + let text = LINK_RE.replace_all(&text, "<$2|$1>"); // [text](url) → + let text = HEADING_RE.replace_all(&text, "*$1*"); // # heading → *heading* let text = CODE_BLOCK_LANG_RE.replace_all(&text, "```\n"); // ```rust → ``` text.into_owned() } @@ -1319,7 +1368,13 @@ mod tests { let ttl = std::time::Duration::from_secs(300); let adapter = SlackAdapter::new("xoxb-test".into(), ttl, AllowBots::Mentions); - assert!(adapter.use_streaming(false), "should stream when no other bot"); - assert!(!adapter.use_streaming(true), "should NOT stream when other bot present"); + assert!( + adapter.use_streaming(false), + "should stream when no other bot" + ); + assert!( + !adapter.use_streaming(true), + "should NOT stream when other bot present" + ); } } diff --git a/src/stt.rs b/src/stt.rs index 122db9b68..d266e6117 100644 --- a/src/stt.rs +++ b/src/stt.rs @@ -1,6 +1,74 @@ +use crate::adapter::{ChannelRef, ChatAdapter, MessageRef}; use crate::config::SttConfig; use reqwest::multipart; -use tracing::{debug, error}; +use std::sync::Arc; +use tracing::{debug, error, warn}; + +/// Outcome of attempting STT on a single audio attachment. +/// Used by adapters to feed `post_echo`. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum EchoEntry { + Success(String), + Failed, +} + +/// Render a list of echo entries as a single multi-line quoted block. +/// Returns `None` for empty input so callers can short-circuit. +/// +/// Each entry produces one `> 🎤 …` line. Internal newlines inside a +/// transcript are flattened to spaces so each entry occupies exactly one +/// visual line — Discord and Slack both stop applying `>` at the next `\n`. +pub fn format_echo_message(entries: &[EchoEntry]) -> Option { + if entries.is_empty() { + return None; + } + let mut lines = Vec::with_capacity(entries.len()); + for e in entries { + match e { + EchoEntry::Success(text) => { + let flat = text.replace(['\n', '\r'], " "); + lines.push(format!("> 🎤 {flat}")); + } + EchoEntry::Failed => { + lines.push("> 🎤 (transcription failed)".to_string()); + } + } + } + Some(lines.join("\n")) +} + +/// Post a transcript echo to the thread and add a ⚠️ reaction for any failed +/// entries. No-op when the config disables echoing or when `entries` is empty. +/// +/// Errors from the adapter (send/reaction) are logged and swallowed — the +/// echo is best-effort and must never block the agent reply. +pub async fn post_echo( + adapter: &Arc, + thread: &ChannelRef, + trigger: &MessageRef, + entries: &[EchoEntry], + cfg: &SttConfig, +) { + if !cfg.echo_transcript { + return; + } + let Some(body) = format_echo_message(entries) else { + return; + }; + if let Err(e) = adapter.send_message(thread, &body).await { + warn!(error = %e, platform = adapter.platform(), "failed to send STT echo message"); + } + for entry in entries { + if matches!(entry, EchoEntry::Failed) { + if let Err(e) = adapter.add_reaction(trigger, "⚠️").await { + warn!(error = %e, platform = adapter.platform(), "failed to add STT failure reaction"); + } + // Add only one reaction even with multiple failures — emoji reactions + // are unique per (user, emoji, message), so additional calls are no-ops. + break; + } + } +} /// Transcribe audio bytes via an OpenAI-compatible `/audio/transcriptions` endpoint. pub async fn transcribe( @@ -10,7 +78,10 @@ pub async fn transcribe( filename: String, mime_type: &str, ) -> Option { - let url = format!("{}/audio/transcriptions", cfg.base_url.trim_end_matches('/')); + let url = format!( + "{}/audio/transcriptions", + cfg.base_url.trim_end_matches('/') + ); let file_part = multipart::Part::bytes(audio_bytes) .file_name(filename) @@ -59,3 +130,225 @@ pub async fn transcribe( debug!(chars = text.len(), "STT transcription complete"); Some(text) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn format_single_success_entry() { + let entries = vec![EchoEntry::Success("hello world".into())]; + let out = format_echo_message(&entries).expect("non-empty input → Some"); + assert_eq!(out, "> 🎤 hello world"); + } + + #[test] + fn format_single_failure_entry() { + let entries = vec![EchoEntry::Failed]; + let out = format_echo_message(&entries).expect("non-empty input → Some"); + assert_eq!(out, "> 🎤 (transcription failed)"); + } + + #[test] + fn format_multiple_mixed_entries() { + let entries = vec![ + EchoEntry::Success("first".into()), + EchoEntry::Failed, + EchoEntry::Success("third".into()), + ]; + let out = format_echo_message(&entries).expect("non-empty input → Some"); + assert_eq!(out, "> 🎤 first\n> 🎤 (transcription failed)\n> 🎤 third"); + } + + #[test] + fn format_empty_entries_returns_none() { + let entries: Vec = vec![]; + assert!(format_echo_message(&entries).is_none()); + } + + #[test] + fn format_strips_internal_newlines_in_transcript() { + // Multi-line transcripts must collapse to a single quoted line so the + // ">" prefix still applies to every visual line. + let entries = vec![EchoEntry::Success("line one\nline two".into())]; + let out = format_echo_message(&entries).expect("non-empty input → Some"); + assert_eq!(out, "> 🎤 line one line two"); + } + + use crate::adapter::{ChannelRef, ChatAdapter, MessageRef}; + use anyhow::Result; + use async_trait::async_trait; + use std::sync::{Arc, Mutex}; + + #[derive(Default)] + struct MockAdapter { + sent_messages: Mutex>, + reactions: Mutex>, + } + + #[async_trait] + impl ChatAdapter for MockAdapter { + fn platform(&self) -> &'static str { + "mock" + } + fn message_limit(&self) -> usize { + 4000 + } + async fn send_message(&self, channel: &ChannelRef, content: &str) -> Result { + self.sent_messages + .lock() + .unwrap() + .push((channel.clone(), content.to_string())); + Ok(MessageRef { + channel: channel.clone(), + message_id: "mock-msg".into(), + }) + } + async fn create_thread( + &self, + channel: &ChannelRef, + _trigger: &MessageRef, + _title: &str, + ) -> Result { + Ok(channel.clone()) + } + async fn add_reaction(&self, msg: &MessageRef, emoji: &str) -> Result<()> { + self.reactions + .lock() + .unwrap() + .push((msg.clone(), emoji.to_string())); + Ok(()) + } + async fn remove_reaction(&self, _msg: &MessageRef, _emoji: &str) -> Result<()> { + Ok(()) + } + fn use_streaming(&self, _other_bot_present: bool) -> bool { + false + } + } + + fn test_channel() -> ChannelRef { + ChannelRef { + platform: "mock".into(), + channel_id: "C1".into(), + thread_id: Some("T1".into()), + parent_id: None, + origin_event_id: None, + } + } + + fn test_trigger() -> MessageRef { + MessageRef { + channel: test_channel(), + message_id: "M1".into(), + } + } + + fn cfg(echo: bool) -> SttConfig { + SttConfig { + echo_transcript: echo, + ..SttConfig::default() + } + } + + #[tokio::test] + async fn post_echo_success_sends_one_message_no_reactions() { + let mock = Arc::new(MockAdapter::default()); + let adapter: Arc = mock.clone(); + let entries = vec![EchoEntry::Success("hello".into())]; + post_echo( + &adapter, + &test_channel(), + &test_trigger(), + &entries, + &cfg(true), + ) + .await; + + assert_eq!(mock.sent_messages.lock().unwrap().len(), 1); + assert_eq!(mock.sent_messages.lock().unwrap()[0].1, "> 🎤 hello"); + assert!(mock.reactions.lock().unwrap().is_empty()); + } + + #[tokio::test] + async fn post_echo_failure_adds_warning_reaction() { + let mock = Arc::new(MockAdapter::default()); + let adapter: Arc = mock.clone(); + let entries = vec![EchoEntry::Failed]; + post_echo( + &adapter, + &test_channel(), + &test_trigger(), + &entries, + &cfg(true), + ) + .await; + + assert_eq!(mock.sent_messages.lock().unwrap().len(), 1); + assert_eq!( + mock.sent_messages.lock().unwrap()[0].1, + "> 🎤 (transcription failed)" + ); + let reactions = mock.reactions.lock().unwrap(); + assert_eq!(reactions.len(), 1); + assert_eq!(reactions[0].1, "⚠️"); + } + + #[tokio::test] + async fn post_echo_mixed_one_message_one_reaction() { + let mock = Arc::new(MockAdapter::default()); + let adapter: Arc = mock.clone(); + let entries = vec![EchoEntry::Success("ok".into()), EchoEntry::Failed]; + post_echo( + &adapter, + &test_channel(), + &test_trigger(), + &entries, + &cfg(true), + ) + .await; + + assert_eq!(mock.sent_messages.lock().unwrap().len(), 1); + assert_eq!( + mock.sent_messages.lock().unwrap()[0].1, + "> 🎤 ok\n> 🎤 (transcription failed)" + ); + assert_eq!(mock.reactions.lock().unwrap().len(), 1); + } + + #[tokio::test] + async fn post_echo_disabled_is_noop() { + let mock = Arc::new(MockAdapter::default()); + let adapter: Arc = mock.clone(); + let entries = vec![EchoEntry::Success("hi".into()), EchoEntry::Failed]; + post_echo( + &adapter, + &test_channel(), + &test_trigger(), + &entries, + &cfg(false), + ) + .await; + + assert!(mock.sent_messages.lock().unwrap().is_empty()); + assert!(mock.reactions.lock().unwrap().is_empty()); + } + + #[tokio::test] + async fn post_echo_empty_entries_is_noop() { + let mock = Arc::new(MockAdapter::default()); + let adapter: Arc = mock.clone(); + let entries: Vec = vec![]; + post_echo( + &adapter, + &test_channel(), + &test_trigger(), + &entries, + &cfg(true), + ) + .await; + + assert!(mock.sent_messages.lock().unwrap().is_empty()); + assert!(mock.reactions.lock().unwrap().is_empty()); + } +} diff --git a/src/timestamp.rs b/src/timestamp.rs index e6c8d49f4..aa7adce46 100644 --- a/src/timestamp.rs +++ b/src/timestamp.rs @@ -64,24 +64,36 @@ mod tests { #[test] fn slack_ts_keeps_milliseconds() { // 1714204397 = 2024-04-27T07:53:17 UTC; .123456 → .123 ms - assert_eq!(slack_ts_to_iso8601("1714204397.123456"), "2024-04-27T07:53:17.123Z"); + assert_eq!( + slack_ts_to_iso8601("1714204397.123456"), + "2024-04-27T07:53:17.123Z" + ); } #[test] fn slack_ts_missing_fraction_uses_zero() { - assert_eq!(slack_ts_to_iso8601("1714204397"), "2024-04-27T07:53:17.000Z"); + assert_eq!( + slack_ts_to_iso8601("1714204397"), + "2024-04-27T07:53:17.000Z" + ); } #[test] fn slack_ts_two_digit_fraction_is_120ms_not_12ms() { // ".12" carries decimal semantics: 0.12 s = 120 ms. - assert_eq!(slack_ts_to_iso8601("1714204397.12"), "2024-04-27T07:53:17.120Z"); + assert_eq!( + slack_ts_to_iso8601("1714204397.12"), + "2024-04-27T07:53:17.120Z" + ); } #[test] fn slack_ts_one_digit_fraction_is_100ms_not_1ms() { // ".1" carries decimal semantics: 0.1 s = 100 ms. - assert_eq!(slack_ts_to_iso8601("1714204397.1"), "2024-04-27T07:53:17.100Z"); + assert_eq!( + slack_ts_to_iso8601("1714204397.1"), + "2024-04-27T07:53:17.100Z" + ); } #[test] From 442f4809d82f2859cdb033549d914a4df6231dda Mon Sep 17 00:00:00 2001 From: chaodufashi Date: Wed, 6 May 2026 16:23:10 -0400 Subject: [PATCH 003/100] feat(discord): support role mention as trigger (allowed_role_ids) (#759) * feat(discord): support role mention as trigger (allowed_role_ids) Add allowed_role_ids config field to DiscordConfig. When a message mentions a role in this list, it is treated as equivalent to a direct @mention for trigger purposes. - src/config.rs: add allowed_role_ids field (default empty) - src/discord.rs: extend is_mentioned to check msg.mention_roles against allowed_role_ids; update resolve_mentions to strip triggering role mentions from prompt - src/main.rs: parse allowed_role_ids via parse_id_set, pass to Handler - charts/openab: add allowedRoleIds with snowflake validation - config.toml.example: document new field Closes #758 Discord Discussion URL: https://discord.com/channels/1488041051187974246/1501546581105705012 * docs(discord): document allowed_role_ids feature Update docs/discord.md: - Add allowed_role_ids config reference section with setup steps - Update @Mention Behavior to include role mention trigger - Update Helm Values example with allowedRoleIds - Update troubleshooting to reflect role mention support --------- Co-authored-by: chaodu-agent --- charts/openab/templates/configmap.yaml | 11 ++++++ charts/openab/values.yaml | 7 ++++ config.toml.example | 2 + docs/discord.md | 53 ++++++++++++++++++++++---- src/config.rs | 5 +++ src/discord.rs | 48 ++++++++++++++++++----- src/main.rs | 3 ++ 7 files changed, 111 insertions(+), 18 deletions(-) diff --git a/charts/openab/templates/configmap.yaml b/charts/openab/templates/configmap.yaml index 32106c2bd..ebbdb7586 100644 --- a/charts/openab/templates/configmap.yaml +++ b/charts/openab/templates/configmap.yaml @@ -44,6 +44,17 @@ data: {{- if $cfg.discord.trustedBotIds }} trusted_bot_ids = {{ $cfg.discord.trustedBotIds | toJson }} {{- end }} + {{- range $cfg.discord.allowedRoleIds }} + {{- if regexMatch "e\\+|E\\+" (toString .) }} + {{- fail (printf "discord.allowedRoleIds contains a mangled ID: %s — use --set-string instead of --set for role IDs" (toString .)) }} + {{- end }} + {{- if not (regexMatch "^[0-9]{17,20}$" (toString .)) }} + {{- fail (printf "discord.allowedRoleIds contains an invalid role ID: %s — must be a 17-20 digit snowflake ID" (toString .)) }} + {{- end }} + {{- end }} + {{- if $cfg.discord.allowedRoleIds }} + allowed_role_ids = {{ $cfg.discord.allowedRoleIds | toJson }} + {{- end }} {{- /* allowUserMessages: controls whether the bot requires @mention in threads (Discord) */ -}} {{- if $cfg.discord.allowUserMessages }} {{- if not (has $cfg.discord.allowUserMessages (list "involved" "mentions" "multibot-mentions")) }} diff --git a/charts/openab/values.yaml b/charts/openab/values.yaml index 2935637df..8a83e963a 100644 --- a/charts/openab/values.yaml +++ b/charts/openab/values.yaml @@ -44,6 +44,8 @@ agents: # allowBotMessages: "off" # # trustedBotIds: [] # empty = any bot (mode permitting) # trustedBotIds: [] + # # allowedRoleIds: [] # role IDs that trigger the bot + # allowedRoleIds: [] # workingDir: /home/agent # # nameOverride: custom deployment name (default: -) # nameOverride: "" @@ -163,6 +165,11 @@ agents: allowBotMessages: "off" # trustedBotIds: [] # empty = any bot (mode permitting); set to restrict trustedBotIds: [] + # allowedRoleIds: Role IDs that trigger the bot (same as direct @mention). + # Create a Discord role, assign it to the bot, then users can @role to trigger. + # Empty (default) = role mentions do not trigger the bot. + # allowedRoleIds: ["1234567890123456789"] + allowedRoleIds: [] # maxBotTurns: soft cap on consecutive bot turns per thread before # the bot stops auto-replying. A human message resets the counter. # Default 100 (Rust-side `default_max_bot_turns()`). Raise for long diff --git a/config.toml.example b/config.toml.example index cf99f6bdb..cc3282a9a 100644 --- a/config.toml.example +++ b/config.toml.example @@ -11,6 +11,8 @@ allowed_channels = ["1234567890"] # ↑ omitted + non-empty list → auto- # allow_bot_messages = "off" # "off" (default) | "mentions" | "all" # "mentions" is recommended for multi-agent collaboration # trusted_bot_ids = [] # empty = any bot (mode permitting); set to restrict +# allowed_role_ids = [] # role IDs that trigger the bot (same as direct @mention) + # note: if multiple bots share the same role, all will respond simultaneously # allow_user_messages = "involved" # "involved" (default) | "mentions" # "involved" = reply in threads bot owns or has participated in # "mentions" = always require @mention diff --git a/docs/discord.md b/docs/discord.md index 9a3527962..ac4af4e96 100644 --- a/docs/discord.md +++ b/docs/discord.md @@ -134,18 +134,53 @@ trusted_bot_ids = ["123456789012345678"] # only this bot's messages pass throug Empty (default) = any bot can pass through (subject to the mode check). +### `allowed_role_ids` + +Role IDs that trigger the bot, same as a direct @mention. This enables users to invoke multiple bots simultaneously with a single role mention (e.g. `@AllBots review this`). + +```toml +allowed_role_ids = ["123456789012345678"] # @mention this role = trigger the bot +``` + +Empty (default) = role mentions do not trigger the bot. + +**Setup:** +1. Create a Discord role (e.g. `Bots` or `AllAgents`) +2. Assign the role to all bots you want to trigger together +3. Add the role's ID to each bot's `allowed_role_ids` +4. Users type `@RoleName ` to trigger all bots at once + +> **Note:** If multiple bots share the same role, all will respond simultaneously. Use `multibot-mentions` mode if you want bots to require explicit @mention when other bots are already in the thread. + +#### Interaction with `multibot-mentions` mode + +When `allow_user_messages = "multibot-mentions"` is set alongside `allowed_role_ids`: + +| Action | Result | +|--------|--------| +| `@Role review this` in a channel | All bots trigger (role mention = explicit mention) | +| Follow-up in the thread without @mention | Only the thread owner responds (multibot gate kicks in) | +| `@Role follow up` in the thread | All bots respond again | + +This gives the best of both worlds: one role mention to summon all bots, but subsequent messages in the thread don't cause all bots to pile on. + --- ## @Mention Behavior -**Always @mention the bot user, not the role.** Discord shows both in autocomplete — pick the one without the role icon. +The bot responds to: + +1. **Direct @mention** (`@BotUser`) — always works +2. **Role mention** (`@RoleName`) — only if the role ID is in `allowed_role_ids` +3. **Thread reply** — depends on `allow_user_messages` mode (no @mention needed in `involved` mode) ``` -✅ @AgentBroker hello ← user mention, bot responds -❌ @AgentBroker hello ← role mention (with role icon), bot ignores +✅ @AgentBroker hello ← user mention, bot responds +✅ @AllBots hello ← role mention, bot responds (if role in allowed_role_ids) +❌ @SomeOtherRole hello ← role not in allowed_role_ids, bot ignores ``` -Role mentions are ignored because they are shared across bots and cause false positives in multi-bot setups. This is intentional since v0.7.8-beta.3 (#420, #440). +The triggering role mention is stripped from the prompt sent to the agent (same as the bot's own user mention). ### User mention UIDs @@ -153,7 +188,8 @@ When a user mentions another user (e.g. `@SomeUser`) in a message to the bot, th - The LLM can copy `<@UID>` into its reply to produce a clickable Discord mention - The bot's own mention is stripped (so the bot doesn't see itself being triggered) -- Role mentions are replaced with `@(role)` placeholder +- Triggering role mentions (in `allowed_role_ids`) are stripped +- Other role mentions are replaced with `@(role)` placeholder To help the LLM know who each UID refers to, provide a UID→name mapping via system prompt or context entry (see [Multi-Bot Setup](#multi-bot-setup) below). @@ -274,10 +310,11 @@ helm install openab openab/openab \ --set agents.kiro.discord.botToken="$DISCORD_BOT_TOKEN" \ --set-string 'agents.kiro.discord.allowedChannels[0]=YOUR_CHANNEL_ID' \ --set agents.kiro.discord.allowBotMessages=off \ - --set agents.kiro.discord.allowUserMessages=involved + --set agents.kiro.discord.allowUserMessages=involved \ + --set-string 'agents.kiro.discord.allowedRoleIds[0]=YOUR_ROLE_ID' ``` -⚠️ Use `--set-string` for channel/user IDs to avoid float64 precision loss. +⚠️ Use `--set-string` for channel/user/role IDs to avoid float64 precision loss. --- @@ -288,7 +325,7 @@ helm install openab openab/openab \ 1. **Check channel ID** — make sure it's in `allowed_channels` 2. **Check permissions** — bot needs Send Messages, Create Public Threads, Read Message History in the channel 3. **Check intents** — Message Content Intent must be enabled in Developer Portal -4. **Check @mention type** — use user mention, not role mention +4. **Check @mention type** — use user mention or a role in `allowed_role_ids` 5. **Check if in a thread** — with `mentions` mode, @mention is required even in threads ### Bot stops receiving messages after restart diff --git a/src/config.rs b/src/config.rs index dd56484d2..f3c60f66b 100644 --- a/src/config.rs +++ b/src/config.rs @@ -161,6 +161,11 @@ pub struct DiscordConfig { /// Human message resets the counter. Default: 100. #[serde(default = "default_max_bot_turns")] pub max_bot_turns: u32, + /// Role IDs that trigger the bot (same as direct @mention). + /// When a message mentions a role in this list, it is treated as a bot trigger. + /// Empty (default) = role mentions do not trigger the bot. + #[serde(default)] + pub allowed_role_ids: Vec, /// Allow the bot to respond to Discord direct messages (DMs). /// Default: false (opt-in). `allowed_users` still applies in DMs. #[serde(default)] diff --git a/src/discord.rs b/src/discord.rs index a8b27be26..e4946ea36 100644 --- a/src/discord.rs +++ b/src/discord.rs @@ -155,6 +155,8 @@ pub struct Handler { pub allow_bot_messages: AllowBots, pub trusted_bot_ids: HashSet, pub allow_user_messages: AllowUsers, + /// Role IDs that trigger the bot (same as direct @mention). + pub allowed_role_ids: HashSet, /// Positive-only cache: thread channel_id → cached_at for threads where bot has participated. pub participated_threads: tokio::sync::Mutex>, /// Positive-only cache: thread channel_id → cached_at for threads where other bots have posted. @@ -379,7 +381,9 @@ impl EventHandler for Handler { self.allow_all_channels || self.allowed_channels.contains(&channel_id); let is_mentioned = - msg.mentions_user_id(bot_id) || msg.content.contains(&format!("<@{}>", bot_id)); + msg.mentions_user_id(bot_id) || msg.content.contains(&format!("<@{}>", bot_id)) + || (!self.allowed_role_ids.is_empty() + && msg.mention_roles.iter().any(|r| self.allowed_role_ids.contains(&r.get()))); // Bot message gating (from upstream #321) if msg.author.bot { @@ -570,7 +574,7 @@ impl EventHandler for Handler { return; } - let prompt = resolve_mentions(&msg.content, bot_id); + let prompt = resolve_mentions(&msg.content, bot_id, &self.allowed_role_ids); // No text and no attachments → skip if prompt.is_empty() && msg.attachments.is_empty() { @@ -1264,13 +1268,19 @@ fn is_thread_already_exists_error(err: &anyhow::Error) -> bool { static ROLE_MENTION_RE: LazyLock = LazyLock::new(|| regex::Regex::new(r"<@&\d+>").unwrap()); -fn resolve_mentions(content: &str, bot_id: UserId) -> String { +fn resolve_mentions(content: &str, bot_id: UserId, allowed_role_ids: &HashSet) -> String { // 1. Strip the bot's own trigger mention let out = content .replace(&format!("<@{}>", bot_id), "") .replace(&format!("<@!{}>", bot_id), ""); - // 2. Other user mentions: keep <@UID> as-is so the LLM can mention back - // 3. Fallback: replace role mentions only (user mentions are preserved) + // 2. Strip allowed role mentions (they triggered the bot, not useful in prompt) + let out = if allowed_role_ids.is_empty() { + out + } else { + allowed_role_ids.iter().fold(out, |s, id| s.replace(&format!("<@&{}>", id), "")) + }; + // 3. Other user mentions: keep <@UID> as-is so the LLM can mention back + // 4. Fallback: replace remaining role mentions only (user mentions are preserved) let out = ROLE_MENTION_RE.replace_all(&out, "@(role)").to_string(); out.trim().to_string() } @@ -1416,7 +1426,7 @@ mod tests { #[test] fn resolve_mentions_strips_bot_mention() { let bot_id = UserId::new(111); - let result = resolve_mentions("hello <@111> world", bot_id); + let result = resolve_mentions("hello <@111> world", bot_id, &HashSet::new()); assert_eq!(result, "hello world"); } @@ -1424,7 +1434,7 @@ mod tests { #[test] fn resolve_mentions_strips_bot_mention_legacy() { let bot_id = UserId::new(111); - let result = resolve_mentions("hello <@!111> world", bot_id); + let result = resolve_mentions("hello <@!111> world", bot_id, &HashSet::new()); assert_eq!(result, "hello world"); } @@ -1432,7 +1442,7 @@ mod tests { #[test] fn resolve_mentions_preserves_other_user_mentions() { let bot_id = UserId::new(111); - let result = resolve_mentions("<@111> say hi to <@222>", bot_id); + let result = resolve_mentions("<@111> say hi to <@222>", bot_id, &HashSet::new()); assert_eq!(result, "say hi to <@222>"); } @@ -1440,7 +1450,7 @@ mod tests { #[test] fn resolve_mentions_replaces_role_mentions() { let bot_id = UserId::new(111); - let result = resolve_mentions("hello <@&999>", bot_id); + let result = resolve_mentions("hello <@&999>", bot_id, &HashSet::new()); assert_eq!(result, "hello @(role)"); } @@ -1448,10 +1458,28 @@ mod tests { #[test] fn resolve_mentions_empty_after_strip() { let bot_id = UserId::new(111); - let result = resolve_mentions("<@111>", bot_id); + let result = resolve_mentions("<@111>", bot_id, &HashSet::new()); assert_eq!(result, ""); } + /// Allowed role mentions are stripped from prompt (not replaced with @(role)). + #[test] + fn resolve_mentions_strips_allowed_role() { + let bot_id = UserId::new(111); + let roles: HashSet = [999].into_iter().collect(); + let result = resolve_mentions("hello <@&999> world", bot_id, &roles); + assert_eq!(result, "hello world"); + } + + /// Non-allowed role mentions are still replaced with @(role). + #[test] + fn resolve_mentions_keeps_other_roles_as_placeholder() { + let bot_id = UserId::new(111); + let roles: HashSet = [999].into_iter().collect(); + let result = resolve_mentions("<@&999> check <@&888>", bot_id, &roles); + assert_eq!(result, "check @(role)"); + } + // --- thread-race error detection --- /// Detects the Discord error code for "thread already exists" (160004). diff --git a/src/main.rs b/src/main.rs index 3cfce2dbf..1a6eb5b64 100644 --- a/src/main.rs +++ b/src/main.rs @@ -374,12 +374,14 @@ async fn main() -> anyhow::Result<()> { let allowed_users = parse_id_set(&discord_cfg.allowed_users, "discord.allowed_users")?; let trusted_bot_ids = parse_id_set(&discord_cfg.trusted_bot_ids, "discord.trusted_bot_ids")?; + let allowed_role_ids = parse_id_set(&discord_cfg.allowed_role_ids, "discord.allowed_role_ids")?; info!( allow_all_channels, allow_all_users, channels = allowed_channels.len(), users = allowed_users.len(), trusted_bots = trusted_bot_ids.len(), + role_triggers = allowed_role_ids.len(), allow_bot_messages = ?discord_cfg.allow_bot_messages, allow_user_messages = ?discord_cfg.allow_user_messages, allow_dm = discord_cfg.allow_dm, @@ -410,6 +412,7 @@ async fn main() -> anyhow::Result<()> { allow_bot_messages: discord_cfg.allow_bot_messages, trusted_bot_ids, allow_user_messages: discord_cfg.allow_user_messages, + allowed_role_ids, participated_threads: tokio::sync::Mutex::new(std::collections::HashMap::new()), multibot_threads: tokio::sync::Mutex::new(std::collections::HashMap::new()), session_ttl: std::time::Duration::from_secs(ttl_secs), From 271bbb1d62f45ea4e8ec97cbbea1f7146a72c6f5 Mon Sep 17 00:00:00 2001 From: "openab-app[bot]" <274185012+openab-app[bot]@users.noreply.github.com> Date: Thu, 7 May 2026 04:53:46 +0800 Subject: [PATCH 004/100] release: v0.8.3-beta.4 (#763) Co-authored-by: openab-app[bot] <274185012+openab-app[bot]@users.noreply.github.com> --- charts/openab/Chart.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/charts/openab/Chart.yaml b/charts/openab/Chart.yaml index d21edc249..426a63184 100644 --- a/charts/openab/Chart.yaml +++ b/charts/openab/Chart.yaml @@ -2,5 +2,5 @@ apiVersion: v2 name: openab description: A lightweight, secure, cloud-native ACP harness that bridges Discord and any ACP-compatible coding CLI. type: application -version: 0.8.3-beta.3 -appVersion: "0.8.3-beta.3" +version: 0.8.3-beta.4 +appVersion: "0.8.3-beta.4" From 0ec5b8f82208a502613d07a284f32d44aacf4d68 Mon Sep 17 00:00:00 2001 From: chaodufashi Date: Wed, 6 May 2026 20:09:37 -0400 Subject: [PATCH 005/100] docs: add ECS Fargate Spot reference architecture (#765) * docs: add ECS Fargate Spot reference architecture * docs: add gist config to diagram and Phase 4 * docs: add AI-agent usage callout at top * docs: separate AWS boundary from external services in diagram * docs: fix right border alignment in diagram * docs: fix diagram border alignment * docs: fix diagram alignment (consistent column width) * docs: use ASCII-only diagram for consistent rendering * docs: use full GitHub URL in example prompt --------- Co-authored-by: chaodu-agent --- docs/refarch/aws-ecs-fargate-spot.md | 189 +++++++++++++++++++++++++++ 1 file changed, 189 insertions(+) create mode 100644 docs/refarch/aws-ecs-fargate-spot.md diff --git a/docs/refarch/aws-ecs-fargate-spot.md b/docs/refarch/aws-ecs-fargate-spot.md new file mode 100644 index 000000000..a4e2b1cf7 --- /dev/null +++ b/docs/refarch/aws-ecs-fargate-spot.md @@ -0,0 +1,189 @@ +# Reference Architecture: OpenAB on AWS ECS Fargate Spot + +> **This doc is meant to be used with Kiro or any coding CLI.** Prompt your AI agent with something like: +> +> ``` +> per https://github.com/openabdev/openab/blob/main/docs/refarch/aws-ecs-fargate-spot.md deploy an openab on ECS Fargate Spot for me in my AWS account +> ``` +> +> and it will guide you through (or handle) the full setup on AWS. + +Deploy a single OpenAB bot on ECS Fargate Spot for ~$2.7/month with persistent auth via S3. + +## Architecture + +``` ++-- AWS -------------------------------------------------------+ +| | +| +-- ECS Fargate Spot Task --------------------------------+ | +| | | | +| | +-----------+ +----------------+ +------------+ | | +| | |s3-restore | | openab | | s3-sync | | | +| | |(init) |->|(main container)| | (sidecar) | | | +| | |pull auth | | kiro-cli acp | | push auth | | | +| | |from S3 | | Discord bot | | every 5min | | | +| | +-----------+ +----------------+ +------------+ | | +| | | | | | | | +| | +--------------+- /data volume ----+ | | +| +---------------------------------------------------------+ | +| | +| | | | +| S3 Bucket Secrets Manager | +| (auth state) (bot token) | +| | ++--------------------------------------------------------------+ + | | + Discord API +-- GitHub ------+ + (bot gateway) | Gist | + | (config.toml) | + +----------------+ +``` + +## Cost + +| Resource | Spec | Spot Price/mo | +|----------|------|---------------| +| Fargate Task | 0.25 vCPU + 512MB | ~$2.7 | +| S3 | < 1MB state | ~$0 | +| Secrets Manager | 1 secret | $0.40 | +| CloudWatch Logs | minimal | ~$0 | +| **Total** | | **~$3.1/month** | + +## Prerequisites + +- AWS CLI configured with permissions for ECS, IAM, S3, Secrets Manager, CloudWatch Logs, EC2 +- A Discord bot token (from Discord Developer Portal) +- Kiro CLI subscription (for OAuth login) + +## Deployment Steps + +### Phase 1: Store the Discord bot token + +Create a Secrets Manager secret with key `DISCORD_BOT_TOKEN`: + +```bash +aws secretsmanager create-secret --name openab \ + --secret-string '{"DISCORD_BOT_TOKEN":"YOUR_BOT_TOKEN_HERE"}' \ + --region us-east-1 +``` + +Note the secret ARN for later. + +### Phase 2: Create IAM roles + +Create two roles for ECS tasks: + +1. **Execution role** (`openab-ecs-execution-role`): + - Trust: `ecs-tasks.amazonaws.com` + - Attach: `AmazonECSTaskExecutionRolePolicy` + - Inline policy: `secretsmanager:GetSecretValue` on the secret ARN + +2. **Task role** (`openab-ecs-task-role`): + - Trust: `ecs-tasks.amazonaws.com` + - Inline policies: + - S3: `s3:GetObject`, `s3:PutObject`, `s3:ListBucket`, `s3:DeleteObject` on the state bucket + - SSM (for ECS Exec): `ssmmessages:CreateControlChannel`, `CreateDataChannel`, `OpenControlChannel`, `OpenDataChannel` + +### Phase 3: Create infrastructure + +1. **S3 bucket** for auth state persistence (e.g. `openab-state-`) +2. **CloudWatch log group** `/ecs/openab` +3. **ECS cluster** named `openab` with capacity providers `FARGATE_SPOT` + `FARGATE` +4. **Security group** — egress-only (no inbound rules needed) + +### Phase 4: Create the config.toml + +Host `config.toml` as a GitHub Gist (recommended) or any HTTPS URL. OpenAB fetches it at startup via `openab run -c `. + +Create a **secret gist** (or public if you prefer) with your config: + +```bash +gh gist create --filename config.toml --desc "OpenAB ECS config" - <<'EOF' +[discord] +bot_token = "${DISCORD_BOT_TOKEN}" +allow_all_channels = true +allow_all_users = true +allow_bot_messages = "mentions" +allow_user_messages = "multibot-mentions" +max_bot_turns = 1000 +message_processing_mode = "per-thread" + +[agent] +command = "kiro-cli" +args = ["acp", "--trust-all-tools"] +working_dir = "/home/agent" + +[pool] +max_sessions = 3 +session_ttl_hours = 1 + +[reactions] +enabled = true +remove_after_reply = false +EOF +``` + +Use the raw gist URL (e.g. `https://gist.githubusercontent.com///raw//config.toml`) in Phase 5. + +### Phase 5: Register task definition and create service + +Register a task definition with three containers: + +| Container | Image | Role | Essential | +|-----------|-------|------|-----------| +| `s3-restore` | `amazon/aws-cli` | Pull auth from S3 + `chown 1000:1000` | No (init) | +| `openab` | `ghcr.io/openabdev/openab:latest` | Main bot process | Yes | +| `s3-sync` | `amazon/aws-cli` | Push auth to S3 every 5 min | No (sidecar) | + +Key settings: +- CPU: 256 (0.25 vCPU), Memory: 512 MB +- Network mode: `awsvpc`, assign public IP +- Capacity provider: `FARGATE_SPOT` +- Enable ECS Exec for interactive login +- `openab` container depends on `s3-restore` (condition: SUCCESS) +- `openab` entrypoint: restore auth from shared volume, then `exec openab run -c ` +- Inject `DISCORD_BOT_TOKEN` from Secrets Manager via container `secrets` +- Shared volume (`agent-data`) mounted at `/data` across all containers + +Create an ECS service with `desiredCount: 1`. + +### Phase 6: Authenticate Kiro CLI (one-time) + +After the task starts, exec in and login: + +```bash +TASK_ID=$(aws ecs list-tasks --cluster openab --service-name openab-kiro \ + --desired-status RUNNING --query 'taskArns[0]' --output text | awk -F/ '{print $NF}') + +aws ecs execute-command --cluster openab --task $TASK_ID \ + --container openab --interactive \ + --command "kiro-cli login --use-device-flow" +``` + +Then copy auth to the shared volume for S3 persistence: + +```bash +aws ecs execute-command --cluster openab --task $TASK_ID \ + --container openab --interactive \ + --command "cp /home/agent/.local/share/kiro-cli/data.sqlite3 /data/data.sqlite3" +``` + +The sidecar syncs to S3 within 5 minutes. Future task restarts auto-restore auth. + +### Phase 7: Verify + +Mention `@YourBot` in a Discord channel. Check logs: + +```bash +aws logs tail /ecs/openab --follow --region us-east-1 +``` + +Look for: `discord bot connected` → `spawning agent` → streaming response. + +## Important Notes + +- **Spot interruption**: Task may be reclaimed with 2-min notice. Auth persists via S3; bot reconnects automatically on new task launch. +- **Auth file ownership**: The S3 restore step must `chown 1000:1000` the auth file — ECS Exec runs as root but kiro-cli runs as uid 1000 (`agent`). +- **Config via URL**: `openab run -c ` fetches config over HTTPS. Use `${ENV_VAR}` for secrets — expanded at runtime from container environment. +- **No NAT needed**: Public subnet + `assignPublicIp: ENABLED` gives direct internet access. +- **Memory**: 512MB is tight (~370MB idle). Bump to 1024MB if sessions OOM. From 00da8e4026f86ca17a1bcd385ace2c71b43aa6a6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E8=AA=9E=E5=AB=A3?= Date: Fri, 8 May 2026 07:14:42 +0800 Subject: [PATCH 006/100] feat(gateway): feishu thread participation tracking (involved mode) (#744) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(gateway): feishu thread participation tracking (involved mode) Once the bot replies in a thread, subsequent messages in that thread bypass @mention gating — matching Discord's default 'involved' mode. - Add participated_threads cache (HashMap) - Bypass mention gating when message is in a participated thread - Record participation on successful reply to a thread - TTL controlled by FEISHU_SESSION_TTL_HOURS (default 24h) - Cache eviction at 1000 entries (oldest-half strategy) - 3 new tests for participation logic * refactor(gateway): address review nits on #744 - Extract check_thread_participated() helper to reduce duplication - Add comments explaining intentional poisoned-mutex recovery - Improve eviction: drop TTL-expired entries first, then oldest half * fix(gateway): address second-round review nits on #744 - Add comment clarifying session_ttl_secs=0 disables participation tracking - Update bot_turns comment: remove TODO, note existing eviction pattern * fix(gateway): early-return in record_participation when TTL=0 Skip cache insertion entirely when session_ttl_secs is 0 (feature disabled), avoiding unnecessary mutex lock and cache accumulation. --------- Co-authored-by: wangyuyan-agent <265828726+wangyuyan-agent@users.noreply.github.com> Co-authored-by: masami-agent --- docs/feishu.md | 9 ++ gateway/src/adapters/feishu.rs | 188 ++++++++++++++++++++++++++++----- 2 files changed, 173 insertions(+), 24 deletions(-) diff --git a/docs/feishu.md b/docs/feishu.md index a4a494f98..b1c70eb2a 100644 --- a/docs/feishu.md +++ b/docs/feishu.md @@ -80,6 +80,7 @@ https://your-gateway-host/webhook/feishu | — | `FEISHU_ALLOW_BOTS` | `off` | Bot message handling: `off` / `mentions` / `all` | | — | `FEISHU_TRUSTED_BOT_IDS` | — | Comma-separated open_id list of known bots | | — | `FEISHU_MAX_BOT_TURNS` | `20` | Max consecutive bot replies per channel before suppression | +| — | `FEISHU_SESSION_TTL_HOURS` | `24` | How long the bot remembers thread participation (hours). After expiry, @mention is required again. | | `gateway.botUsername` | — | — | Set to bot's `open_id` for @mention gating | | `gateway.streaming` | — | `false` | Enable streaming (typewriter) mode | @@ -95,6 +96,14 @@ In group chats, the bot only responds when @mentioned (default). To find your bo To disable mention gating: `feishu.requireMention: false`. +### Thread Participation (Involved Mode) + +Once the bot replies in a thread (topic), it remembers that thread and responds to subsequent messages **without requiring @mention** — similar to Discord's `allow_user_messages: "involved"` mode. + +- Only applies to threads (messages with `root_id`). Main channel messages always require @mention. +- Participation is stored in memory. Gateway restart clears the cache; users need to @mention once to re-engage. +- TTL controlled by `FEISHU_SESSION_TTL_HOURS` (default 24h). After expiry, @mention is required again. + ## Security Notes - `appSecret`, `verificationToken`, and `encryptKey` are stored in a Kubernetes Secret, not in ConfigMap. diff --git a/gateway/src/adapters/feishu.rs b/gateway/src/adapters/feishu.rs index 922fae342..286fa32b1 100644 --- a/gateway/src/adapters/feishu.rs +++ b/gateway/src/adapters/feishu.rs @@ -85,6 +85,11 @@ pub struct FeishuConfig { pub max_bot_turns: u32, pub dedupe_ttl_secs: u64, pub message_limit: usize, + /// TTL for participated-thread cache entries (seconds). Threads older than + /// this are forgotten and require a fresh @mention to re-engage. + /// Set to 0 (via FEISHU_SESSION_TTL_HOURS=0) to disable participation + /// tracking entirely — all messages will require @mention. + pub session_ttl_secs: u64, } impl FeishuConfig { @@ -137,6 +142,11 @@ impl FeishuConfig { .ok() .and_then(|v| v.parse().ok()) .unwrap_or(4000); + let session_ttl_secs = std::env::var("FEISHU_SESSION_TTL_HOURS") + .ok() + .and_then(|v| v.parse::().ok()) + .unwrap_or(24) + * 3600; Some(Self { app_id, @@ -154,6 +164,7 @@ impl FeishuConfig { max_bot_turns, dedupe_ttl_secs, message_limit, + session_ttl_secs, }) } @@ -246,6 +257,7 @@ mod event_types { envelope: &FeishuEventEnvelope, bot_open_id: Option<&str>, config: &FeishuConfig, + is_thread_participated: bool, ) -> Option<(GatewayEvent, Vec)> { let _header = envelope.header.as_ref()?; let event = envelope.event.as_ref()?; @@ -412,11 +424,16 @@ mod event_types { // Gateway-side mention gating: in groups, skip if require_mention // is true and bot is not mentioned (for human senders). + // Bypass: if bot has previously replied in this thread (participated), + // no @mention needed (like Discord's "involved" mode). + let in_thread = thread_id.is_some(); if channel_type == "group" && !is_bot_sender && config.require_mention { - if let Some(bot_id) = bot_open_id { - let bot_mentioned = mention_ids.iter().any(|id| id == bot_id); - if !bot_mentioned { - return None; + if !(in_thread && is_thread_participated) { + if let Some(bot_id) = bot_open_id { + let bot_mentioned = mention_ids.iter().any(|id| id == bot_id); + if !bot_mentioned { + return None; + } } } } @@ -627,7 +644,11 @@ pub struct FeishuAdapter { pub name_cache: Arc>>, /// Per-channel bot turn counter. Key = chat_id, Value = (count, last_reset). /// Human message resets count to 0. Prevents runaway bot-to-bot loops. - pub bot_turns: Arc>>, // TODO: add TTL eviction for long-running deploys + pub bot_turns: Arc>>, // eviction: human msg resets; follow-up can add TTL like participated_threads + /// Positive-only cache: thread_id (root_id) → last_replied_at. + /// When bot has replied in a thread, subsequent messages in that thread + /// bypass @mention gating (like Discord's "involved" mode). + pub participated_threads: Arc>>, pub client: reqwest::Client, } @@ -644,6 +665,7 @@ impl FeishuAdapter { bot_open_id: Arc::new(RwLock::new(None)), name_cache: Arc::new(std::sync::Mutex::new(HashMap::new())), bot_turns: Arc::new(std::sync::Mutex::new(HashMap::new())), + participated_threads: Arc::new(std::sync::Mutex::new(HashMap::new())), client: reqwest::Client::new(), } } @@ -737,6 +759,7 @@ pub async fn start_websocket( let client = adapter.client.clone(); let name_cache = adapter.name_cache.clone(); let bot_turns = adapter.bot_turns.clone(); + let participated_threads = adapter.participated_threads.clone(); let handle = tokio::spawn(async move { let mut backoff_secs = 1u64; @@ -751,6 +774,7 @@ pub async fn start_websocket( &mut shutdown_rx, &name_cache, &bot_turns, + &participated_threads, ) .await; @@ -791,6 +815,7 @@ async fn ws_connect_loop( shutdown_rx: &mut watch::Receiver, name_cache: &Arc>>, bot_turns: &Arc>>, + participated_threads: &Arc>>, ) -> anyhow::Result<()> { let api_base = config.api_base(); @@ -818,7 +843,7 @@ async fn ws_connect_loop( Some(Ok(tokio_tungstenite::tungstenite::Message::Text(text))) => { handle_ws_message( &text, bot_open_id_store, dedupe, config, event_tx, - name_cache, token_cache, client, bot_turns, + name_cache, token_cache, client, bot_turns, participated_threads, ).await; } Some(Ok(tokio_tungstenite::tungstenite::Message::Ping(data))) => { @@ -839,7 +864,7 @@ async fn ws_connect_loop( if let Ok(text) = String::from_utf8(payload.clone()) { handle_ws_message( &text, bot_open_id_store, dedupe, config, event_tx, - name_cache, token_cache, client, bot_turns, + name_cache, token_cache, client, bot_turns, participated_threads, ).await; } } @@ -879,6 +904,7 @@ async fn handle_ws_message( token_cache: &Arc, client: &reqwest::Client, bot_turns: &Arc>>, + participated_threads: &Arc>>, ) { let envelope: FeishuEventEnvelope = match serde_json::from_str(text) { Ok(e) => e, @@ -914,7 +940,12 @@ async fn handle_ws_message( let bot_id = bot_open_id_store.read().await; let bot_id_ref = bot_id.as_deref(); - if let Some((mut gateway_event, media_refs)) = parse_message_event(&envelope, bot_id_ref, config) { + // Check if the message is in a thread where bot has previously replied + let is_thread_participated = check_thread_participated( + &envelope, participated_threads, config.session_ttl_secs, + ); + + if let Some((mut gateway_event, media_refs)) = parse_message_event(&envelope, bot_id_ref, config, is_thread_participated) { // Also dedupe by message_id if dedupe.is_duplicate(&gateway_event.message_id) { return; @@ -1639,6 +1670,59 @@ async fn remove_reaction(adapter: &FeishuAdapter, message_id: &str, emoji: &str) // Reply handler // --------------------------------------------------------------------------- +/// Check if the bot has participated in the thread referenced by this envelope. +/// Returns `true` if the message is in a thread and that thread has a valid +/// (non-expired) participation entry in the cache. +fn check_thread_participated( + envelope: &FeishuEventEnvelope, + cache: &Arc>>, + session_ttl_secs: u64, +) -> bool { + envelope + .event + .as_ref() + .and_then(|e| e.message.as_ref()) + .and_then(|m| m.root_id.as_deref().or(m.parent_id.as_deref())) + .map(|tid| { + // Intentionally recover from poisoned mutex — cache data loss is acceptable + // and preferable to panicking the gateway. + let c = cache.lock().unwrap_or_else(|e| e.into_inner()); + c.get(tid).is_some_and(|ts| ts.elapsed().as_secs() < session_ttl_secs) + }) + .unwrap_or(false) +} + +/// Max entries in the participated_threads cache before eviction. +const PARTICIPATION_CACHE_MAX: usize = 1000; + +/// Record that the bot has participated in a thread. Evicts oldest entries +/// when the cache exceeds PARTICIPATION_CACHE_MAX. +fn record_participation( + cache: &Arc>>, + thread_id: &str, + session_ttl_secs: u64, +) { + if session_ttl_secs == 0 { + return; // Participation tracking disabled + } + // Intentionally recover from poisoned mutex — cache data loss is acceptable + // and preferable to panicking the gateway. + let mut map = cache.lock().unwrap_or_else(|e| e.into_inner()); + map.insert(thread_id.to_string(), Instant::now()); + // Evict if over capacity: first drop expired entries, then oldest half if still over + if map.len() > PARTICIPATION_CACHE_MAX { + map.retain(|_, ts| ts.elapsed().as_secs() < session_ttl_secs); + if map.len() > PARTICIPATION_CACHE_MAX { + let mut entries: Vec<_> = map.iter().map(|(k, v)| (k.clone(), *v)).collect(); + entries.sort_by_key(|(_, ts)| *ts); + let evict_count = entries.len() / 2; + for (k, _) in entries.into_iter().take(evict_count) { + map.remove(&k); + } + } + } +} + pub async fn handle_reply( reply: &GatewayReply, adapter: &FeishuAdapter, @@ -1701,6 +1785,10 @@ pub async fn handle_reply( match send_post_message(&adapter.client, &api_base, &token, &reply.channel.id, thread_id, text).await { Some(msg_id) => { adapter.dedupe.is_duplicate(&msg_id); + // Record thread participation for mention bypass + if let Some(tid) = thread_id { + record_participation(&adapter.participated_threads, tid, adapter.config.session_ttl_secs); + } // Send response with message_id back to OAB core (for streaming edit) if let Some(ref req_id) = reply.request_id { let resp = crate::schema::GatewayResponse { @@ -1734,9 +1822,16 @@ pub async fn handle_reply( } } } else { + let mut sent_any = false; for chunk in split_text(text, limit) { if let Some(msg_id) = send_post_message(&adapter.client, &api_base, &token, &reply.channel.id, thread_id, chunk).await { adapter.dedupe.is_duplicate(&msg_id); + sent_any = true; + } + } + if sent_any { + if let Some(tid) = thread_id { + record_participation(&adapter.participated_threads, tid, adapter.config.session_ttl_secs); } } } @@ -2012,7 +2107,12 @@ pub async fn webhook( let bot_id = feishu.bot_open_id.read().await; let bot_id_ref = bot_id.as_deref(); - if let Some((mut gateway_event, media_refs)) = parse_message_event(&envelope, bot_id_ref, &feishu.config) { + // Check participated threads for mention bypass + let is_thread_participated = check_thread_participated( + &envelope, &feishu.participated_threads, feishu.config.session_ttl_secs, + ); + + if let Some((mut gateway_event, media_refs)) = parse_message_event(&envelope, bot_id_ref, &feishu.config, is_thread_participated) { if !feishu.dedupe.is_duplicate(&gateway_event.message_id) { let name = resolve_user_name( &gateway_event.sender.id, &feishu.name_cache, &feishu.token_cache, @@ -2086,6 +2186,7 @@ mod tests { max_bot_turns: 20, dedupe_ttl_secs: 300, message_limit: 4000, + session_ttl_secs: 86400, } } @@ -2304,7 +2405,7 @@ mod tests { fn parse_dm_text() { let env = make_envelope("p2p", "hello", "ou_user1", None); let cfg = test_config(); - let (evt, _media) = parse_message_event(&env, Some("ou_bot"), &cfg).unwrap(); + let (evt, _media) = parse_message_event(&env, Some("ou_bot"), &cfg, false).unwrap(); assert_eq!(evt.platform, "feishu"); assert_eq!(evt.channel.channel_type, "direct"); assert_eq!(evt.channel.id, "oc_chat1"); @@ -2324,7 +2425,7 @@ mod tests { }]; let env = make_envelope("group", "@_user_1 explain VPC", "ou_user1", Some(mentions)); let cfg = test_config(); - let (evt, _media) = parse_message_event(&env, Some("ou_bot"), &cfg).unwrap(); + let (evt, _media) = parse_message_event(&env, Some("ou_bot"), &cfg, false).unwrap(); assert_eq!(evt.channel.channel_type, "group"); assert_eq!(evt.content.text, "explain VPC"); assert_eq!(evt.mentions, vec!["ou_bot"]); @@ -2335,7 +2436,7 @@ mod tests { let env = make_envelope("group", "just chatting", "ou_user1", None); let cfg = test_config(); // require_mention = true // Gateway-side mention gating: group message without bot mention is filtered - assert!(parse_message_event(&env, Some("ou_bot"), &cfg).is_none()); + assert!(parse_message_event(&env, Some("ou_bot"), &cfg, false).is_none()); } #[test] @@ -2343,7 +2444,7 @@ mod tests { let env = make_envelope("group", "just chatting", "ou_user1", None); let mut cfg = test_config(); cfg.require_mention = false; - let evt = parse_message_event(&env, Some("ou_bot"), &cfg); + let evt = parse_message_event(&env, Some("ou_bot"), &cfg, false); assert!(evt.is_some()); } @@ -2352,14 +2453,14 @@ mod tests { let mut env = make_envelope("p2p", "hello", "ou_bot", None); env.event.as_mut().unwrap().sender.as_mut().unwrap().sender_type = Some("bot".into()); let cfg = test_config(); - assert!(parse_message_event(&env, Some("ou_bot"), &cfg).is_none()); + assert!(parse_message_event(&env, Some("ou_bot"), &cfg, false).is_none()); } #[test] fn parse_skips_empty_text() { let env = make_envelope("p2p", " ", "ou_user1", None); let cfg = test_config(); - assert!(parse_message_event(&env, Some("ou_bot"), &cfg).is_none()); + assert!(parse_message_event(&env, Some("ou_bot"), &cfg, false).is_none()); } #[test] @@ -2367,14 +2468,14 @@ mod tests { let mut env = make_envelope("p2p", "hello", "ou_user1", None); env.event.as_mut().unwrap().message.as_mut().unwrap().message_type = Some("sticker".into()); let cfg = test_config(); - assert!(parse_message_event(&env, Some("ou_bot"), &cfg).is_none()); + assert!(parse_message_event(&env, Some("ou_bot"), &cfg, false).is_none()); } #[test] fn parse_skips_self_message() { let env = make_envelope("p2p", "hello", "ou_bot", None); let cfg = test_config(); - assert!(parse_message_event(&env, Some("ou_bot"), &cfg).is_none()); + assert!(parse_message_event(&env, Some("ou_bot"), &cfg, false).is_none()); } // --- Dedupe tests --- @@ -2506,7 +2607,7 @@ mod tests { }]; let env = make_envelope("group", "@_user_1 tell me about @_user_1 patterns", "ou_user1", Some(mentions)); let cfg = test_config(); - let (evt, _media) = parse_message_event(&env, Some("ou_bot"), &cfg).unwrap(); + let (evt, _media) = parse_message_event(&env, Some("ou_bot"), &cfg, false).unwrap(); // Only first @_user_1 removed, second preserved assert!(evt.content.text.contains("@_user_1")); } @@ -2518,7 +2619,7 @@ mod tests { let env = make_envelope("p2p", "hello", "ou_stranger", None); let mut cfg = test_config(); cfg.allowed_users = vec!["ou_vip".into()]; - assert!(parse_message_event(&env, Some("ou_bot"), &cfg).is_none()); + assert!(parse_message_event(&env, Some("ou_bot"), &cfg, false).is_none()); } #[test] @@ -2526,7 +2627,7 @@ mod tests { let env = make_envelope("p2p", "hello", "ou_vip", None); let mut cfg = test_config(); cfg.allowed_users = vec!["ou_vip".into()]; - assert!(parse_message_event(&env, Some("ou_bot"), &cfg).is_some()); + assert!(parse_message_event(&env, Some("ou_bot"), &cfg, false).is_some()); } // --- allowed_groups filtering --- @@ -2541,7 +2642,7 @@ mod tests { let env = make_envelope("group", "@_user_1 hello", "ou_user1", Some(mentions)); let mut cfg = test_config(); cfg.allowed_groups = vec!["oc_other".into()]; // oc_chat1 not in list - assert!(parse_message_event(&env, Some("ou_bot"), &cfg).is_none()); + assert!(parse_message_event(&env, Some("ou_bot"), &cfg, false).is_none()); } #[test] @@ -2554,7 +2655,7 @@ mod tests { let env = make_envelope("group", "@_user_1 hello", "ou_user1", Some(mentions)); let mut cfg = test_config(); cfg.allowed_groups = vec!["oc_chat1".into()]; - assert!(parse_message_event(&env, Some("ou_bot"), &cfg).is_some()); + assert!(parse_message_event(&env, Some("ou_bot"), &cfg, false).is_some()); } // --- Token TTL from API response --- @@ -2611,7 +2712,7 @@ mod tests { let mut env = make_envelope("p2p", "reply", "ou_user1", None); env.event.as_mut().unwrap().message.as_mut().unwrap().root_id = Some("om_root".into()); let cfg = test_config(); - let (evt, _media) = parse_message_event(&env, Some("ou_bot"), &cfg).unwrap(); + let (evt, _media) = parse_message_event(&env, Some("ou_bot"), &cfg, false).unwrap(); assert_eq!(evt.channel.thread_id, Some("om_root".into())); } @@ -2620,7 +2721,7 @@ mod tests { let mut env = make_envelope("p2p", "reply", "ou_user1", None); env.event.as_mut().unwrap().message.as_mut().unwrap().parent_id = Some("om_parent".into()); let cfg = test_config(); - let (evt, _media) = parse_message_event(&env, Some("ou_bot"), &cfg).unwrap(); + let (evt, _media) = parse_message_event(&env, Some("ou_bot"), &cfg, false).unwrap(); assert_eq!(evt.channel.thread_id, Some("om_parent".into())); } @@ -2637,4 +2738,43 @@ mod tests { fn emoji_mapping_unknown() { assert_eq!(emoji_to_feishu_reaction("🎉"), None); } + + // --- Participated thread tests --- + + #[test] + fn participated_thread_bypasses_mention_gating() { + let cfg = test_config(); // require_mention = true + // Build envelope with root_id (in a thread) + let mut env = make_envelope("group", "Hello", "ou_user1", None); + env.event.as_mut().unwrap().message.as_mut().unwrap().root_id = Some("root_123".into()); + // Without participation: no @mention → None + assert!(parse_message_event(&env, Some("ou_bot"), &cfg, false).is_none()); + // With participation: no @mention → Some (bypass) + let result = parse_message_event(&env, Some("ou_bot"), &cfg, true); + assert!(result.is_some()); + let (evt, _) = result.unwrap(); + assert_eq!(evt.channel.thread_id.as_deref(), Some("root_123")); + } + + #[test] + fn participated_no_effect_without_thread() { + let cfg = test_config(); // require_mention = true + // Message in main channel (no thread_id) — participated flag doesn't help + let env = make_envelope("group", "Hello", "ou_user1", None); + assert!(parse_message_event(&env, Some("ou_bot"), &cfg, true).is_none()); + } + + #[test] + fn record_participation_and_eviction() { + let cache = Arc::new(std::sync::Mutex::new(HashMap::new())); + // Record a thread + record_participation(&cache, "thread_1", 86400); + assert_eq!(cache.lock().unwrap().len(), 1); + // Fill beyond PARTICIPATION_CACHE_MAX + for i in 0..PARTICIPATION_CACHE_MAX + 10 { + record_participation(&cache, &format!("thread_{i}"), 86400); + } + // After eviction, should be roughly half + assert!(cache.lock().unwrap().len() <= PARTICIPATION_CACHE_MAX); + } } From 205aa8fb574bf28c9842d076a02a43caf40a3be5 Mon Sep 17 00:00:00 2001 From: Brett Chien Date: Fri, 8 May 2026 07:16:55 +0800 Subject: [PATCH 007/100] fix(acp): clean up pending + cancel agent on abandoned prompts (#760) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(acp): clean up pending + cancel agent on abandoned prompts (#732) The flat 600s recv_timeout in adapter.rs:386 fires "Agent stopped responding" without removing pending[id] or sending session/cancel. The agent keeps running the abandoned prompt and eventually emits its final response with the original id. The reader at connection.rs:284 looks up pending[id], sees the now-stale entry, and forwards the message to the *current* notify_tx subscriber — which belongs to the next prompt. The next prompt's loop sees notification.id.is_some() and breaks immediately with empty text_buf, returning "(no response)". Each new prompt sent before the agent drains its backlog inherits the previous prompt's stale id and the cascade persists. Fix follows the issue's recommended A+B+C: (A) Replace flat 600s timeout with a tokio::select! loop in stream_prompt_blocks. Recv arm + 30s liveness arm. Liveness arm checks conn.alive() (cheap, just !reader_handle.is_finished()) and a configurable hard ceiling. Default ceiling is 30 min via pool.prompt_hard_timeout_secs. Long-running tools no longer trip the timeout — only a dead reader task or the hard ceiling abandon the prompt. (B) Add AcpConnection::abandon_request(request_id) called on every abandon path: drops pending[request_id] so a late response cannot route to a future subscriber, and best-effort writes session/cancel so the agent stops working on a request the broker has given up on. (C) Capture request_id from session_prompt() (was discarded as `_`) and skip notifications whose id doesn't match. Defense-in-depth at the routing layer; complements (B)'s cleanup if any future abandon path forgets to call abandon_request. No unit test for abandon_request — the connection has no test seam without spawning a real subprocess. Behavior is exercised end-to-end via the adapter loop on real ACP backends. Refs: - #76 (Assumption 2: prompts always complete) - #307 (sibling: same family, different visible symptom) - #470 (added the 600s recv timeout this issue exposes) Co-Authored-By: Claude Opus 4.7 * chore(acp): apply chaodu-agent NITs from PR #760 - pool.liveness_check_secs: hoist the recv-loop poll cadence out of a hard-coded const onto PoolConfig so deployments can tune it. Default remains 30s. - adapter: change hard-timeout error message from ({}m) to ({}s) so non-multiple-of-60 ceilings render correctly (e.g. 90s → "(90s)"). - acp/connection: emit a tracing::trace! line when an id-bearing message arrives whose pending entry was already abandoned. Behaviour is unchanged — the adapter recv loop still filters by request_id; this just makes the stale-response path observable at trace level. cargo check + cargo clippy -- -D warnings + cargo test --bin openab all clean (238 passed). * fix(acp): add precision doc + id to session/cancel - Add ±liveness_check_secs precision note to prompt_hard_timeout_secs doc - Add JSON-RPC id field to session/cancel in abandon_request Co-authored-by: 超渡法師 * chore(acp): apply chaodu-agent round-2 NITs from PR #760 - adapter::AdapterRouter::new: emit a tracing::warn! when liveness_check_secs >= prompt_hard_timeout_secs, since in that case the hard ceiling can only fire on the next liveness tick and may be effectively bypassed. Operator-visible warning, not a silent clamp. - adapter: switch prompt_start from std::time::Instant to tokio::time::Instant so the timer shares tokio's clock with the tokio::time::sleep in the same select! arm (cohesive with future tokio::time::pause()-based tests). - adapter + acp/connection: extend the stale-id filter / fall-through comments to note that the path is only exercised against a live subprocess and is covered by manual repro, not a unit test. Note: chaodu-agent NIT 2 (cancel response noise) requires no code change. abandon_request emits a JSON-RPC notification (no id field) per the ACP spec, so a spec-compliant agent must not respond, and even a non-compliant reply with no id would not hit the stale-id trace path. PR comment to follow. cargo check + cargo clippy -- -D warnings + cargo test --bin openab all clean (238 passed). * test(acp): add reader-loop unit tests for stale-id path (#732) Extract the reader-loop body in AcpConnection::spawn into a free generic function `run_reader_loop` so tests can drive it with `tokio::io::duplex()` halves instead of a real subprocess. Production path is unchanged — spawn() now calls `tokio::spawn(run_reader_loop(...))` with the same args. Two new tests cover: - stale-id response forwarded to subscriber when `pending` is empty (the #732 fall-through path that the adapter recv loop filters by request_id) - matched-id response resolves the pending oneshot AND forwards a copy to the subscriber (regression guard for the dual branch) Co-Authored-By: Claude Opus 4.7 * docs(acp): clarify abandon_request stale-id intent (#732 NIT 2) session/cancel carries a fresh JSON-RPC id but is intentionally not registered in `pending`, so the agent's reply lands in the stale-id branch of run_reader_loop and only emits a trace! log. We never wait on the cancel response; the adapter recv loop's request_id filter is the actual safety net against leakage into the next prompt. Doc-only — no behavioural change. Co-Authored-By: Claude Opus 4.7 * chore(acp): apply chaodu-agent round-3 NITs from PR #760 NIT 1: `abandon_request` was sending `session/cancel` with a JSON-RPC id, making it request-shaped. Per ACP spec, `session/cancel` is a client notification (no id, no response expected). Pool-side `cancel_session` and `reset_session` were already notification-style; this aligns `abandon_request` with both spec and existing convention. Doc comment reverted to notification semantics. NIT 2: Reject `pool.liveness_check_secs = 0` in `parse_config`. Zero would make the `tokio::time::sleep` arm in the recv `select!` loop immediately ready, spinning the loop while the prompt is still under the hard timeout. --------- Co-authored-by: Brett Chien <1193046+brettchien@users.noreply.github.com> Co-authored-by: Claude Opus 4.7 Co-authored-by: 超渡法師 --- config.toml.example | 4 + src/acp/connection.rs | 331 ++++++++++++++++++++++++++++++------------ src/adapter.rs | 66 +++++++-- src/config.rs | 26 ++++ src/dispatch.rs | 2 + src/main.rs | 2 + 6 files changed, 324 insertions(+), 107 deletions(-) diff --git a/config.toml.example b/config.toml.example index cc3282a9a..d33a0902b 100644 --- a/config.toml.example +++ b/config.toml.example @@ -106,6 +106,10 @@ working_dir = "/home/agent" [pool] max_sessions = 10 session_ttl_hours = 24 +# Hard ceiling (sec) per prompt; see #732. Default: 1800 (30 min). +# prompt_hard_timeout_secs = 1800 +# Liveness-check cadence (sec) for the recv loop; see #732. Default: 30. +# liveness_check_secs = 30 [markdown] tables = "code" # "code" (default) | "bullets" | "off" diff --git a/src/acp/connection.rs b/src/acp/connection.rs index c1b36a472..90c0eae24 100644 --- a/src/acp/connection.rs +++ b/src/acp/connection.rs @@ -6,11 +6,11 @@ use serde_json::{json, Value}; use std::collections::HashMap; use std::sync::atomic::{AtomicU64, Ordering}; use std::sync::Arc; -use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; +use tokio::io::{AsyncBufReadExt, AsyncRead, AsyncWrite, AsyncWriteExt, BufReader}; use tokio::process::{Child, ChildStdin}; use tokio::sync::{mpsc, oneshot, Mutex}; use tokio::task::JoinHandle; -use tracing::{debug, error, info}; +use tracing::{debug, error, info, trace}; /// Pick the most permissive selectable permission option from ACP options. fn pick_best_option(options: &[Value]) -> Option { @@ -149,6 +149,112 @@ fn build_agent_env( (result, inherited) } +/// Reader loop body: reads JSON-RPC messages from `reader`, auto-replies +/// `session/request_permission` via `writer`, resolves pending responses, +/// and forwards notifications + stale id-bearing messages to the active +/// subscriber. Extracted as a free generic function so unit tests can drive +/// it with `tokio::io::duplex()` halves instead of a real child process. +pub(crate) async fn run_reader_loop( + reader: R, + writer: Arc>, + pending: Arc>>>, + notify_tx: Arc>>>, +) where + R: AsyncRead + Unpin + Send + 'static, + W: AsyncWrite + Unpin + Send + 'static, +{ + let mut reader = BufReader::new(reader); + let mut line = String::new(); + loop { + line.clear(); + match reader.read_line(&mut line).await { + Ok(0) => break, // EOF + Ok(_) => {} + Err(e) => { + error!("reader error: {e}"); + break; + } + } + let msg: JsonRpcMessage = match serde_json::from_str(line.trim()) { + Ok(m) => m, + Err(_) => continue, + }; + debug!(line = line.trim(), "acp_recv"); + + // Auto-reply session/request_permission + if msg.method.as_deref() == Some("session/request_permission") { + if let Some(id) = msg.id { + let title = msg + .params + .as_ref() + .and_then(|p| p.get("toolCall")) + .and_then(|t| t.get("title")) + .and_then(|t| t.as_str()) + .unwrap_or("?"); + + let outcome = build_permission_response(msg.params.as_ref()); + info!(title, %outcome, "auto-respond permission"); + let reply = JsonRpcResponse::new(id, outcome); + if let Ok(data) = serde_json::to_string(&reply) { + let mut w = writer.lock().await; + let _ = w.write_all(format!("{data}\n").as_bytes()).await; + let _ = w.flush().await; + } + } + continue; + } + + // Response (has id) → resolve pending AND forward to subscriber + if let Some(id) = msg.id { + let mut map = pending.lock().await; + if let Some(tx) = map.remove(&id) { + // Forward to subscriber so they see the completion + let sub = notify_tx.lock().await; + if let Some(ntx) = sub.as_ref() { + // Clone the essential fields for the subscriber + let _ = ntx.send(JsonRpcMessage { + id: Some(id), + method: None, + result: msg.result.clone(), + error: msg.error.clone(), + params: None, + }); + } + let _ = tx.send(msg); + continue; + } + // Stale id (#732): pending was already abandoned. Falls through + // to subscriber forwarding; the adapter recv loop filters by + // request_id so it can't leak into the next prompt. + trace!(request_id = id, "stale id-bearing message after abandon"); + } + + // Notification → forward to subscriber + let sub = notify_tx.lock().await; + if let Some(tx) = sub.as_ref() { + let _ = tx.send(msg); + } + } + + // Connection closed — resolve all pending with error + let mut map = pending.lock().await; + for (_, tx) in map.drain() { + let _ = tx.send(JsonRpcMessage { + id: None, + method: None, + result: None, + error: Some(crate::acp::protocol::JsonRpcError { + code: -1, + message: "connection closed".into(), + }), + params: None, + }); + } + // Close the notify channel so rx.recv() returns None + let mut sub = notify_tx.lock().await; + *sub = None; +} + impl AcpConnection { pub async fn spawn( command: &str, @@ -254,99 +360,12 @@ impl AcpConnection { let notify_tx: Arc>>> = Arc::new(Mutex::new(None)); - let reader_handle = { - let pending = pending.clone(); - let notify_tx = notify_tx.clone(); - let stdin_clone = stdin.clone(); - tokio::spawn(async move { - let mut reader = BufReader::new(stdout); - let mut line = String::new(); - loop { - line.clear(); - match reader.read_line(&mut line).await { - Ok(0) => break, // EOF - Ok(_) => {} - Err(e) => { - error!("reader error: {e}"); - break; - } - } - let msg: JsonRpcMessage = match serde_json::from_str(line.trim()) { - Ok(m) => m, - Err(_) => continue, - }; - debug!(line = line.trim(), "acp_recv"); - - // Auto-reply session/request_permission - if msg.method.as_deref() == Some("session/request_permission") { - if let Some(id) = msg.id { - let title = msg - .params - .as_ref() - .and_then(|p| p.get("toolCall")) - .and_then(|t| t.get("title")) - .and_then(|t| t.as_str()) - .unwrap_or("?"); - - let outcome = build_permission_response(msg.params.as_ref()); - info!(title, %outcome, "auto-respond permission"); - let reply = JsonRpcResponse::new(id, outcome); - if let Ok(data) = serde_json::to_string(&reply) { - let mut w = stdin_clone.lock().await; - let _ = w.write_all(format!("{data}\n").as_bytes()).await; - let _ = w.flush().await; - } - } - continue; - } - - // Response (has id) → resolve pending AND forward to subscriber - if let Some(id) = msg.id { - let mut map = pending.lock().await; - if let Some(tx) = map.remove(&id) { - // Forward to subscriber so they see the completion - let sub = notify_tx.lock().await; - if let Some(ntx) = sub.as_ref() { - // Clone the essential fields for the subscriber - let _ = ntx.send(JsonRpcMessage { - id: Some(id), - method: None, - result: msg.result.clone(), - error: msg.error.clone(), - params: None, - }); - } - let _ = tx.send(msg); - continue; - } - } - - // Notification → forward to subscriber - let sub = notify_tx.lock().await; - if let Some(tx) = sub.as_ref() { - let _ = tx.send(msg); - } - } - - // Connection closed — resolve all pending with error - let mut map = pending.lock().await; - for (_, tx) in map.drain() { - let _ = tx.send(JsonRpcMessage { - id: None, - method: None, - result: None, - error: Some(crate::acp::protocol::JsonRpcError { - code: -1, - message: "connection closed".into(), - }), - params: None, - }); - } - // Close the notify channel so rx.recv() returns None - let mut sub = notify_tx.lock().await; - *sub = None; - }) - }; + let reader_handle = tokio::spawn(run_reader_loop( + stdout, + stdin.clone(), + pending.clone(), + notify_tx.clone(), + )); Ok(Self { _proc: proc, @@ -557,6 +576,26 @@ impl AcpConnection { self.last_active = Instant::now(); } + /// Drop the pending entry for `request_id` and best-effort send + /// `session/cancel` as a JSON-RPC notification (no id; per ACP spec the + /// agent does not reply). Errors are swallowed: the agent process may + /// already be dead, in which case the stdin write fails harmlessly. + /// See #732. + pub async fn abandon_request(&self, request_id: u64) { + self.pending.lock().await.remove(&request_id); + let Some(session_id) = self.acp_session_id.as_deref() else { + return; + }; + let req = json!({ + "jsonrpc": "2.0", + "method": "session/cancel", + "params": {"sessionId": session_id}, + }); + if let Ok(data) = serde_json::to_string(&req) { + let _ = self.send_raw(&data).await; + } + } + /// Return a clone of the stdin handle for lock-free cancel. pub fn cancel_handle(&self) -> Arc> { Arc::clone(&self.stdin) @@ -758,3 +797,105 @@ mod tests { assert!(inherited.is_empty()); } } + +#[cfg(test)] +mod reader_loop_tests { + use super::*; + use std::collections::HashMap; + use std::sync::Arc; + use tokio::io::{duplex, AsyncWriteExt}; + use tokio::sync::{mpsc, oneshot, Mutex}; + + /// #732 stale-id path: when a response arrives for an id the broker has + /// already abandoned, the reader must (a) not crash, (b) leave `pending` + /// untouched, and (c) still forward the message to whoever is currently + /// subscribed — the adapter recv loop is responsible for filtering by + /// request_id so the stray response never leaks into the next prompt. + #[tokio::test] + async fn stale_id_response_is_forwarded_without_pending_entry() { + let (mut agent_stdout_writer, agent_stdout_reader) = duplex(8 * 1024); + let (agent_stdin_writer, _agent_stdin_reader) = duplex(8 * 1024); + + let pending: Arc>>> = + Arc::new(Mutex::new(HashMap::new())); + let notify_tx: Arc>>> = + Arc::new(Mutex::new(None)); + + let (sub_tx, mut sub_rx) = mpsc::unbounded_channel(); + *notify_tx.lock().await = Some(sub_tx); + + let writer = Arc::new(Mutex::new(agent_stdin_writer)); + let handle = tokio::spawn(run_reader_loop( + agent_stdout_reader, + writer, + pending.clone(), + notify_tx.clone(), + )); + + let stale = b"{\"jsonrpc\":\"2.0\",\"id\":42,\"result\":{\"stopReason\":\"ok\"}}\n"; + agent_stdout_writer.write_all(stale).await.unwrap(); + agent_stdout_writer.flush().await.unwrap(); + + let forwarded = tokio::time::timeout( + std::time::Duration::from_secs(2), + sub_rx.recv(), + ) + .await + .expect("subscriber should receive stale message before timeout") + .expect("subscriber channel should not be closed"); + assert_eq!(forwarded.id, Some(42)); + assert!(pending.lock().await.is_empty()); + + drop(agent_stdout_writer); + handle.await.unwrap(); + } + + /// Matched-id path: when a response's id is in `pending`, the loop must + /// resolve the oneshot AND forward a copy to the subscriber so the + /// adapter's recv loop sees the completion. Guards against regressions + /// that would suppress the forward branch while keeping resolve. + #[tokio::test] + async fn matched_id_response_resolves_pending_and_forwards() { + let (mut agent_stdout_writer, agent_stdout_reader) = duplex(8 * 1024); + let (agent_stdin_writer, _agent_stdin_reader) = duplex(8 * 1024); + + let pending: Arc>>> = + Arc::new(Mutex::new(HashMap::new())); + let notify_tx: Arc>>> = + Arc::new(Mutex::new(None)); + + let (resp_tx, resp_rx) = oneshot::channel(); + pending.lock().await.insert(7, resp_tx); + + let (sub_tx, mut sub_rx) = mpsc::unbounded_channel(); + *notify_tx.lock().await = Some(sub_tx); + + let writer = Arc::new(Mutex::new(agent_stdin_writer)); + let handle = tokio::spawn(run_reader_loop( + agent_stdout_reader, + writer, + pending.clone(), + notify_tx.clone(), + )); + + let payload = b"{\"jsonrpc\":\"2.0\",\"id\":7,\"result\":{\"stopReason\":\"end_turn\"}}\n"; + agent_stdout_writer.write_all(payload).await.unwrap(); + agent_stdout_writer.flush().await.unwrap(); + + let resolved = tokio::time::timeout(std::time::Duration::from_secs(2), resp_rx) + .await + .expect("oneshot should resolve") + .expect("oneshot should not be cancelled"); + assert_eq!(resolved.id, Some(7)); + + let forwarded = tokio::time::timeout(std::time::Duration::from_secs(2), sub_rx.recv()) + .await + .expect("subscriber should receive forwarded copy") + .expect("subscriber channel should not be closed"); + assert_eq!(forwarded.id, Some(7)); + assert!(pending.lock().await.is_empty()); + + drop(agent_stdout_writer); + handle.await.unwrap(); + } +} diff --git a/src/adapter.rs b/src/adapter.rs index 106cd47bd..5a80f29e7 100644 --- a/src/adapter.rs +++ b/src/adapter.rs @@ -2,7 +2,7 @@ use anyhow::Result; use async_trait::async_trait; use serde::Serialize; use std::sync::Arc; -use tracing::error; +use tracing::{error, warn}; use crate::acp::{classify_notification, AcpEvent, ContentBlock, SessionPool}; use crate::config::{ReactionsConfig, ToolDisplay}; @@ -159,6 +159,9 @@ pub struct AdapterRouter { pool: Arc, reactions_config: ReactionsConfig, table_mode: TableMode, + prompt_hard_timeout: std::time::Duration, + /// Polling cadence for the recv-loop liveness check (#732). + liveness_check_interval: std::time::Duration, } impl AdapterRouter { @@ -166,11 +169,24 @@ impl AdapterRouter { pool: Arc, reactions_config: ReactionsConfig, table_mode: TableMode, + prompt_hard_timeout_secs: u64, + liveness_check_secs: u64, ) -> Self { + if liveness_check_secs >= prompt_hard_timeout_secs { + warn!( + liveness_check_secs, + prompt_hard_timeout_secs, + "pool.liveness_check_secs >= pool.prompt_hard_timeout_secs; \ + the hard ceiling will only fire after the next liveness tick \ + and may be effectively bypassed. Lower liveness_check_secs." + ); + } Self { pool, reactions_config, table_mode, + prompt_hard_timeout: std::time::Duration::from_secs(prompt_hard_timeout_secs), + liveness_check_interval: std::time::Duration::from_secs(liveness_check_secs), } } @@ -335,6 +351,8 @@ impl AdapterRouter { let streaming = adapter.use_streaming(other_bot_present); let table_mode = self.table_mode; let tool_display = self.reactions_config.tool_display; + let prompt_hard_timeout = self.prompt_hard_timeout; + let liveness_check_interval = self.liveness_check_interval; self.pool .with_connection(thread_key, |conn| { @@ -343,7 +361,7 @@ impl AdapterRouter { let reset = conn.session_reset; conn.session_reset = false; - let (mut rx, _) = conn.session_prompt(content_blocks).await?; + let (mut rx, request_id) = conn.session_prompt(content_blocks).await?; reactions.set_thinking().await; let mut text_buf = String::new(); @@ -396,20 +414,44 @@ impl AdapterRouter { (None, None) }; - // Process ACP notifications + // (#732) Liveness-aware recv loop. Filters stale id-bearing + // messages and abandons cleanly on dead agent / hard ceiling + // so late responses cannot leak into the next prompt. let mut response_error: Option = None; - let recv_timeout = std::time::Duration::from_secs(600); + let prompt_start = tokio::time::Instant::now(); loop { - let notification = match tokio::time::timeout(recv_timeout, rx.recv()).await - { - Ok(Some(n)) => n, - Ok(None) => break, // channel closed - Err(_) => { - response_error = Some("Agent stopped responding".into()); - break; + let notification = tokio::select! { + msg = rx.recv() => match msg { + Some(n) => n, + // Reader saw EOF and already drained pending; nothing to abandon. + None => break, + }, + _ = tokio::time::sleep(liveness_check_interval) => { + if !conn.alive() { + response_error = Some("Agent process died".into()); + conn.abandon_request(request_id).await; + break; + } + if prompt_start.elapsed() > prompt_hard_timeout { + response_error = Some(format!( + "Agent exceeded hard timeout ({}s)", + prompt_hard_timeout.as_secs(), + )); + conn.abandon_request(request_id).await; + break; + } + continue; } }; - if notification.id.is_some() { + if let Some(notification_id) = notification.id { + if notification_id != request_id { + // Stale response from a previously-abandoned prompt. + // No automated test seam: this path only triggers when a + // real subprocess emits a late response after the broker + // already called abandon_request — covered by manual + // repro against a live agent (see #732 PR description). + continue; + } if let Some(ref err) = notification.error { response_error = Some(format_coded_error(err.code, &err.message)); } diff --git a/src/config.rs b/src/config.rs index f3c60f66b..2187f0d7f 100644 --- a/src/config.rs +++ b/src/config.rs @@ -318,6 +318,20 @@ pub struct PoolConfig { pub max_sessions: usize, #[serde(default = "default_ttl_hours")] pub session_ttl_hours: u64, + /// Hard ceiling for a single prompt (#732). Once exceeded, the broker + /// abandons the in-flight request, sends `session/cancel` to the agent, + /// and clears the pending entry so late responses cannot leak into the + /// next prompt's subscriber. + /// + /// Precision: checked every `liveness_check_secs`, so actual cutoff is + /// ±`liveness_check_secs` from this value. + #[serde(default = "default_prompt_hard_timeout_secs")] + pub prompt_hard_timeout_secs: u64, + /// Polling cadence (seconds) for the recv-loop liveness check (#732). + /// Lower = faster reaction to a dead agent / hard ceiling at the cost of + /// more wakeups while the agent is streaming normally. + #[serde(default = "default_liveness_check_secs")] + pub liveness_check_secs: u64, } #[derive(Debug, Clone, Deserialize)] @@ -439,6 +453,12 @@ fn default_max_sessions() -> usize { fn default_ttl_hours() -> u64 { 4 } +pub(crate) fn default_prompt_hard_timeout_secs() -> u64 { + 30 * 60 +} +pub(crate) fn default_liveness_check_secs() -> u64 { + 30 +} fn default_true() -> bool { true } @@ -486,6 +506,8 @@ impl Default for PoolConfig { Self { max_sessions: default_max_sessions(), session_ttl_hours: default_ttl_hours(), + prompt_hard_timeout_secs: default_prompt_hard_timeout_secs(), + liveness_check_secs: default_liveness_check_secs(), } } } @@ -622,6 +644,10 @@ fn parse_config(raw: &str, source: &str) -> anyhow::Result { "gateway.max_batch_tokens must be > 0" ); } + anyhow::ensure!( + config.pool.liveness_check_secs > 0, + "pool.liveness_check_secs must be > 0 (zero would spin the recv loop)" + ); Ok(config) } diff --git a/src/dispatch.rs b/src/dispatch.rs index a3fbec886..013ee3d81 100644 --- a/src/dispatch.rs +++ b/src/dispatch.rs @@ -1072,6 +1072,8 @@ mod tests { pool, crate::config::ReactionsConfig::default(), crate::markdown::TableMode::Off, + crate::config::default_prompt_hard_timeout_secs(), + crate::config::default_liveness_check_secs(), )); Dispatcher::with_idle_timeout(router, 10, 24_000, grouping, DEFAULT_CONSUMER_IDLE_TIMEOUT) } diff --git a/src/main.rs b/src/main.rs index 1a6eb5b64..706079b6d 100644 --- a/src/main.rs +++ b/src/main.rs @@ -147,6 +147,8 @@ async fn main() -> anyhow::Result<()> { pool.clone(), cfg.reactions, cfg.markdown.tables, + cfg.pool.prompt_hard_timeout_secs, + cfg.pool.liveness_check_secs, )); // Shutdown signal for Slack adapter From abff22e443d9629efe0f56a2e1574b05b74ee489 Mon Sep 17 00:00:00 2001 From: Masami <266806885+masami-agent@users.noreply.github.com> Date: Fri, 8 May 2026 07:19:44 +0800 Subject: [PATCH 008/100] docs(adr): fix compliance rule precision in turn-boundary-batching (#755) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - §6.4 Rule 1: add adapter-layer scope clarification caveat (resolve_mentions() runs before {prompt} construction, not subject to the rule — rule applies from Dispatcher::submit onward) - §6.4 Rule 8: scope to retry-failed case only (first SendError triggers transparent retry per §2.5; only failed retry surfaces ❌ + ⚠️ + Err) - §3.2: mark slack_ts_to_iso8601 as (proposed helper) - Metadata: reword self-reference for clarity - §6.6: clarify threshold formula units (count × tokens > 500 tokens) Closes #754 Co-authored-by: masami-agent --- docs/adr/turn-boundary-batching.md | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/docs/adr/turn-boundary-batching.md b/docs/adr/turn-boundary-batching.md index 6d9cc909b..de147e59a 100644 --- a/docs/adr/turn-boundary-batching.md +++ b/docs/adr/turn-boundary-batching.md @@ -4,7 +4,7 @@ - **Date:** 2026-04-29 - **Author:** @brettchien - **Tracking issue:** [#580](https://github.com/openabdev/openab/issues/580) — kept as historical discussion record -- **Implementation PR:** [#686](https://github.com/openabdev/openab/pull/686) (Phase 1 wiring; this ADR documents the design it lands) +- **Implementation PR:** [#686](https://github.com/openabdev/openab/pull/686) (Phase 1 wiring; the ADR documents the design that PR implements) - **Related:** [#78](https://github.com/openabdev/openab/issues/78) (Session Management — precondition), [#58](https://github.com/openabdev/openab/issues/58) (per-connection locking — precondition), [#307](https://github.com/openabdev/openab/issues/307) (cross-session blocking — adjacent symptom of §2.7) - **Anchor pinning:** - **Released-code anchors (file:line) — pinned to v0.8.2-beta.1** ([`52052b8`](https://github.com/openabdev/openab/commit/52052b8b104a85a7073dd6ae99eeb9f2fd331abe)). All `acp/connection.rs:NNN`, `acp/pool.rs:NNN`, `adapter.rs:NNN`, `discord.rs:NNN`, `slack.rs:NNN` references resolve at this SHA. They will drift against later commits — that's expected; the ADR documents the *design* relative to a stable base, not a moving target. @@ -360,7 +360,7 @@ For a single-message dispatch (`batch.len() == 1`) the minimum is two blocks: de | Source | Value | |---|---| | Discord adapter | `msg.timestamp` (serenity 0.12 `Timestamp`, RFC 3339 by default) | -| Slack adapter | `slack_ts_to_iso8601(event.ts)` — converts epoch-seconds-with-fractional to ISO 8601 with millisecond precision | +| Slack adapter | `slack_ts_to_iso8601(event.ts)` (proposed helper) — converts epoch-seconds-with-fractional to ISO 8601 with millisecond precision | | Gateway adapter | `chrono::Utc::now().to_rfc3339()` at receive time — best-effort for non-Discord/Slack channels; documented as approximate | `schema` stays `openab.sender.v1` — the field is additive and existing parsers keep working. Two purposes: @@ -788,6 +788,8 @@ The rules below operationalize I3 (broker structural fidelity). Together they fo 1. **Broker forwards `{prompt}` verbatim.** Broker must not parse, classify, transform, summarize, or annotate the user-supplied text content within `{prompt}`. Any future feature that needs to inspect `{prompt}` content must do so without mutating what the agent receives. + *Note: Adapter-level preprocessing that runs before `{prompt}` is constructed (e.g. `resolve_mentions()` in `discord.rs`) is not subject to this rule. This rule applies to the broker/dispatcher layer — i.e. from `Dispatcher::submit` onward.* + **Counter-examples (prohibited):** broker stripping markdown formatting before dispatch; broker expanding Discord `<@123>` mentions to `@username` strings; broker appending an `[image attached]` string when an image accompanies the prompt; broker collapsing repeated whitespace; broker normalizing Unicode forms. 2. **No banners or framing strings.** Broker must not inject any leading or trailing instruction text into the dispatched batch (e.g. no `[Batched: N messages…]`, no `[End of batch]`). All metadata lives in `` JSON. @@ -804,7 +806,7 @@ The rules below operationalize I3 (broker structural fidelity). Together they fo 7. **Splitting only at message boundaries.** When the token-budget cap (`max_batch_tokens`) forces a batch to split across multiple ACP turns, the split must occur between two arrival events — never inside a single arrival event. A single oversized message dispatches alone; the broker does not truncate or summarize it. -8. **No silent failure on consumer death.** When `submit` observes `SendError` (consumer task death), the failure must surface as ❌ on `msg.trigger_msg` **and** `⚠️ {format_user_error}` text in the channel **and** `Err` propagated to the caller. Already-enqueued messages whose `submit` already returned `Ok` are residual loss equivalent to a pod restart mid-turn (documented; out of Phase 1 scope to recover). Messages in the consumer's in-flight batch at the time of the panic are also residual loss — their `submit` already returned `Ok` before the consumer died, so they cannot be reacted from the `SendError` path. +8. **No silent failure on consumer death (retry-failed case).** When `submit` observes `SendError` (consumer task death), it first attempts a transparent retry — evict the dead consumer, spawn a fresh one, and re-send (§2.5). The first `SendError` is absorbed silently because the dominant cause is the benign first-message-after-idle race. Only when the **retry also fails** must the failure surface as ❌ on `msg.trigger_msg` **and** `⚠️ {format_user_error}` text in the channel **and** `Err` propagated to the caller. Already-enqueued messages whose `submit` already returned `Ok` are residual loss equivalent to a pod restart mid-turn (documented; out of Phase 1 scope to recover). Messages in the consumer's in-flight batch at the time of the panic are also residual loss — their `submit` already returned `Ok` before the consumer died, so they cannot be reacted from the `SendError` path. 9. **`bot_turns` runs at ingest, not at dispatch.** Multi-bot loop guards (`slack.rs:672-696`) execute before `submit`; batching is downstream and cannot bypass them. Bot-turn-limit counts batches as turns (one ACP invocation = one logical turn); the per-message ingest counter is unchanged. @@ -848,7 +850,7 @@ info_span!("dispatch", channel = %channel_id, adapter = "discord") Per-event metrics fold into the per-dispatch line as array fields → log line count = dispatch count, independent of batch size. -**Threshold for dedup re-evaluation:** when `p95_batch_size × avg_tokens_per_event > 500 tokens` (used as a rough proxy for per-dispatch `` envelope overhead) on any production channel for a sustained 24h window, the broker team must re-open the dedup question (e.g. emit `` only when sender or timestamp delta changes). Below that threshold the envelope cost is below noise and the readability win of always-explicit headers wins. +**Threshold for dedup re-evaluation:** when `p95_batch_size (count) × avg_tokens_per_event (tokens) > 500 tokens` of per-dispatch `` envelope overhead on any production channel for a sustained 24h window, the broker team must re-open the dedup question (e.g. emit `` only when sender or timestamp delta changes). Below that threshold the envelope cost is below noise and the readability win of always-explicit headers wins. **Phase 1 acceptance test (masami #1):** after Phase 1 lands and is deployed to a test channel, send a 3-message batch and verify the single `info!` line carries `events_per_dispatch = 3`, `packed_block_count = N`, `agent_dispatch_ms = N`, `tokens_per_event = [t1, t2, t3]`, `wait_ms = [w1, w2, w3]`. If any field is missing or events are split across multiple log lines, Phase 1 does not merge. From d728ef2f35b6ba6a0b8a1ddbe6d2fe4648628cdf Mon Sep 17 00:00:00 2001 From: Cheng-Lung Sung Date: Sat, 9 May 2026 01:37:56 +0800 Subject: [PATCH 009/100] [codex] Pass Discord video attachments to agents (#772) * fix(discord): pass video attachments to agents * docs(discord): add Attachment Handling section Document supported attachment types (audio, text, image, video) and the new video metadata forwarding behavior. --------- Co-authored-by: chaodu-agent --- docs/discord.md | 31 +++++++++++++++++++++++++++++++ src/discord.rs | 48 +++++++++++++++++++++++++++++++++++++++++++++++- src/media.rs | 28 ++++++++++++++++++++++++++++ 3 files changed, 106 insertions(+), 1 deletion(-) diff --git a/docs/discord.md b/docs/discord.md index ac4af4e96..0cabd6451 100644 --- a/docs/discord.md +++ b/docs/discord.md @@ -206,6 +206,37 @@ Each thread gets its own agent session. Sessions are cleaned up after `session_t --- +## Attachment Handling + +OpenAB processes Discord file attachments and converts them into content blocks +for the agent. Supported types (checked in order): + +| Type | Detection | Agent receives | +|------|-----------|----------------| +| Audio | MIME `audio/*` | Transcribed text via STT (if enabled) | +| Text files | Extension list (`.txt`, `.md`, `.json`, etc.) | File content inlined (up to 5 files, 1 MB total) | +| Images | MIME `image/*` or image extensions | Base64-encoded image block | +| Video | MIME `video/*` or extensions (`.mp4`, `.mov`, `.webm`, `.mkv`, `.m4v`, `.avi`) | Text block with filename, content type, size, and Discord CDN URL | + +Unsupported attachment types are silently ignored. + +### Video attachments + +Video files are not downloaded or transcoded. The agent receives metadata and the +Discord CDN URL so it can fetch or inspect the file using tools like `ffprobe`. + +``` +[Video attachment] +filename: demo.mp4 +content_type: video/mp4 +size_bytes: 8421376 +url: https://cdn.discordapp.com/attachments/.../demo.mp4 +``` + +No configuration is needed — video forwarding is always enabled. + +--- + ## Streaming OpenAB uses **edit-streaming** on Discord — the bot sends a placeholder message and updates it every 1.5 seconds as tokens arrive, giving a live typing effect. diff --git a/src/discord.rs b/src/discord.rs index e4946ea36..5065aa607 100644 --- a/src/discord.rs +++ b/src/discord.rs @@ -596,7 +596,8 @@ impl EventHandler for Handler { &msg.timestamp.to_rfc3339().unwrap_or_default(), ); - // Build extra content blocks from attachments (audio → STT, text → inline, image → encode) + // Build extra content blocks from attachments (audio -> STT, text -> inline, + // image -> encode, video -> URL for agent-side inspection). let mut extra_blocks = Vec::new(); let mut echo_entries: Vec = Vec::new(); let mut text_file_bytes: u64 = 0; @@ -675,6 +676,14 @@ impl EventHandler for Handler { { debug!(url = %attachment.url, filename = %attachment.filename, "adding image attachment"); extra_blocks.push(block); + } else if media::is_video_file(&attachment.filename, attachment.content_type.as_deref()) { + debug!(url = %attachment.url, filename = %attachment.filename, "adding video attachment link"); + extra_blocks.push(video_attachment_block( + &attachment.filename, + attachment.content_type.as_deref(), + u64::from(attachment.size), + &attachment.url, + )); } } @@ -1285,6 +1294,23 @@ fn resolve_mentions(content: &str, bot_id: UserId, allowed_role_ids: &HashSet, + size: u64, + url: &str, +) -> ContentBlock { + ContentBlock::Text { + text: format!( + "[Video attachment]\nfilename: {}\ncontent_type: {}\nsize_bytes: {}\nurl: {}", + filename, + content_type.unwrap_or("unknown"), + size, + url + ), + } +} + /// Build a `SenderContext` for Discord messages. /// /// Pure function extracted from `EventHandler::message` for testability. @@ -1480,6 +1506,26 @@ mod tests { assert_eq!(result, "check @(role)"); } + #[test] + fn video_attachment_block_includes_actionable_metadata() { + let block = video_attachment_block( + "demo.mp4", + Some("video/mp4"), + 12345, + "https://cdn.discordapp.com/attachments/demo.mp4", + ); + + let ContentBlock::Text { text } = block else { + panic!("video attachments must be forwarded as text metadata"); + }; + + assert!(text.contains("[Video attachment]")); + assert!(text.contains("filename: demo.mp4")); + assert!(text.contains("content_type: video/mp4")); + assert!(text.contains("size_bytes: 12345")); + assert!(text.contains("url: https://cdn.discordapp.com/attachments/demo.mp4")); + } + // --- thread-race error detection --- /// Detects the Discord error code for "thread already exists" (160004). diff --git a/src/media.rs b/src/media.rs index aa56e5f49..b42cfa6e2 100644 --- a/src/media.rs +++ b/src/media.rs @@ -198,6 +198,26 @@ pub fn is_audio_mime(mime: &str) -> bool { mime.starts_with("audio/") } +/// Check if an attachment is a video file. +pub fn is_video_file(filename: &str, content_type: Option<&str>) -> bool { + let mime = content_type.unwrap_or(""); + let mime_base = mime.split(';').next().unwrap_or(mime).trim(); + if mime_base.starts_with("video/") { + return true; + } + + filename + .rsplit('.') + .next() + .map(|ext| { + matches!( + ext.to_lowercase().as_str(), + "mp4" | "mov" | "m4v" | "webm" | "mkv" | "avi" + ) + }) + .unwrap_or(false) +} + /// Extensions recognised as text-based files that can be inlined into the prompt. const TEXT_EXTENSIONS: &[&str] = &[ "txt", "csv", "log", "md", "json", "jsonl", "yaml", "yml", "toml", "xml", "rs", "py", "js", @@ -401,4 +421,12 @@ mod tests { let garbage = vec![0x00, 0x01, 0x02, 0x03]; assert!(resize_and_compress(&garbage).is_err()); } + + #[test] + fn video_file_detects_mime_and_common_extensions() { + assert!(is_video_file("clip.bin", Some("video/mp4"))); + assert!(is_video_file("clip.mp4", None)); + assert!(is_video_file("clip.MOV", None)); + assert!(!is_video_file("notes.txt", Some("text/plain"))); + } } From 83ffebd52343a121294b51b17596114bbcae56b2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E8=AA=9E=E5=AB=A3?= Date: Sat, 9 May 2026 01:53:55 +0800 Subject: [PATCH 010/100] feat(gateway): feishu multibot-mentions mode (#746) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(gateway): feishu multibot-mentions mode Add AllowUsers enum (Involved/Mentions/MultibotMentions) controlled by FEISHU_ALLOW_USER_MESSAGES env var. In multibot-mentions mode, once another bot is @mentioned in a participated thread, require @mention for all bots — prevents multiple bots from responding simultaneously. Multibot detection strategy: - If FEISHU_TRUSTED_BOT_IDS configured: exact match - Otherwise: infer from allowed_users (mention not self and not in allowed_users → assumed to be another bot) - Only triggers in threads where bot has already participated This avoids requiring users to discover per-app open_ids for other bots. * refactor(gateway): extract detect_and_mark_multibot() helper Deduplicate the multibot detection block (~30 lines) that was repeated in both handle_ws_message and webhook(). Both now call a shared detect_and_mark_multibot() helper that handles: - Thread participation check - @mention-based other-bot detection (trusted IDs or inference) - Multibot cache marking with eviction - Computing is_thread_participated based on allow_user_messages mode Also update PARTICIPATION_CACHE_MAX comment to note it is intentionally shared between participated_threads and multibot_threads caches. * refactor(gateway): address review nits on #746 1. session_ttl_secs doc comment: clarify conversion from FEISHU_SESSION_TTL_HOURS 2. Rename is_thread_participated → bypass_mention_gating in parse_message_event with doc comment explaining the parameter semantics * fix(gateway): add missing attachments field in googlechat tests Content struct gained an attachments field in #744 merge but googlechat test constructors were not updated. * refactor: rename is_thread_participated to bypass_mention for clarity Rename the local variable at both call sites (WebSocket and webhook) to match the parameter name in parse_message_event, making the semantic intent clearer: this is the final mode-computed bypass decision, not raw participation status. --------- Co-authored-by: wangyuyan-agent <265828726+wangyuyan-agent@users.noreply.github.com> Co-authored-by: masami-agent Co-authored-by: chaodu-agent --- docs/feishu.md | 19 +++ gateway/src/adapters/feishu.rs | 191 +++++++++++++++++++++++++++-- gateway/src/adapters/googlechat.rs | 8 ++ 3 files changed, 205 insertions(+), 13 deletions(-) diff --git a/docs/feishu.md b/docs/feishu.md index b1c70eb2a..f1139d08e 100644 --- a/docs/feishu.md +++ b/docs/feishu.md @@ -81,6 +81,7 @@ https://your-gateway-host/webhook/feishu | — | `FEISHU_TRUSTED_BOT_IDS` | — | Comma-separated open_id list of known bots | | — | `FEISHU_MAX_BOT_TURNS` | `20` | Max consecutive bot replies per channel before suppression | | — | `FEISHU_SESSION_TTL_HOURS` | `24` | How long the bot remembers thread participation (hours). After expiry, @mention is required again. | +| — | `FEISHU_ALLOW_USER_MESSAGES` | `involved` | Thread response mode: `involved` / `mentions` / `multibot-mentions`. See below. | | `gateway.botUsername` | — | — | Set to bot's `open_id` for @mention gating | | `gateway.streaming` | — | `false` | Enable streaming (typewriter) mode | @@ -104,6 +105,24 @@ Once the bot replies in a thread (topic), it remembers that thread and responds - Participation is stored in memory. Gateway restart clears the cache; users need to @mention once to re-engage. - TTL controlled by `FEISHU_SESSION_TTL_HOURS` (default 24h). After expiry, @mention is required again. +### Multi-Bot Threads (multibot-mentions Mode) + +When `FEISHU_ALLOW_USER_MESSAGES=multibot-mentions`, the bot detects when another bot is @mentioned in a participated thread and reverts to requiring @mention — preventing all bots from responding simultaneously. + +| Mode | Behavior | +|------|----------| +| `involved` (default) | Bot responds in participated threads without @mention. All participated bots respond. | +| `multibot-mentions` | Same as `involved`, but once another bot is @mentioned in the thread, require @mention for all bots. | +| `mentions` | Always require @mention, even in participated threads. | + +**Multi-bot detection** (how the gateway identifies "another bot"): + +1. If `FEISHU_TRUSTED_BOT_IDS` is set → exact match against configured IDs +2. If only `FEISHU_ALLOWED_USERS` is set → any @mention that is not self and not in allowed_users is inferred as another bot (recommended, zero-config) +3. If neither is set → no multibot detection + +Note: Detection only triggers in threads where the bot has already participated. This prevents premature marking of threads the bot hasn't joined. + ## Security Notes - `appSecret`, `verificationToken`, and `encryptKey` are stored in a Kubernetes Secret, not in ConfigMap. diff --git a/gateway/src/adapters/feishu.rs b/gateway/src/adapters/feishu.rs index 286fa32b1..09e97fe0d 100644 --- a/gateway/src/adapters/feishu.rs +++ b/gateway/src/adapters/feishu.rs @@ -68,6 +68,20 @@ pub enum AllowBots { All, } +/// Controls when the bot responds without @mention in threads. +/// Mirrors Discord's `allow_user_messages` setting. +#[derive(Debug, Clone, PartialEq, Default)] +pub enum AllowUsers { + /// Bot responds in threads it has participated in without @mention. + #[default] + Involved, + /// Always require @mention, even in participated threads. + Mentions, + /// Like Involved, but if another bot has also posted in the thread, + /// require @mention to avoid all bots responding. + MultibotMentions, +} + #[derive(Debug, Clone)] pub struct FeishuConfig { pub app_id: String, @@ -81,6 +95,7 @@ pub struct FeishuConfig { pub allowed_users: Vec, pub require_mention: bool, pub allow_bots: AllowBots, + pub allow_user_messages: AllowUsers, pub trusted_bot_ids: Vec, pub max_bot_turns: u32, pub dedupe_ttl_secs: u64, @@ -89,6 +104,7 @@ pub struct FeishuConfig { /// this are forgotten and require a fresh @mention to re-engage. /// Set to 0 (via FEISHU_SESSION_TTL_HOURS=0) to disable participation /// tracking entirely — all messages will require @mention. + /// Converted from `FEISHU_SESSION_TTL_HOURS` (user-facing, in hours) to seconds internally. pub session_ttl_secs: u64, } @@ -130,6 +146,16 @@ impl FeishuConfig { _ => AllowBots::Off, }; let trusted_bot_ids = parse_csv("FEISHU_TRUSTED_BOT_IDS"); + let allow_user_messages = match std::env::var("FEISHU_ALLOW_USER_MESSAGES") + .unwrap_or_else(|_| "involved".into()) + .to_lowercase() + .replace('-', "_") + .as_str() + { + "mentions" => AllowUsers::Mentions, + "multibot_mentions" => AllowUsers::MultibotMentions, + _ => AllowUsers::Involved, + }; let max_bot_turns = std::env::var("FEISHU_MAX_BOT_TURNS") .ok() .and_then(|v| v.parse().ok()) @@ -160,6 +186,7 @@ impl FeishuConfig { allowed_users, require_mention, allow_bots, + allow_user_messages, trusted_bot_ids, max_bot_turns, dedupe_ttl_secs, @@ -253,11 +280,16 @@ mod event_types { /// Parse a feishu im.message.receive_v1 event into a GatewayEvent. /// Returns None if the event should be skipped (unsupported type, bot message, etc). /// The Vec contains references to media that need async download. + /// + /// `bypass_mention_gating`: whether the bot should skip @mention requirement for this message. + /// This is the final computed result from mode-specific logic (detect_and_mark_multibot), + /// already accounting for the configured `allow_user_messages` mode. + /// Do NOT pass raw participation status here. pub fn parse_message_event( envelope: &FeishuEventEnvelope, bot_open_id: Option<&str>, config: &FeishuConfig, - is_thread_participated: bool, + bypass_mention_gating: bool, ) -> Option<(GatewayEvent, Vec)> { let _header = envelope.header.as_ref()?; let event = envelope.event.as_ref()?; @@ -428,7 +460,7 @@ mod event_types { // no @mention needed (like Discord's "involved" mode). let in_thread = thread_id.is_some(); if channel_type == "group" && !is_bot_sender && config.require_mention { - if !(in_thread && is_thread_participated) { + if !(in_thread && bypass_mention_gating) { if let Some(bot_id) = bot_open_id { let bot_mentioned = mention_ids.iter().any(|id| id == bot_id); if !bot_mentioned { @@ -649,6 +681,9 @@ pub struct FeishuAdapter { /// When bot has replied in a thread, subsequent messages in that thread /// bypass @mention gating (like Discord's "involved" mode). pub participated_threads: Arc>>, + /// Positive-only cache: thread_id → first_seen for threads where other bots + /// have posted. Used by multibot-mentions mode to require @mention. + pub multibot_threads: Arc>>, pub client: reqwest::Client, } @@ -666,6 +701,7 @@ impl FeishuAdapter { name_cache: Arc::new(std::sync::Mutex::new(HashMap::new())), bot_turns: Arc::new(std::sync::Mutex::new(HashMap::new())), participated_threads: Arc::new(std::sync::Mutex::new(HashMap::new())), + multibot_threads: Arc::new(std::sync::Mutex::new(HashMap::new())), client: reqwest::Client::new(), } } @@ -760,6 +796,7 @@ pub async fn start_websocket( let name_cache = adapter.name_cache.clone(); let bot_turns = adapter.bot_turns.clone(); let participated_threads = adapter.participated_threads.clone(); + let multibot_threads = adapter.multibot_threads.clone(); let handle = tokio::spawn(async move { let mut backoff_secs = 1u64; @@ -775,6 +812,7 @@ pub async fn start_websocket( &name_cache, &bot_turns, &participated_threads, + &multibot_threads, ) .await; @@ -816,6 +854,7 @@ async fn ws_connect_loop( name_cache: &Arc>>, bot_turns: &Arc>>, participated_threads: &Arc>>, + multibot_threads: &Arc>>, ) -> anyhow::Result<()> { let api_base = config.api_base(); @@ -843,7 +882,7 @@ async fn ws_connect_loop( Some(Ok(tokio_tungstenite::tungstenite::Message::Text(text))) => { handle_ws_message( &text, bot_open_id_store, dedupe, config, event_tx, - name_cache, token_cache, client, bot_turns, participated_threads, + name_cache, token_cache, client, bot_turns, participated_threads, multibot_threads, ).await; } Some(Ok(tokio_tungstenite::tungstenite::Message::Ping(data))) => { @@ -864,7 +903,7 @@ async fn ws_connect_loop( if let Ok(text) = String::from_utf8(payload.clone()) { handle_ws_message( &text, bot_open_id_store, dedupe, config, event_tx, - name_cache, token_cache, client, bot_turns, participated_threads, + name_cache, token_cache, client, bot_turns, participated_threads, multibot_threads, ).await; } } @@ -905,6 +944,7 @@ async fn handle_ws_message( client: &reqwest::Client, bot_turns: &Arc>>, participated_threads: &Arc>>, + multibot_threads: &Arc>>, ) { let envelope: FeishuEventEnvelope = match serde_json::from_str(text) { Ok(e) => e, @@ -940,12 +980,16 @@ async fn handle_ws_message( let bot_id = bot_open_id_store.read().await; let bot_id_ref = bot_id.as_deref(); - // Check if the message is in a thread where bot has previously replied - let is_thread_participated = check_thread_participated( - &envelope, participated_threads, config.session_ttl_secs, + // Check if the message is in a thread where bot has previously replied, + // respecting the allow_user_messages mode: + // - Involved (default): bypass @mention if participated + // - MultibotMentions: bypass only if participated AND no other bot in thread + // - Mentions: never bypass + let bypass_mention = detect_and_mark_multibot( + &envelope, bot_id_ref, config, participated_threads, multibot_threads, ); - if let Some((mut gateway_event, media_refs)) = parse_message_event(&envelope, bot_id_ref, config, is_thread_participated) { + if let Some((mut gateway_event, media_refs)) = parse_message_event(&envelope, bot_id_ref, config, bypass_mention) { // Also dedupe by message_id if dedupe.is_duplicate(&gateway_event.message_id) { return; @@ -967,6 +1011,8 @@ async fn handle_ws_message( ); return; } + // (Feishu doesn't push bot messages to other bots' WebSocket, + // so multibot detection is done via mentions instead — see below.) } else { // Human message resets bot turn counter turns.remove(channel_id.as_str()); @@ -1692,9 +1738,92 @@ fn check_thread_participated( .unwrap_or(false) } -/// Max entries in the participated_threads cache before eviction. +/// Max entries before eviction. Shared by both `participated_threads` and +/// `multibot_threads` caches — they have the same cardinality (one entry per +/// active thread) so a single limit is appropriate for both. const PARTICIPATION_CACHE_MAX: usize = 1000; +/// Detect if a message @mentions another bot in a participated thread, and if +/// so, mark the thread in the multibot cache. Returns whether @mention gating +/// should be bypassed, respecting the configured `allow_user_messages` mode. +/// +/// This consolidates the duplicated multibot detection logic used by both the +/// WebSocket and webhook paths. +fn detect_and_mark_multibot( + envelope: &FeishuEventEnvelope, + bot_open_id: Option<&str>, + config: &FeishuConfig, + participated_threads: &Arc>>, + multibot_threads: &Arc>>, +) -> bool { + let self_participated = check_thread_participated( + envelope, participated_threads, config.session_ttl_secs, + ); + + let thread_id_for_check = envelope + .event + .as_ref() + .and_then(|e| e.message.as_ref()) + .and_then(|m| m.root_id.as_deref().or(m.parent_id.as_deref())); + + // Early multibot detection: if a message in a participated thread @mentions + // another bot, mark the thread as multibot immediately. + if let Some(tid) = thread_id_for_check { + if self_participated { + let mentions = envelope + .event + .as_ref() + .and_then(|e| e.message.as_ref()) + .and_then(|m| m.mentions.as_ref()); + if let Some(mention_list) = mentions { + let bot_self_id = bot_open_id.unwrap_or(""); + let mention_ids: Vec<_> = mention_list.iter().filter_map(|m| { + m.id.as_ref().and_then(|id| id.open_id.as_deref()) + }).collect(); + + let mentions_other_bot = if !config.trusted_bot_ids.is_empty() { + mention_ids.iter().any(|oid| { + config.trusted_bot_ids.iter().any(|bid| bid == oid) + }) + } else if !config.allowed_users.is_empty() { + mention_ids.iter().any(|oid| { + *oid != bot_self_id && !config.allowed_users.iter().any(|u| u == oid) + }) + } else { + false + }; + + if mentions_other_bot { + info!(thread_id = %tid, "multibot thread detected via @mention"); + let mut cache = multibot_threads.lock().unwrap_or_else(|e| e.into_inner()); + cache.entry(tid.to_string()).or_insert_with(Instant::now); + if cache.len() > PARTICIPATION_CACHE_MAX { + cache.retain(|_, ts| ts.elapsed().as_secs() < config.session_ttl_secs); + } + } + } + } + } + + // Compute bypass_mention_gating based on mode + match config.allow_user_messages { + AllowUsers::Mentions => false, + AllowUsers::Involved => self_participated, + AllowUsers::MultibotMentions => { + if !self_participated { + false + } else { + thread_id_for_check + .map(|tid| { + let cache = multibot_threads.lock().unwrap_or_else(|e| e.into_inner()); + !cache.get(tid).is_some_and(|ts| ts.elapsed().as_secs() < config.session_ttl_secs) + }) + .unwrap_or(true) + } + } + } +} + /// Record that the bot has participated in a thread. Evicts oldest entries /// when the cache exceeds PARTICIPATION_CACHE_MAX. fn record_participation( @@ -2107,12 +2236,13 @@ pub async fn webhook( let bot_id = feishu.bot_open_id.read().await; let bot_id_ref = bot_id.as_deref(); - // Check participated threads for mention bypass - let is_thread_participated = check_thread_participated( - &envelope, &feishu.participated_threads, feishu.config.session_ttl_secs, + // Check participated threads and multibot detection for mention bypass + let bypass_mention = detect_and_mark_multibot( + &envelope, bot_id_ref, &feishu.config, + &feishu.participated_threads, &feishu.multibot_threads, ); - if let Some((mut gateway_event, media_refs)) = parse_message_event(&envelope, bot_id_ref, &feishu.config, is_thread_participated) { + if let Some((mut gateway_event, media_refs)) = parse_message_event(&envelope, bot_id_ref, &feishu.config, bypass_mention) { if !feishu.dedupe.is_duplicate(&gateway_event.message_id) { let name = resolve_user_name( &gateway_event.sender.id, &feishu.name_cache, &feishu.token_cache, @@ -2182,6 +2312,7 @@ mod tests { allowed_users: vec![], require_mention: true, allow_bots: AllowBots::Off, + allow_user_messages: AllowUsers::Involved, trusted_bot_ids: vec![], max_bot_turns: 20, dedupe_ttl_secs: 300, @@ -2777,4 +2908,38 @@ mod tests { // After eviction, should be roughly half assert!(cache.lock().unwrap().len() <= PARTICIPATION_CACHE_MAX); } + + // --- Multibot-mentions mode tests --- + + #[test] + fn multibot_mentions_mode_bypasses_when_single_bot() { + let mut cfg = test_config(); + cfg.allow_user_messages = AllowUsers::MultibotMentions; + let mut env = make_envelope("group", "Hello", "ou_user1", None); + env.event.as_mut().unwrap().message.as_mut().unwrap().root_id = Some("root_456".into()); + // participated + no other bot → bypass_mention_gating=true + let result = parse_message_event(&env, Some("ou_bot"), &cfg, true); + assert!(result.is_some()); + } + + #[test] + fn multibot_mentions_mode_requires_mention_when_not_participated() { + let mut cfg = test_config(); + cfg.allow_user_messages = AllowUsers::MultibotMentions; + let mut env = make_envelope("group", "Hello", "ou_user1", None); + env.event.as_mut().unwrap().message.as_mut().unwrap().root_id = Some("root_456".into()); + // not participated → bypass_mention_gating=false + assert!(parse_message_event(&env, Some("ou_bot"), &cfg, false).is_none()); + } + + #[test] + fn mentions_mode_never_bypasses() { + let mut cfg = test_config(); + cfg.allow_user_messages = AllowUsers::Mentions; + let mut env = make_envelope("group", "Hello", "ou_user1", None); + env.event.as_mut().unwrap().message.as_mut().unwrap().root_id = Some("root_789".into()); + // Even with bypass_mention_gating=true, Mentions mode never bypasses + // (caller would pass false because Mentions mode always returns false) + assert!(parse_message_event(&env, Some("ou_bot"), &cfg, false).is_none()); + } } diff --git a/gateway/src/adapters/googlechat.rs b/gateway/src/adapters/googlechat.rs index 68759e026..73787089a 100644 --- a/gateway/src/adapters/googlechat.rs +++ b/gateway/src/adapters/googlechat.rs @@ -1371,6 +1371,7 @@ mod tests { content: Content { content_type: "text".into(), text: "hello".into(), + attachments: vec![], }, command: None, request_id: Some("req_123".into()), @@ -1413,6 +1414,7 @@ mod tests { content: Content { content_type: "text".into(), text: "hello".into(), + attachments: vec![], }, command: None, request_id: Some("req_fail".into()), @@ -1459,6 +1461,7 @@ mod tests { content: Content { content_type: "text".into(), text: "".into(), + attachments: vec![], }, command: None, request_id: Some("req_empty".into()), @@ -1502,6 +1505,7 @@ mod tests { content: Content { content_type: "text".into(), text: long_text, + attachments: vec![], }, command: None, request_id: Some("req_multi_fail".into()), @@ -1535,6 +1539,7 @@ mod tests { content: Content { content_type: "text".into(), text: "hello".into(), + attachments: vec![], }, command: None, request_id: Some("req_notoken".into()), @@ -1579,6 +1584,7 @@ mod tests { content: Content { content_type: "text".into(), text: "updated text".into(), + attachments: vec![], }, command: Some("edit_message".into()), request_id: None, @@ -1620,6 +1626,7 @@ mod tests { content: Content { content_type: "text".into(), text: long_text, + attachments: vec![], }, command: None, request_id: Some("req_multi".into()), @@ -1676,6 +1683,7 @@ mod tests { content: Content { content_type: "text".into(), text: long_text, + attachments: vec![], }, command: None, request_id: Some("req_partial".into()), From 7bf7f631254ba27b9401aaa1d0aefb54b8ea005f Mon Sep 17 00:00:00 2001 From: chaodufashi Date: Fri, 8 May 2026 19:14:18 -0400 Subject: [PATCH 011/100] docs: add steering design guide for AI agent hot/cold memory (#774) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * docs: add steering design guide for AI agent hot/cold memory * docs: incorporate Claude Code feedback — fix CC memory mapping - CLAUDE.md is hierarchical (global → project → subdir) - settings.json is config, not instructions - Add MEMORY.md index (200-line cap) as hot memory - Individual memory files are cold storage - Add CC auto-memory as real-world hot/cold example * docs: incorporate Codex + Gemini feedback — fix agent mappings - Codex: AGENTS.md hierarchical, 32KB cap, not .codex/instructions.md - Gemini: GEMINI.md hierarchical (global → project → subdir) + MEMORY.md - Update size guidance: caps vary by agent, attention dilution is real constraint - Add note about common hierarchical loading pattern across CC/Codex/Gemini * docs: incorporate Copilot feedback — fix mapping - Not single-file: repo-wide + path-specific + AGENTS.md (nearest-in-tree) - Layered precedence: Personal > Path-specific > Repo-wide > Agent > Org - No hard size cap for Chat/Agent; code review reads first 4K chars - Remove 'pending confirmation' note * docs: add Loading Model Comparison section Three distinct loading models across agents: - Always loaded (every session) - Directory-scoped (processing files in that tree) - File-scoped (Copilot applyTo glob match) Based on feedback from 口渡法師 (Copilot-backed agent). * docs: fix stale .codex/instructions.md references - Remove .codex/instructions.md from Terminology and Architecture sections - Codex uses AGENTS.md, not .codex/instructions.md - Fix duplicated lines in Architecture Pattern block * docs: address Copilot NITs — add support qualifier and 'documented' cap - AGENTS.md nearest-in-tree: add 'where supported: cloud agent / CLI' - 'No hard size cap' → 'No documented hard size cap' * docs: add anti-pattern — task-scoped rules in file-scoped locations Review SOP, response format, collaboration protocol should be in always-loaded layer, not path/directory-specific locations. Based on 口渡法師 feedback. * docs: fix broken code fence + add stale links anti-pattern + maintenance note - Remove duplicate MEMORY.md line that broke markdown rendering - Add anti-pattern: stale links in hot memory - Add maintenance rule: document loading model before adding new agent Based on 擺渡法師 feedback. * docs: add principles — structure over prose, WHAT/HOW only in hot - Structured constraints (lists, tables) > natural language paragraphs - Hot memory = WHAT + HOW. WHY goes in cold storage (ADRs). Based on 覺渡法師 feedback. * docs: evolve to three-tier memory architecture (hot/warm/cold) Based on unanimous feedback from all five monks: - Add Warm/Conditional layer between Hot and Cold - Warm = trigger metadata in hot, body loads on demand - Document three trigger mechanisms: rule-based, semantic, explicit - Update terminology, architecture diagram, decision flowchart - Add 'What Goes in Warm Context' section - Add anti-pattern: mandatory behavior hidden in cold without trigger - Key insight: 'put the table of contents in hot, put the chapters in warm' * docs: replace column layout with ASCII layered diagram Visual hierarchy makes the three-tier model immediately clear: Hot (top, small) → Warm (middle, triggered) → Cold (bottom, unlimited) * docs: fix inconsistencies flagged by 口渡 - Opening line updated to three-tier model - Terminology: CC/Gemini individual memory files are warm (body), not the index - Real-world example: memory files are warm, not cold - Consistent: index = hot trigger, body = warm, no-trigger docs = cold * docs: add Self-Reflection Prompt section Agents can use this prompt to audit their own memory allocation against the guide's principles. Turns the doc from passive reference into an active diagnostic tool. * docs: upgrade Self-Reflection Prompt to 6-step audit checklist Incorporates feedback from all four monks: - 擺渡: add warm layer, loading trigger, fresh-session test - 普渡: add trigger precision check - 口渡: add canonical source identification, layer/trigger classification - 覺渡: validated via live execution Now covers: inventory → classify → violations → trigger quality → optimization plan → verification * docs: update self-reflection prompt to reference specific doc path Points agents to docs/steering-design-guide.md in OpenAB repo so they can fetch and read the guide before auditing. * docs: add Problem section — why this guide exists Without deliberate organization: bloated instructions, duplicated rules, missing triggers, no shared standard across agents. * docs: add OpenAB agent-agnostic context to Problem section OpenAB supports multiple agents side by side; this guide provides a shared memory architecture standard for consistent behavior across all supported coding agents. --------- Co-authored-by: chaodu-agent --- docs/steering-design-guide.md | 251 ++++++++++++++++++++++++++++++++++ 1 file changed, 251 insertions(+) create mode 100644 docs/steering-design-guide.md diff --git a/docs/steering-design-guide.md b/docs/steering-design-guide.md new file mode 100644 index 000000000..2b63fdb35 --- /dev/null +++ b/docs/steering-design-guide.md @@ -0,0 +1,251 @@ +# Steering Design Guide + +## Problem + +AI coding agents load persistent instructions every session, but without deliberate organization: +- **Bloated instructions** dilute attention — critical rules get buried in noise +- **Duplicated rules** across files inevitably contradict each other when one is updated +- **Missing triggers** mean mandatory behaviors live in docs the agent never reads +- **No shared standard** across agents leads to each team reinventing the wheel + +This guide establishes a universal framework for organizing agent memory into layers, so rules are reliably followed, context budgets are respected, and teams can onboard new agents without starting from scratch. + +OpenAB is designed to be agent-agnostic — it supports Kiro, Claude Code, Codex, Gemini, Copilot, and OpenCode running side by side. This guide provides a shared memory architecture standard that allows all supported coding agents to maintain consistent behavior, collaborate effectively, and operate from a single source of truth regardless of their underlying platform differences. + +--- + +How to organize AI agent memory across three tiers: hot (always loaded), warm (triggered on demand), and cold (searched when needed). + +Applies to: Kiro, Claude Code, Codex, Gemini, Copilot, OpenCode — any agent that supports persistent instruction files. + +--- + +## Terminology + +| Term | Meaning | Examples | +|------|---------|---------| +| 🔥 **Hot memory** | Loaded every session, always in context | `AGENTS.md`, `.kiro/steering/`, `CLAUDE.md`, `GEMINI.md`, `.github/copilot-instructions.md` | +| ☕ **Warm context** | Not always loaded, but auto-triggered when conditions match | Codex Skills (body), Copilot path-specific instructions, CC/Gemini individual memory files (pointed to by hot index), subdir instruction files | +| ❄️ **Cold storage** | Searched or retrieved on demand, no automatic trigger | Knowledge bases, `docs/`, project wikis, ADRs, historical records | + +--- + +## What Goes in Hot Memory + +| Criteria | Example | +|----------|---------| +| Every interaction might trigger it | Output format spec, verdict logic | +| Identity & relationships | Agent name, team members, contact IDs | +| SOP trigger words | "review PRs" → auto-execute workflow | +| Hard rules that are easy to get wrong | "NITs are blocking", "never merge", "English only on GitHub" | +| Tool usage patterns | Login flows, API call patterns | +| Constraints that override defaults | "Don't ask for confirmation on X", "Always do Y before Z" | + +## What Stays in Cold Storage + +| Criteria | Example | +|----------|---------| +| Historical records / case studies | Past incident lessons, collaboration logs | +| One-time reference | Installation steps, migration guides | +| Large data | User profiles, conversation history | +| Design proposals / RFCs | Architecture decisions, feature specs | +| Lookup tables | Feature flags, config reference, changelogs | + +## What Goes in Warm Context + +| Criteria | Example | +|----------|---------| +| Too large for hot, but has a reliable trigger | Deployment SOP, release checklist | +| Only relevant for specific file types or paths | Gateway adapter checklist, Helm wiring rules | +| Domain-specific expert knowledge | Platform auth spec details, crypto implementation patterns | +| Complex workflows with steps and scripts | Incident triage playbook, skill bodies | + +**Rule of thumb:** If it has a clear trigger condition and is > 1KB, make it warm. Keep only the trigger (name + one-line description + path) in hot. + +--- + +## Design Principles + +1. **Small and precise** — Keep hot memory concise. Practical caps vary by agent (CC: ~200 lines for MEMORY.md, Codex: 32KB, Kiro: ~15KB recommended). Regardless of hard limits, attention dilution is the real constraint — less is more. +2. **Behavior-oriented** — Every line should change "what the agent does next." Remove anything that's just "nice to know." +3. **Single source of truth** — Define each rule in exactly one place. Duplication across files creates contradiction risk. +4. **Testable** — Each rule should be verifiable with a single prompt from a fresh session. +5. **One file per responsibility** — Separate concerns: identity, review process, workflow triggers. Avoid monolithic instruction files. +6. **Hot/cold separation** — If the agent can find it via search when needed, it doesn't need to be always-loaded. +7. **Structure over prose** — Use lists, tables, or key-value pairs in hot memory. LLMs follow structured constraints more reliably than natural language paragraphs. +8. **WHAT and HOW only** — Hot memory defines what to do and how. Put the WHY (historical context, incident backstory) in cold storage (ADRs, lessons learned). + +--- + +## Decision Flowchart + +``` +"If this rule is NOT loaded, will the next response be wrong?" +│ +├─ Yes → 🔥 Hot memory +│ +├─ Only when doing a specific task/touching specific files +│ → ☕ Warm context (put trigger in hot, body in warm) +│ +└─ No, it's reference → ❄️ Cold storage +``` + +--- + +## Architecture Pattern + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ 🔥 HOT — Always Loaded │ +│ │ +│ Identity, hard rules, collaboration protocol, trigger index │ +│ AGENTS.md / CLAUDE.md / GEMINI.md / .kiro/steering/* │ +│ .github/copilot-instructions.md / MEMORY.md index │ +│ < 15KB │ +├─────────────────────────────────────────────────────────────────┤ +│ ☕ WARM — Progressive Exposure │ +│ │ +│ Auto-loaded when trigger condition matches │ +│ Skills (SKILL.md body), path-specific instructions, │ +│ subdir instruction files, individual memory files, │ +│ domain SOPs, deployment playbooks │ +│ │ +│ Triggers: Rule-based (applyTo glob) │ +│ Semantic (agent reads index, decides to load) │ +│ Explicit (activate_skill / read_file) │ +├─────────────────────────────────────────────────────────────────┤ +│ ❄️ COLD — Search on Demand │ +│ │ +│ No automatic trigger; requires explicit search/retrieval │ +│ Knowledge bases, docs/*.md, wikis, ADRs, RFCs, │ +│ historical records, lessons learned, team trivia │ +│ Unlimited │ +└─────────────────────────────────────────────────────────────────┘ + + ▲ Smaller, precise, behavioral + │ + ▼ Larger, reference, historical +``` + +**Key insight:** The warm layer's *trigger metadata* lives in hot memory (skill names, index entries, applyTo globs). Only the *body* loads on demand. Pattern: **put the table of contents in hot, put the chapters in warm.** + +> **Trigger mechanisms vary by agent:** +> - **Rule-based:** Copilot `applyTo` glob match, Codex skill metadata match +> - **Semantic:** CC/Gemini memory index — agent reads description and decides to load +> - **Explicit:** Agent calls `activate_skill` or `read_file` when task matches + +> **Real-world example:** Claude Code's auto-memory system is a natural implementation of hot/warm separation — `MEMORY.md` index (hot, 200-line cap) points to individual `.md` memory files (warm, loaded when agent determines relevance from index description). + +> **Common pattern:** CC, Codex, and Gemini all use hierarchical loading (global → project → subdir). This naturally supports "one file per responsibility" by placing topic-specific rules in the relevant subdirectory's instruction file. + +--- + +## Agent-Specific File Mapping + +> **Note:** Most agents are hybrid — they combine multiple loading models. The table below shows the primary mechanisms. + +### Loading Models + +| Model | Trigger | Examples | +|-------|---------|---------| +| **Always loaded** | Every session/interaction in repo context | Kiro `.kiro/steering/*`, CC/Codex/Gemini root instruction file, Copilot `.github/copilot-instructions.md` | +| **Directory-scoped** | Processing files within that directory tree | CC/Codex/Gemini subdir instruction files, Copilot `AGENTS.md` (nearest-in-tree) | +| **File-scoped** | Matching an `applyTo` glob pattern | Copilot `.github/instructions/**/*.instructions.md` | + +**Implication for hot memory design:** +- "Always loaded" = put task-agnostic rules here (identity, verdict logic, workflow triggers) +- "Directory-scoped" = put domain-specific rules here (gateway checklist, docs standards) +- "File-scoped" = put file-type-specific review expectations here (only Copilot supports this natively) + +| Agent | Hot Memory Location | Notes | +|-------|-------------------|-------| +| Kiro | `AGENTS.md` + `.kiro/steering/*.md` | Multiple files, one per topic | +| Claude Code | `CLAUDE.md` (project) + `~/.claude/CLAUDE.md` (global) + `MEMORY.md` index | Hierarchical loading (global → project → subdir). Auto-memory index is hot (200-line cap); individual memory files are cold. `settings.json` is config, not instructions | +| Codex | `AGENTS.md` hierarchical (global → project root → subdir) | Each directory loads at most one file. 32KB cap (`project_doc_max_bytes`). Use nested `AGENTS.md` for per-directory responsibility split. No multi-file topic split within same dir | +| Gemini | `GEMINI.md` hierarchical (`~/.gemini/GEMINI.md` global → `./GEMINI.md` project → subdir) + `MEMORY.md` index | Same hierarchical pattern as CC/Codex. Private project memory index is hot; individual memory files are cold | +| Copilot | `.github/copilot-instructions.md` (repo-wide) + `.github/instructions/**/*.instructions.md` (path-specific) + `AGENTS.md` (nearest-in-tree, where supported: cloud agent / CLI) | Layered: Personal > Path-specific > Repo-wide > Agent > Organization. No documented hard size cap for Chat/Agent (code review reads first 4K chars only). Keep short (~2 pages recommended) | +| OpenCode | `AGENTS.md` or equivalent | Follows repo convention | + +--- + +## Validation Checklist + +After adding or changing hot memory: + +1. **Start a fresh session** (no prior context) +2. **Ask a question that triggers the rule** — e.g., "what format should a review comment use?" +3. **Verify the response follows the rule exactly** +4. **Test edge cases** — e.g., "what if there's only one 🟡 finding?" +5. **Check for contradictions** — does the new rule conflict with anything else in hot memory? + +If the agent doesn't follow the rule → it's either not loaded, too buried in other content, or ambiguously worded. + +--- + +## Anti-Patterns + +| Anti-Pattern | Why It's Bad | Fix | +|--------------|-------------|-----| +| Dumping everything into one file | Critical rules get lost in noise | Split by responsibility | +| Duplicating rules across files | Inevitable contradictions when one is updated | Single source + pointer | +| Putting case studies in hot memory | Wastes context budget on history | Move to docs, reference by lesson only | +| Vague rules ("be helpful") | Untestable, no behavioral change | Make specific and testable | +| Hot memory > 20KB | Diminishing returns, attention dilution | Audit and move cold items out | +| Task-scoped rules in file/directory-scoped locations | Review SOP, response format, collaboration protocol only load when certain files are touched — missing when needed most | Put task-agnostic workflow rules in always-loaded layer, not path-specific | +| Stale links in hot memory | Index points to missing files; fresh session gets dead references | Audit links quarterly; remove or create the target | +| Mandatory behavior hidden in cold without trigger | Agent must follow it but has no path to discover it | Add trigger metadata to hot, or promote to warm with clear activation condition | + +--- + +## Maintenance + +- **Quarterly audit**: Review hot memory files. Remove rules that are no longer relevant or have become default behavior. +- **After contradictions**: When agent behavior contradicts a rule, check if it's a loading issue or a conflict with another rule. +- **After new capabilities**: When adding new workflows, decide hot vs cold before writing the doc. +- **Adding a new agent**: Document its loading model and precedence before adding file mappings. Don't assume it works like existing agents. + +--- + +## Self-Reflection Prompt + +Use this prompt from a fresh session to audit memory allocation against this guide: + +``` +Per the steering design guide in docs/steering-design-guide.md from OpenAB GitHub repo, review your current memory allocation: + +1. INVENTORY — List all loaded/discoverable instruction sources: + - File path + - Layer (Hot / Warm / Cold) + - Trigger model (always / directory / file-glob / semantic / explicit) + - Approximate size (lines or KB) + +2. CLASSIFY — For each item, what type of content is it? + - WHAT/HOW (behavior rule) vs WHY (history/rationale) + - Identity / hard rule / workflow / reference / trivia + +3. VIOLATIONS — Identify items that break the guide's principles: + - Not behavior-oriented (nice-to-know in hot) + - Duplicated or conflicting across files + - Stale links pointing to missing files + - Too large for its layer + - WHY/history in hot instead of cold + - Mandatory behavior in cold with no trigger path + +4. TRIGGER QUALITY — Review warm layer triggers: + - Are index descriptions precise enough to fire correctly? + - Where is the canonical source for each rule? + - Will the agent reliably see it when needed? + +5. OPTIMIZATION PLAN — Propose concrete moves: + - Remove (stale, duplicate, irrelevant) + - Keep in hot (behavioral, high-frequency) + - Promote cold → warm (add trigger) + - Demote hot → warm or cold (too large, low-frequency) + - Split (one file doing too many jobs) + - Add missing trigger/index entry + +6. VERIFY — Name one fresh-session test prompt that would confirm + the highest-risk rule still loads correctly. +``` + +Expected output: a before/after table with file paths, layer assignments, sizes, and rationale for each move. From c06e0e62995e7fa95085e42b1ac20697d09efceb Mon Sep 17 00:00:00 2001 From: chaodufashi Date: Sat, 9 May 2026 10:23:28 -0400 Subject: [PATCH 012/100] feat: agent-controlled reply-to via [[reply_to:message_id]] directive (#777) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: agent-controlled reply-to via [[reply_to:message_id]] directive Problem: Agents currently cannot reply to a specific message in a thread. All output is sent as plain messages, losing conversational context in busy multi-bot threads. Solution: Two-layer change enabling agents to specify reply targets: 1. Input: SenderContext now includes message_id, so agents know the ID of each incoming message. 2. Output: Agents can prefix their response with directives: [[reply_to:1502606076451885136]] Actual reply content... OAB parses consecutive [[key:value]] lines as a header block, strips them from content, and uses them for platform-specific message delivery (Discord: message_reference). Design decisions evaluated: - Option A (chosen): Inline directives in output text. Minimal change, no ACP protocol modification, forward-compatible (unknown keys ignored). - Option B (deferred): ACP metadata field. Cleaner but requires protocol change across all backends. Appropriate when more directives are needed (ephemeral, components, attachments). Directive format: - Consecutive [[key:value]] lines at output start = header block - First non-[[...]] line = content begins - Unknown keys silently ignored (forward compatible) - reply_to value must be numeric snowflake (validated) - Extensible: future directives like [[ephemeral:true]] can be added Implementation: - adapter.rs: parse_output_directives() + OutputDirectives struct - adapter.rs: ChatAdapter::send_message_with_reply() (default: fallback) - discord.rs: CreateMessage::reference_message() for reply-to - discord.rs: build_sender_context() includes msg.id - slack.rs, cron.rs, gateway.rs: message_id field added to SenderContext * fix: address review findings on reply-to directive F1 (streaming path): reply_to only works in send-once mode. This is acceptable because streaming is disabled in multi-bot threads (where reply-to matters most). Added explanatory comment. F2 (CRLF offset): Fixed parse_output_directives to handle both \n and \r\n line endings correctly instead of assuming +1. F3 (API error fallback): send_message_with_reply now catches Discord API errors (unknown message, cross-channel reference) and falls back to plain send_message instead of propagating the error. * fix: reply_to directive works in streaming path too When reply_to directive is present and streaming mode created a placeholder, the placeholder is blanked (zero-width space) and the real content is sent as a new reply message. This ensures reply_to has consistent semantics regardless of streaming mode. Behavior: - Streaming + no reply_to: normal edit-in-place (unchanged) - Streaming + reply_to: blank placeholder, send as reply - Send-once + reply_to: send as reply (unchanged) * docs: add output directives documentation Covers: - Directive format spec ([[key:value]] header block) - reply_to directive usage and behavior - SenderContext.message_id for getting message IDs - Multi-agent use case example - Comparison with OpenClaw/Hermes Agent - Future directives roadmap * docs: add agent-controlled reply-to to README features * test: add unit tests for parse_output_directives 8 tests covering: - Normal reply_to parsing - No directives (plain content) - Multiple directives (unknown keys ignored) - Invalid reply_to (non-numeric) rejected - Empty reply_to rejected - CRLF line endings handled correctly - Directive-only output (no content) - Non-directive first line stops parsing * fix: simplify streaming + reply_to path (remove redundant edits) Per review: the three sequential edits were wasteful and caused brief content duplication. Simplified to: 1. Single edit to zero-width space (hide placeholder) 2. Send real content as reply No more flicker or ghost content. * fix: add fallback logging + 2 more edge case tests - Discord: tracing::warn on reply_to fallback (was silent) - Test: duplicate reply_to (last wins) - Test: CRLF with multiple directives Total directive tests: 10 * fix: relax message_id validation for cross-platform compatibility Was: numeric-only (Discord snowflake) Now: alphanumeric + dots + hyphens + underscores, max 64 chars This allows: - Discord snowflakes: 1502606076451885136 - Slack ts: 1234567890.123456 - UUID-style: 550e8400-e29b-41d4-a716-446655440000 Rejects: whitespace, control chars, empty, >64 chars Added test: parse_slack_ts_format_accepted Updated test: rejection now checks whitespace (not hyphens) * fix: guard against empty content after directive stripping If agent output is directive-only (e.g. just [[reply_to:123]] with no actual content), stripped_content would be empty. Discord rejects empty messages, causing silent failures. Fix: if content is empty/whitespace after stripping, fall back to '_(no response)_' — same behavior as when agent returns no text. * fix: delete placeholder instead of zero-width space on reply_to Adds delete_message to ChatAdapter trait (default no-op) and implements it for Discord. Streaming + reply_to path now deletes the placeholder entirely instead of editing to zero-width space. No more ghost empty bubbles in Discord threads. * fix: delete_message default falls back to edit zero-width space Per review: default no-op would leave placeholder visible if delete fails or adapter doesn't support it. Default now edits to zero-width space (existing behavior), Discord overrides with real delete. * fix: clippy errors (unnecessary_unwrap + too_many_arguments) - Replace is_some() + unwrap() with if let Some(ref reply_id) - Allow clippy::too_many_arguments on build_sender_context (8 params) * fix: log unknown directives at debug level Helps agent developers diagnose typos like [[reply-to:...]] vs [[reply_to:...]]. Forward compatible: unknown keys still ignored at runtime, just logged for debugging. * fix: remaining clippy unnecessary_unwrap in streaming path * docs: note Slack reply_to is parsed but not yet implemented * docs: remove Future Directives section (avoid premature commitment) * fix: send-before-delete order + parse directives before markdown F1: Send reply first, then delete placeholder. If send fails, placeholder remains visible (no message loss for user). F2: Parse directives before markdown::convert_tables. Directives are meta-layer and should be stripped before content transforms. * fix: parse directives from raw text_buf + check send before delete F1: Directives now parsed from raw text_buf BEFORE compose_display, ensuring tool call output cannot interfere with directive parsing. F3: Send result is checked — placeholder only deleted if first chunk sends successfully. On send failure, placeholder remains visible (no message loss). * fix: [[X]] without colon stops parsing (preserves agent content) B2: Lines like [[Note]], [[Summary]], [[Thought]] (no colon) are legitimate agent content, not directives. Parser now only advances content_start when split_once(':') succeeds. Without colon → break. Added test: parse_bracket_without_colon_preserved * docs: fix value spec accuracy + document duplicate-key behavior D1: Value spec now correctly describes cross-platform validation (non-empty, ≤64 chars, no whitespace) instead of 'numeric only'. D2: Added rule: 'If the same key appears multiple times, last wins.' Also: clarified [[X]] without colon stops parsing. * fix: add warn logging on send/delete failure + align docs with parser - tracing::warn on reply send failure (ops can diagnose permission issues) - tracing::warn on placeholder delete failure - Docs: 'no whitespace' → 'ASCII alphanumeric plus ., -, _' (matches code) --------- Co-authored-by: chaodu-agent --- README.md | 1 + docs/output-directives.md | 76 ++++++++++++ src/adapter.rs | 246 ++++++++++++++++++++++++++++++++++++-- src/cron.rs | 1 + src/discord.rs | 42 +++++++ src/gateway.rs | 1 + src/slack.rs | 1 + 7 files changed, 361 insertions(+), 7 deletions(-) create mode 100644 docs/output-directives.md diff --git a/README.md b/README.md index 4dd3d4f21..58f59e61a 100644 --- a/README.md +++ b/README.md @@ -42,6 +42,7 @@ A lightweight, secure, cloud-native ACP harness that bridges **Discord, Slack**, - **@mention trigger** — mention the bot in an allowed channel to start a conversation - **Thread-based multi-turn** — auto-creates threads; no @mention needed for follow-ups - **Multi-agent collaboration** — bot-to-bot messaging for coordinated workflows ([docs/multi-agent.md](docs/multi-agent.md)) +- **Agent-controlled reply-to** — agents choose which message to reply to via `[[reply_to:id]]` directive, enabling clear conversation threads in multi-bot channels ([docs/output-directives.md](docs/output-directives.md)) - **Edit-streaming** — live-updates the Discord message every 1.5s as tokens arrive - **Emoji status reactions** — 👀→🤔→🔥/👨‍💻/⚡→👍+random mood face - **Image & file support** — send images and files through chat ([docs/sendimages.md](docs/sendimages.md), [docs/sendfiles.md](docs/sendfiles.md)) diff --git a/docs/output-directives.md b/docs/output-directives.md new file mode 100644 index 000000000..797116587 --- /dev/null +++ b/docs/output-directives.md @@ -0,0 +1,76 @@ +# Output Directives + +## Overview + +Agents can control platform-specific message delivery by prefixing their output with `[[key:value]]` directives. OAB parses and strips these before sending to the platform. + +## Format + +``` +[[reply_to:1502606076451885136]] +[[ephemeral:true]] ← future +Actual message content starts here... +``` + +Rules: +- Consecutive `[[key:value]]` lines at the start of output = directive header block +- First line that doesn't match `[[key:value]]` (with colon) = content begins +- `[[X]]` without colon is NOT a directive — stops parsing, preserved as content +- Directives are stripped from the final message (never visible to users) +- Unknown keys are silently ignored (forward compatible, logged at debug level) +- If the same key appears multiple times, the last value wins + +## Available Directives + +### `reply_to` + +Reply to a specific message by ID (Discord: `message_reference`). + +``` +[[reply_to:1502606076451885136]] +Here is my reply to that specific message. +``` + +**Value**: Platform message ID. Format depends on the target adapter — Discord requires a numeric snowflake; Slack accepts `ts` (e.g. `1234567890.123456`). The directive parser validates that the value is non-empty, ≤64 chars, and contains only ASCII alphanumeric characters plus `.`, `-`, `_`; per-platform format validation happens in each adapter. + +**Behavior**: +- Discord: sends with `message_reference`, showing the native "replying to..." UI +- Invalid/non-existent message ID: silently falls back to plain send +- Works in both streaming and send-once modes + +**How agents get message IDs**: Every incoming message includes `message_id` in `SenderContext`: + +```json +{ + "schema": "openab.sender.v1", + "sender_id": "845835116920307722", + "sender_name": "pahud.hsieh", + "message_id": "1502606076451885136", + "channel": "discord", + ... +} +``` + +## Multi-Agent Use Case + +In a thread with multiple bots, agents can reply to each other's messages: + +``` +Human: "Review this PR" (message_id: 100) +Bot A: "Found 3 issues" (message_id: 101) +Bot B output: + [[reply_to:101]] + I agree with Bot A on F1, but F2 is actually fine because... +``` + +This creates clear visual conversation threads within a Discord thread — essential for multi-agent collaboration. + +## Comparison with Other Platforms + +| Platform | Reply Mechanism | Agent Control | +|----------|----------------|---------------| +| OpenClaw | `replyToMode` config (`off`/`first`/`all`) | ❌ Platform decides, always to trigger msg | +| Hermes Agent | `DISCORD_REPLY_TO_MODE` env var | ❌ Platform decides, always to trigger msg | +| **OAB** | `[[reply_to:message_id]]` directive | ✅ Agent chooses any message | + +> **Note:** `reply_to` is currently implemented for Discord only. Slack message IDs (ts format like `1234567890.123456`) are accepted by the parser but the Slack adapter does not yet send threaded replies via this directive — it falls back to plain send. Slack support can be added in a future PR. diff --git a/src/adapter.rs b/src/adapter.rs index 5a80f29e7..5ecc8e96f 100644 --- a/src/adapter.rs +++ b/src/adapter.rs @@ -11,6 +11,63 @@ use crate::format; use crate::markdown::{self, TableMode}; use crate::reactions::StatusReactionController; +// --- Output directive parsing --- + +/// Parsed directives from agent output header block. +/// Consecutive `[[key:value]]` lines at the start of output are directives. +#[derive(Default, Debug)] +pub struct OutputDirectives { + /// Message ID to reply to (Discord: message_reference) + pub reply_to: Option, +} + +/// Parse `[[key:value]]` directives from the beginning of agent output. +/// Returns parsed directives and the remaining content (directives stripped). +pub fn parse_output_directives(content: &str) -> (OutputDirectives, String) { + let mut directives = OutputDirectives::default(); + let mut content_start = 0; + + for line in content.lines() { + let trimmed = line.trim(); + if let Some(inner) = trimmed.strip_prefix("[[").and_then(|s| s.strip_suffix("]]")) { + if let Some((key, value)) = inner.split_once(':') { + match key.trim() { + "reply_to" => { + let v = value.trim(); + // Validate: non-empty, reasonable length, no whitespace/control chars + if !v.is_empty() && v.len() <= 64 && v.chars().all(|c| c.is_ascii_alphanumeric() || c == '.' || c == '-' || c == '_') { + directives.reply_to = Some(v.to_string()); + } + } + _ => { + tracing::debug!(key = key.trim(), "unknown output directive ignored"); + } + } + // Advance past this line + its line ending (handles both \n and \r\n) + content_start += line.len(); + if content.as_bytes().get(content_start) == Some(&b'\r') { + content_start += 1; + } + if content.as_bytes().get(content_start) == Some(&b'\n') { + content_start += 1; + } + } else { + // [[X]] without colon — not a directive, stop parsing + break; + } + } else { + break; + } + } + + let remaining = if content_start < content.len() { + &content[content_start..] + } else { + "" + }; + (directives, remaining.to_string()) +} + // --- Platform-agnostic types --- /// Identifies a channel or thread across platforms. @@ -106,6 +163,10 @@ pub struct SenderContext { /// breakage). If future additions require breaking changes, bump to v1.1+. #[serde(skip_serializing_if = "Option::is_none")] pub timestamp: Option, + /// Platform message ID. Agents can use this to reply to a specific message + /// via the `[[reply_to:]]` output directive. + #[serde(skip_serializing_if = "Option::is_none")] + pub message_id: Option, } // --- ChatAdapter trait --- @@ -141,6 +202,24 @@ pub trait ChatAdapter: Send + Sync + 'static { Err(anyhow::anyhow!("edit_message not supported")) } + /// Send a message as a reply to a specific message (Discord: message_reference). + /// Default: falls back to plain send_message (ignores reply_to). + async fn send_message_with_reply( + &self, + channel: &ChannelRef, + content: &str, + reply_to_message_id: &str, + ) -> Result { + let _ = reply_to_message_id; // unused in default impl + self.send_message(channel, content).await + } + + /// Delete a message. Used to remove streaming placeholders when reply_to is set. + /// Default: edits to zero-width space (fallback for platforms without delete support). + async fn delete_message(&self, msg: &MessageRef) -> Result<()> { + self.edit_message(msg, "\u{200b}").await + } + /// Whether this adapter should use streaming edit (true) or send-once (false). /// `other_bot_present` indicates if another bot has posted in the current thread. /// Streaming should be disabled in multi-bot threads to avoid edit interference. @@ -536,6 +615,12 @@ impl AdapterRouter { // Stop the edit loop drop(buf_tx); + // Parse output directives from raw text_buf BEFORE compose_display. + // Directives are agent meta-layer, not content — must be stripped + // before tool lines are composed into the display output. + let (directives, stripped_text) = parse_output_directives(&text_buf); + let text_buf = stripped_text; + // Build final content let final_content = compose_display(&tool_lines, &text_buf, false, tool_display); @@ -554,17 +639,61 @@ impl AdapterRouter { let final_content = markdown::convert_tables(&final_content, table_mode); let chunks = format::split_message(&final_content, message_limit); if let Some(msg) = placeholder_msg { - // Streaming: edit first chunk into placeholder, send rest as new messages - if let Some(first) = chunks.first() { - let _ = adapter.edit_message(&msg, first).await; - } - for chunk in chunks.iter().skip(1) { - let _ = adapter.send_message(&thread_channel, chunk).await; + if let Some(ref reply_id) = directives.reply_to { + // reply_to directive: send reply first, then delete placeholder. + // Only delete if send succeeds — preserves placeholder on failure. + let mut send_ok = false; + let mut first = true; + for chunk in &chunks { + if first { + match adapter.send_message_with_reply( + &thread_channel, + chunk, + reply_id, + ).await { + Ok(_) => { send_ok = true; } + Err(e) => { + tracing::warn!(error = ?e, "reply_to send failed; preserving placeholder"); + } + } + } else { + let _ = adapter.send_message(&thread_channel, chunk).await; + } + first = false; + } + if send_ok { + if let Err(e) = adapter.delete_message(&msg).await { + tracing::warn!(error = ?e, "delete placeholder failed; placeholder will remain visible"); + } + } + } else { + // Normal streaming: edit first chunk into placeholder, send rest + if let Some(first) = chunks.first() { + let _ = adapter.edit_message(&msg, first).await; + } + for chunk in chunks.iter().skip(1) { + let _ = adapter.send_message(&thread_channel, chunk).await; + } } } else { // Send-once: all chunks as new messages + // First chunk uses reply_to directive if present + let mut first = true; for chunk in &chunks { - let _ = adapter.send_message(&thread_channel, chunk).await; + if first { + if let Some(ref reply_id) = directives.reply_to { + let _ = adapter.send_message_with_reply( + &thread_channel, + chunk, + reply_id, + ).await; + } else { + let _ = adapter.send_message(&thread_channel, chunk).await; + } + } else { + let _ = adapter.send_message(&thread_channel, chunk).await; + } + first = false; } } @@ -879,3 +1008,106 @@ mod tests { assert_eq!(out, "response text"); } } + +#[cfg(test)] +mod directive_tests { + use super::parse_output_directives; + + #[test] + fn parse_reply_to_directive() { + let input = "[[reply_to:1502606076451885136]]\nHello world"; + let (directives, content) = parse_output_directives(input); + assert_eq!(directives.reply_to, Some("1502606076451885136".to_string())); + assert_eq!(content, "Hello world"); + } + + #[test] + fn parse_no_directives() { + let input = "Just plain content\nwith multiple lines"; + let (directives, content) = parse_output_directives(input); + assert_eq!(directives.reply_to, None); + assert_eq!(content, input); + } + + #[test] + fn parse_multiple_directives() { + let input = "[[reply_to:123456]]\n[[unknown_key:value]]\nContent here"; + let (directives, content) = parse_output_directives(input); + assert_eq!(directives.reply_to, Some("123456".to_string())); + assert_eq!(content, "Content here"); + } + + #[test] + fn parse_invalid_reply_to_rejects_whitespace() { + let input = "[[reply_to:has spaces]]\nContent"; + let (directives, content) = parse_output_directives(input); + assert_eq!(directives.reply_to, None); + assert_eq!(content, "Content"); + } + + #[test] + fn parse_slack_ts_format_accepted() { + let input = "[[reply_to:1234567890.123456]]\nContent"; + let (directives, content) = parse_output_directives(input); + assert_eq!(directives.reply_to, Some("1234567890.123456".to_string())); + assert_eq!(content, "Content"); + } + + #[test] + fn parse_empty_reply_to() { + let input = "[[reply_to:]]\nContent"; + let (directives, content) = parse_output_directives(input); + assert_eq!(directives.reply_to, None); + assert_eq!(content, "Content"); + } + + #[test] + fn parse_crlf_line_endings() { + let input = "[[reply_to:999]]\r\nContent with CRLF"; + let (directives, content) = parse_output_directives(input); + assert_eq!(directives.reply_to, Some("999".to_string())); + assert_eq!(content, "Content with CRLF"); + } + + #[test] + fn parse_directive_only_no_content() { + let input = "[[reply_to:123]]"; + let (directives, content) = parse_output_directives(input); + assert_eq!(directives.reply_to, Some("123".to_string())); + assert_eq!(content, ""); + } + + #[test] + fn parse_non_directive_line_stops_parsing() { + let input = "Normal first line\n[[reply_to:123]]\nMore content"; + let (directives, content) = parse_output_directives(input); + assert_eq!(directives.reply_to, None); + assert_eq!(content, input); + } + + #[test] + fn parse_duplicate_reply_to_last_wins() { + let input = "[[reply_to:111]]\n[[reply_to:222]]\nContent"; + let (directives, content) = parse_output_directives(input); + // Last value wins + assert_eq!(directives.reply_to, Some("222".to_string())); + assert_eq!(content, "Content"); + } + + #[test] + fn parse_crlf_multiple_directives() { + let input = "[[reply_to:456]]\r\n[[unknown:x]]\r\nContent after CRLF"; + let (directives, content) = parse_output_directives(input); + assert_eq!(directives.reply_to, Some("456".to_string())); + assert_eq!(content, "Content after CRLF"); + } + + #[test] + fn parse_bracket_without_colon_preserved() { + // [[Note]] has no colon — not a directive, preserved as content + let input = "[[Summary]]\nThis is body text"; + let (directives, content) = parse_output_directives(input); + assert_eq!(directives.reply_to, None); + assert_eq!(content, input); + } +} diff --git a/src/cron.rs b/src/cron.rs index 9f39f9b57..ca811d9ef 100644 --- a/src/cron.rs +++ b/src/cron.rs @@ -399,6 +399,7 @@ async fn fire_cronjob( .or(Some(reply_channel.channel_id.clone())), is_bot: true, timestamp: Some(Utc::now().to_rfc3339()), + message_id: None, // cron jobs don't originate from a message }; let sender_json = match serde_json::to_string(&sender) { Ok(j) => j, diff --git a/src/discord.rs b/src/discord.rs index 5065aa607..1370e0719 100644 --- a/src/discord.rs +++ b/src/discord.rs @@ -74,6 +74,41 @@ impl ChatAdapter for DiscordAdapter { }) } + async fn send_message_with_reply( + &self, + channel: &ChannelRef, + content: &str, + reply_to_message_id: &str, + ) -> anyhow::Result { + let ch_id: u64 = Self::resolve_channel(channel).parse()?; + let msg_id: u64 = reply_to_message_id.parse().unwrap_or(0); + if msg_id == 0 { + // Invalid message ID, fall back to plain send + return self.send_message(channel, content).await; + } + let builder = serenity::builder::CreateMessage::new() + .content(content) + .reference_message((ChannelId::new(ch_id), MessageId::new(msg_id))); + match ChannelId::new(ch_id).send_message(&self.http, builder).await { + Ok(msg) => Ok(MessageRef { + channel: channel.clone(), + message_id: msg.id.to_string(), + }), + Err(e) => { + // Fallback to plain send if reply fails (e.g. unknown message, cross-channel) + tracing::warn!(error = ?e, reply_to = reply_to_message_id, "reply_to failed, falling back to plain send"); + self.send_message(channel, content).await + } + } + } + + async fn delete_message(&self, msg: &MessageRef) -> anyhow::Result<()> { + let ch_id: u64 = Self::resolve_channel(&msg.channel).parse()?; + let msg_id: u64 = msg.message_id.parse()?; + self.http.delete_message(ChannelId::new(ch_id), MessageId::new(msg_id), None).await?; + Ok(()) + } + async fn edit_message(&self, msg: &MessageRef, content: &str) -> anyhow::Result<()> { let ch_id: u64 = Self::resolve_channel(&msg.channel).parse()?; let msg_id: u64 = msg.message_id.parse()?; @@ -594,6 +629,7 @@ impl EventHandler for Handler { thread_parent_id.as_deref(), msg.author.bot, &msg.timestamp.to_rfc3339().unwrap_or_default(), + &msg.id.to_string(), ); // Build extra content blocks from attachments (audio -> STT, text -> inline, @@ -1324,6 +1360,7 @@ fn video_attachment_block( /// Note: `ChannelRef.channel_id` uses the *opposite* convention — it holds /// the thread's channel ID for routing (Discord API sends to thread by its /// channel ID). See `ChannelRef` doc comments for details. +#[allow(clippy::too_many_arguments)] fn build_sender_context( sender_id: &str, sender_name: &str, @@ -1332,6 +1369,7 @@ fn build_sender_context( thread_parent_id: Option<&str>, is_bot: bool, timestamp: &str, + message_id: &str, ) -> SenderContext { SenderContext { schema: "openab.sender.v1".into(), @@ -1343,6 +1381,7 @@ fn build_sender_context( thread_id: thread_parent_id.map(|_| msg_channel_id.to_string()), is_bot, timestamp: Some(timestamp.to_string()), + message_id: Some(message_id.to_string()), } } @@ -1718,6 +1757,7 @@ mod tests { Some("parent_ch"), false, "2026-05-01T00:00:00Z", + "msg123", ); assert_eq!(ctx.channel_id, "parent_ch"); assert_eq!(ctx.thread_id, Some("thread_ch".to_string())); @@ -1737,6 +1777,7 @@ mod tests { None, false, "2026-05-01T00:00:00Z", + "msg456", ); assert_eq!(ctx.channel_id, "main_ch"); assert_eq!(ctx.thread_id, None); @@ -1753,6 +1794,7 @@ mod tests { Some("parent"), true, "2026-05-01T00:00:00Z", + "msg789", ); assert!(ctx.is_bot); assert_eq!(ctx.channel_id, "parent"); diff --git a/src/gateway.rs b/src/gateway.rs index d8fa967cf..c0f3077cb 100644 --- a/src/gateway.rs +++ b/src/gateway.rs @@ -642,6 +642,7 @@ pub async fn run_gateway_adapter( } else { event.timestamp.clone() }), + message_id: if event.message_id.is_empty() { None } else { Some(event.message_id.clone()) }, }; let sender_json = serde_json::to_string(&sender_ctx) .unwrap_or_default(); diff --git a/src/slack.rs b/src/slack.rs index 74d460625..1a38c22fe 100644 --- a/src/slack.rs +++ b/src/slack.rs @@ -1063,6 +1063,7 @@ async fn handle_message( thread_id: thread_ts.clone(), is_bot: is_bot_msg, timestamp: Some(crate::timestamp::slack_ts_to_iso8601(&ts)), + message_id: Some(ts.clone()), }; let trigger_msg = MessageRef { From 59c5e3b2b2fe0db54f5d2a65c81dd07c32ba26d1 Mon Sep 17 00:00:00 2001 From: "openab-app[bot]" <274185012+openab-app[bot]@users.noreply.github.com> Date: Sat, 9 May 2026 22:30:27 +0800 Subject: [PATCH 013/100] release: v0.8.3-beta.5 (#778) Co-authored-by: openab-app[bot] <274185012+openab-app[bot]@users.noreply.github.com> --- charts/openab/Chart.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/charts/openab/Chart.yaml b/charts/openab/Chart.yaml index 426a63184..025e5911b 100644 --- a/charts/openab/Chart.yaml +++ b/charts/openab/Chart.yaml @@ -2,5 +2,5 @@ apiVersion: v2 name: openab description: A lightweight, secure, cloud-native ACP harness that bridges Discord and any ACP-compatible coding CLI. type: application -version: 0.8.3-beta.4 -appVersion: "0.8.3-beta.4" +version: 0.8.3-beta.5 +appVersion: "0.8.3-beta.5" From 80d0dbbde612a05cb6c3d1988c028b5abb7afe09 Mon Sep 17 00:00:00 2001 From: chaodufashi Date: Sat, 9 May 2026 14:11:55 -0400 Subject: [PATCH 014/100] fix: lenient output directive parser for inline content (#780) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: make output directive parser lenient for inline content The parser previously required [[key:value]] to be the entire line (using strip_suffix). If an agent put trailing content on the same line (e.g. '[[reply_to:123]] Hello world'), the directive was not recognized and rendered as plain text. Now the parser finds the first ']]' after '[[' and extracts the directive, treating any remaining text on that line as the start of content. This is backward compatible — strict format still works. Adds tests for inline content scenarios. Closes #779 * test: add more edge case tests for lenient directive parser - No space between ]] and content (Chinese text) - Inline Discord mention after directive - Trailing spaces only (empty content) - Brackets in content after directive * test: rename @超渡法師 to @BOT in test data --------- Co-authored-by: chaodu-agent --- src/adapter.rs | 127 ++++++++++++++++++++++++++++++++++++++++--------- 1 file changed, 104 insertions(+), 23 deletions(-) diff --git a/src/adapter.rs b/src/adapter.rs index 5ecc8e96f..2820c9aec 100644 --- a/src/adapter.rs +++ b/src/adapter.rs @@ -26,33 +26,55 @@ pub struct OutputDirectives { pub fn parse_output_directives(content: &str) -> (OutputDirectives, String) { let mut directives = OutputDirectives::default(); let mut content_start = 0; + let mut trailing_content: Option<&str> = None; for line in content.lines() { let trimmed = line.trim(); - if let Some(inner) = trimmed.strip_prefix("[[").and_then(|s| s.strip_suffix("]]")) { - if let Some((key, value)) = inner.split_once(':') { - match key.trim() { - "reply_to" => { - let v = value.trim(); - // Validate: non-empty, reasonable length, no whitespace/control chars - if !v.is_empty() && v.len() <= 64 && v.chars().all(|c| c.is_ascii_alphanumeric() || c == '.' || c == '-' || c == '_') { - directives.reply_to = Some(v.to_string()); + // Try to match [[key:value]] at the start of the line (lenient: allows trailing content) + if let Some(after_open) = trimmed.strip_prefix("[[") { + if let Some(close_pos) = after_open.find("]]") { + let inner = &after_open[..close_pos]; + if let Some((key, value)) = inner.split_once(':') { + match key.trim() { + "reply_to" => { + let v = value.trim(); + // Validate: non-empty, reasonable length, no whitespace/control chars + if !v.is_empty() && v.len() <= 64 && v.chars().all(|c| c.is_ascii_alphanumeric() || c == '.' || c == '-' || c == '_') { + directives.reply_to = Some(v.to_string()); + } + } + _ => { + tracing::debug!(key = key.trim(), "unknown output directive ignored"); } } - _ => { - tracing::debug!(key = key.trim(), "unknown output directive ignored"); + // Check for trailing content after ]] + let remainder = after_open[close_pos + 2..].trim(); + if !remainder.is_empty() { + trailing_content = Some(remainder); + // Advance past this line + content_start += line.len(); + if content.as_bytes().get(content_start) == Some(&b'\r') { + content_start += 1; + } + if content.as_bytes().get(content_start) == Some(&b'\n') { + content_start += 1; + } + break; // Trailing content ends directive header } - } - // Advance past this line + its line ending (handles both \n and \r\n) - content_start += line.len(); - if content.as_bytes().get(content_start) == Some(&b'\r') { - content_start += 1; - } - if content.as_bytes().get(content_start) == Some(&b'\n') { - content_start += 1; + // Advance past this line + its line ending (handles both \n and \r\n) + content_start += line.len(); + if content.as_bytes().get(content_start) == Some(&b'\r') { + content_start += 1; + } + if content.as_bytes().get(content_start) == Some(&b'\n') { + content_start += 1; + } + } else { + // [[X]] without colon — not a directive, stop parsing + break; } } else { - // [[X]] without colon — not a directive, stop parsing + // No closing ]] found — not a directive, stop parsing break; } } else { @@ -60,12 +82,18 @@ pub fn parse_output_directives(content: &str) -> (OutputDirectives, String) { } } - let remaining = if content_start < content.len() { - &content[content_start..] + let remaining = if let Some(trailing) = trailing_content { + if content_start < content.len() { + format!("{}\n{}", trailing, &content[content_start..]) + } else { + trailing.to_string() + } + } else if content_start < content.len() { + content[content_start..].to_string() } else { - "" + String::new() }; - (directives, remaining.to_string()) + (directives, remaining) } // --- Platform-agnostic types --- @@ -1110,4 +1138,57 @@ mod directive_tests { assert_eq!(directives.reply_to, None); assert_eq!(content, input); } + + #[test] + fn parse_reply_to_with_inline_content() { + // Agent puts content on same line as directive — should still parse + let input = "[[reply_to:1502724086474870926]] @BOT I'm on standby"; + let (directives, content) = parse_output_directives(input); + assert_eq!(directives.reply_to, Some("1502724086474870926".to_string())); + assert_eq!(content, "@BOT I'm on standby"); + } + + #[test] + fn parse_reply_to_inline_with_more_lines() { + let input = "[[reply_to:123]] First line\nSecond line\nThird line"; + let (directives, content) = parse_output_directives(input); + assert_eq!(directives.reply_to, Some("123".to_string())); + assert_eq!(content, "First line\nSecond line\nThird line"); + } + + #[test] + fn parse_reply_to_no_space_before_content() { + // No space between ]] and content + let input = "[[reply_to:1502724086474870926]]收到"; + let (directives, content) = parse_output_directives(input); + assert_eq!(directives.reply_to, Some("1502724086474870926".to_string())); + assert_eq!(content, "收到"); + } + + #[test] + fn parse_reply_to_inline_with_mention() { + // Real-world case: directive followed by Discord mention + let input = "[[reply_to:1502724086474870926]] <@1490365068863606784> 我 standby"; + let (directives, content) = parse_output_directives(input); + assert_eq!(directives.reply_to, Some("1502724086474870926".to_string())); + assert_eq!(content, "<@1490365068863606784> 我 standby"); + } + + #[test] + fn parse_reply_to_inline_only_spaces() { + // Trailing spaces only — no real content, should be empty + let input = "[[reply_to:123]] "; + let (directives, content) = parse_output_directives(input); + assert_eq!(directives.reply_to, Some("123".to_string())); + assert_eq!(content, ""); + } + + #[test] + fn parse_reply_to_with_brackets_in_content() { + // Content after ]] contains brackets — should not confuse parser + let input = "[[reply_to:456]] 看看 [[這個]] 怎麼樣"; + let (directives, content) = parse_output_directives(input); + assert_eq!(directives.reply_to, Some("456".to_string())); + assert_eq!(content, "看看 [[這個]] 怎麼樣"); + } } From d6f9c340b22ea3eae565d164196ee86b180440fa Mon Sep 17 00:00:00 2001 From: "openab-app[bot]" <274185012+openab-app[bot]@users.noreply.github.com> Date: Sun, 10 May 2026 02:20:17 +0800 Subject: [PATCH 015/100] release: v0.8.3-beta.6 (#781) Co-authored-by: openab-app[bot] <274185012+openab-app[bot]@users.noreply.github.com> --- charts/openab/Chart.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/charts/openab/Chart.yaml b/charts/openab/Chart.yaml index 025e5911b..982456bf1 100644 --- a/charts/openab/Chart.yaml +++ b/charts/openab/Chart.yaml @@ -2,5 +2,5 @@ apiVersion: v2 name: openab description: A lightweight, secure, cloud-native ACP harness that bridges Discord and any ACP-compatible coding CLI. type: application -version: 0.8.3-beta.5 -appVersion: "0.8.3-beta.5" +version: 0.8.3-beta.6 +appVersion: "0.8.3-beta.6" From ac5fa57a01acea9a5f3904473509db7e60682d53 Mon Sep 17 00:00:00 2001 From: Nick Date: Sun, 10 May 2026 10:23:44 +0800 Subject: [PATCH 016/100] fix(docker): pre-create agent hidden directories with correct ownership (#773) --- Dockerfile.claude | 2 ++ Dockerfile.codex | 2 ++ Dockerfile.copilot | 2 ++ Dockerfile.cursor | 2 ++ Dockerfile.opencode | 2 ++ 5 files changed, 10 insertions(+) diff --git a/Dockerfile.claude b/Dockerfile.claude index e98faf577..e1a1de6a4 100644 --- a/Dockerfile.claude +++ b/Dockerfile.claude @@ -31,6 +31,8 @@ WORKDIR /home/node COPY --from=builder --chown=node:node /build/target/release/openab /usr/local/bin/openab +RUN mkdir -p /home/node/.claude && chown -R node:node /home/node/.claude + USER node HEALTHCHECK --interval=30s --timeout=5s --retries=3 \ CMD pgrep -x openab || exit 1 diff --git a/Dockerfile.codex b/Dockerfile.codex index bdc984f04..6f1ad7dbc 100644 --- a/Dockerfile.codex +++ b/Dockerfile.codex @@ -28,6 +28,8 @@ WORKDIR /home/node COPY --from=builder --chown=node:node /build/target/release/openab /usr/local/bin/openab +RUN mkdir -p /home/node/.codex && chown -R node:node /home/node/.codex + USER node HEALTHCHECK --interval=30s --timeout=5s --retries=3 \ CMD pgrep -x openab || exit 1 diff --git a/Dockerfile.copilot b/Dockerfile.copilot index 2391f3d9f..c2c75f395 100644 --- a/Dockerfile.copilot +++ b/Dockerfile.copilot @@ -27,6 +27,8 @@ WORKDIR /home/node COPY --from=builder --chown=node:node /build/target/release/openab /usr/local/bin/openab +RUN mkdir -p /home/node/.copilot && chown -R node:node /home/node/.copilot + USER node HEALTHCHECK --interval=30s --timeout=5s --retries=3 \ CMD pgrep -x openab || exit 1 diff --git a/Dockerfile.cursor b/Dockerfile.cursor index d703da7be..6bb7035c8 100644 --- a/Dockerfile.cursor +++ b/Dockerfile.cursor @@ -39,6 +39,8 @@ WORKDIR /home/agent COPY --from=builder --chown=agent:agent /build/target/release/openab /usr/local/bin/openab +RUN mkdir -p /home/agent/.cursor && chown -R agent:agent /home/agent/.cursor + USER agent HEALTHCHECK --interval=30s --timeout=5s --retries=3 \ CMD pgrep -x openab || exit 1 diff --git a/Dockerfile.opencode b/Dockerfile.opencode index efbd777f8..7845a72c3 100644 --- a/Dockerfile.opencode +++ b/Dockerfile.opencode @@ -42,6 +42,8 @@ WORKDIR /home/node COPY --from=builder --chown=node:node /build/target/release/openab /usr/local/bin/openab +RUN mkdir -p /home/node/.opencode && chown -R node:node /home/node/.opencode + USER node HEALTHCHECK --interval=30s --timeout=5s --retries=3 \ CMD pgrep -x openab || exit 1 From e74b17116203808e5e6b7c6023df145bbd961629 Mon Sep 17 00:00:00 2001 From: charlie0228 Date: Mon, 11 May 2026 00:43:07 +0800 Subject: [PATCH 017/100] fix(cron): translate POSIX day-of-week to match documented semantics (#785) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(cron): translate POSIX day-of-week to match documented semantics The underlying `cron` crate (0.16.0) uses a non-POSIX day-of-week numbering (Sun=1, Mon=2, ..., Sat=7). openab documents POSIX semantics (Sun=0/7, Mon=1, ..., Sat=6), so numeric day-of-week values were effectively shifted by one day — e.g. `0 7 * * 1-5` (intended Mon-Fri) fired Sun-Thu. Add a small translator that expands each POSIX day-of-week field to a set of days and re-serializes it in the cron crate form (+1 shift, normalizing 7 to 0). Name-based tokens (Mon, Sun, Mon-Fri, ...) pass through unchanged because the cron crate's name-to-ordinal map is internally consistent. Includes regression tests for the observed 2026-05-10 (Sunday) firing of `0 7 * * 1-5` in Asia/Taipei, plus coverage of `0`, `7`, `6`, ranges, lists, steps, and invalid inputs. Closes #784 * fix(cron): expand singleton+step DOW and reject mixed notation - n/step (e.g. 1/2) now expands to (n..=6) before applying step filter, matching POSIX crontab(5) semantics where 1/2 means Mon,Wed,Fri - Mixed numeric+name notation (e.g. 1,Mon) now returns Err instead of silently passing through with incorrect numeric translation Addresses review findings from 法師團隊. * fix(cron): handle 7/step edge case and update stale doc comment - Normalize 7 (Sunday alias) to 0 before singleton+step expansion, preventing 7..=6 empty range error - Update doc comment to reflect that mixed notation now returns Err - Add test for 7/2 edge case * docs(cron): add Known Limitations section for DOW notation Document that mixed numeric/name notation and wrap-around ranges are not supported, with workarounds. * fix(cron): satisfy clippy manual_is_multiple_of lint --------- Co-authored-by: chaodu-agent --- docs/cronjob.md | 9 + src/cron.rs | 460 +++++++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 466 insertions(+), 3 deletions(-) diff --git a/docs/cronjob.md b/docs/cronjob.md index 61e7dc1be..6c73ffbad 100644 --- a/docs/cronjob.md +++ b/docs/cronjob.md @@ -300,6 +300,15 @@ Config-driven cron covers the 80% use case: "send this message at this time." Fo See [Kubernetes CronJob Reference Architecture](cronjob_k8s_refarch.md) for the external scheduler approach. +## Known Limitations + +| Limitation | Details | +|---|---| +| Mixed numeric/name day-of-week | `1,Mon` or `Mon,3` is not supported and will be rejected. Use either all numeric (`1-5`) or all name-based (`Mon-Fri`) notation. | +| Wrap-around day-of-week ranges | `5-2` (Fri through Tue) is not supported. Use explicit listing instead: `5,6,0,1,2`. | + +> **Tip:** Name-based notation (`Mon-Fri`, `Sun`, `Mon,Wed,Fri`) is always available as an alternative to numeric day-of-week values. + ## Troubleshooting | Symptom | Cause | Fix | diff --git a/src/cron.rs b/src/cron.rs index ca811d9ef..733c7695c 100644 --- a/src/cron.rs +++ b/src/cron.rs @@ -13,10 +13,212 @@ use tokio::sync::Mutex; use tracing::{debug, error, info, warn}; /// Parse a 5-field POSIX cron expression into a `Schedule`. +/// /// The `cron` crate expects a 6-field expression (with seconds), so we prepend "0". -pub fn parse_cron_expr(expr: &str) -> Result { - let six_field = format!("0 {}", expr); - Schedule::from_str(&six_field) +/// +/// POSIX numeric day-of-week values (0..=7, where 0 or 7 = Sunday) are translated +/// to the `cron` crate's 1-based form (1..=7, where 1 = Sunday) before being handed +/// to the underlying parser. Without this, numeric day-of-week values are off by one +/// — e.g. `1-5` (Mon-Fri in POSIX) would be evaluated as Sun-Thu. See the +/// [`translate_posix_dow_field`] doc comment for details. +/// +/// Name-based day-of-week tokens (`Mon`, `Sun`, `Mon-Fri`, ...) are passed through +/// unchanged — the `cron` crate's internal name-to-ordinal map is consistent. +pub fn parse_cron_expr(expr: &str) -> Result { + let translated = translate_posix_cron_expr(expr)?; + let six_field = format!("0 {}", translated); + Schedule::from_str(&six_field).map_err(|e| e.to_string()) +} + +/// Translate a 5-field POSIX cron expression so the day-of-week field uses the +/// numeric convention of the `cron` crate. +/// +/// Only the 5th field (day-of-week) is rewritten; the other four fields pass +/// through unchanged. +fn translate_posix_cron_expr(expr: &str) -> Result { + let fields: Vec<&str> = expr.split_whitespace().collect(); + if fields.len() != 5 { + return Err(format!( + "expected 5 whitespace-separated cron fields, got {}: {:?}", + fields.len(), + expr + )); + } + let translated_dow = translate_posix_dow_field(fields[4])?; + Ok(format!( + "{} {} {} {} {}", + fields[0], fields[1], fields[2], fields[3], translated_dow + )) +} + +/// Translate a POSIX day-of-week field to the `cron` crate's numeric form. +/// +/// # Background +/// +/// POSIX cron (and Linux crontab, Kubernetes CronJob, GitHub Actions) uses +/// `0..=7` where `0` or `7` = Sunday, `1` = Monday, ..., `6` = Saturday. +/// +/// The `cron` crate uses `1..=7` where `1` = Sunday, `2` = Monday, ..., `7` = Saturday +/// (it matches via chrono's `Weekday::number_from_sunday()`). Without translation, +/// every numeric day-of-week value fires one day early: +/// +/// | POSIX intent | Without translation (cron crate reads as) | +/// |---------------|-------------------------------------------| +/// | `0`, `7` (Sun) | out-of-range / Sat | +/// | `1` (Mon) | Sun | +/// | `5` (Fri) | Thu | +/// | `1-5` (Mon-Fri) | Sun-Thu | +/// +/// # Algorithm +/// +/// 1. If the field contains any ASCII letter (e.g. `Mon-Fri`), pass it through — +/// the cron crate's name-to-ordinal map is internally consistent. +/// 2. Otherwise, expand each comma-separated component into the set of POSIX +/// day values it represents. Ranges (`a-b`) and step values (`a/s`, `a-b/s`, +/// `*/s`) are expanded here. `7` is normalized to `0` (both = Sunday) to +/// avoid duplication. +/// 3. If the resulting set covers all 7 days, emit `*` for brevity. +/// 4. Otherwise, shift each value by `+1` (POSIX `{0..=6}` → cron crate +/// `{1..=7}`) and emit as a comma-separated list, compacting contiguous +/// runs into ranges for readability. +/// +/// # Mixed numeric and name notation +/// +/// Mixing numeric and name tokens in the same field (e.g. `1,Mon`) is not +/// supported and will return an error. Use either all numeric (POSIX) or all +/// name-based notation. +fn translate_posix_dow_field(field: &str) -> Result { + use std::collections::BTreeSet; + + // Name-based notation is internally consistent in the cron crate — pass through. + // But reject mixed numeric+name notation (e.g. "1,Mon") which would leave the + // numeric part untranslated and silently wrong. + let has_alpha = field.chars().any(|c| c.is_ascii_alphabetic()); + let has_digit = field.chars().any(|c| c.is_ascii_digit()); + if has_alpha && has_digit { + return Err(format!( + "mixed numeric and name notation is not supported in day-of-week field: {:?}", + field + )); + } + if has_alpha { + return Ok(field.to_string()); + } + + if field.is_empty() { + return Err("empty day-of-week field".to_string()); + } + + let mut days: BTreeSet = BTreeSet::new(); + + for part in field.split(',') { + if part.is_empty() { + return Err(format!("empty component in day-of-week field: {:?}", field)); + } + + // Split off optional step: `a/s`, `a-b/s`, `*/s`. + let (range_part, step) = match part.split_once('/') { + Some((r, s)) => { + let step_n: u32 = s + .parse() + .map_err(|_| format!("invalid step value in {:?}", part))?; + if step_n == 0 { + return Err(format!("step value cannot be zero in {:?}", part)); + } + (r, step_n) + } + None => (part, 1u32), + }; + + // Expand range_part to the list of POSIX day values it represents. + // Values may include 7 (Sunday alias for 0); normalization happens below. + let raw_values: Vec = if range_part == "*" { + (0..=6).collect() + } else if let Some((a, b)) = range_part.split_once('-') { + let a_n: u32 = a + .parse() + .map_err(|_| format!("invalid range start in {:?}", part))?; + let b_n: u32 = b + .parse() + .map_err(|_| format!("invalid range end in {:?}", part))?; + if a_n > 7 || b_n > 7 { + return Err(format!( + "day-of-week value out of range (0-7) in {:?}", + part + )); + } + if a_n > b_n { + return Err(format!("invalid range {:?}: start > end", part)); + } + (a_n..=b_n).collect() + } else { + let n: u32 = range_part + .parse() + .map_err(|_| format!("invalid number in {:?}", part))?; + if n > 7 { + return Err(format!("day-of-week value out of range (0-7): {}", n)); + } + if step > 1 { + // n/step means "from n through end-of-domain, stepping by step" + // Normalize 7 (Sunday alias) to 0 before expansion. + let start = if n == 7 { 0 } else { n }; + (start..=6).collect() + } else { + vec![n] + } + }; + + // Apply step filter, normalize 7 → 0, collect into the set. + for (i, &v) in raw_values.iter().enumerate() { + if (i as u32).is_multiple_of(step) { + let normalized = if v == 7 { 0 } else { v }; + days.insert(normalized); + } + } + } + + if days.is_empty() { + return Err(format!("empty day-of-week field: {:?}", field)); + } + + // All 7 days → emit `*` for brevity. + if days.len() == 7 { + return Ok("*".to_string()); + } + + // Shift POSIX {0..=6} → cron crate {1..=7} and emit, compacting contiguous runs. + let shifted: Vec = days.iter().map(|d| d + 1).collect(); + Ok(compact_ordinal_set(&shifted)) +} + +/// Compact a sorted list of ordinals into cron-style comma-list with ranges, +/// e.g. `[2,3,4,5,6]` → `"2-6"`, `[1,3,5]` → `"1,3,5"`, `[1,2,4,5]` → `"1-2,4-5"`. +fn compact_ordinal_set(sorted: &[u32]) -> String { + if sorted.is_empty() { + return String::new(); + } + let mut out: Vec = Vec::new(); + let mut start = sorted[0]; + let mut end = sorted[0]; + for &v in &sorted[1..] { + if v == end + 1 { + end = v; + } else { + out.push(render_run(start, end)); + start = v; + end = v; + } + } + out.push(render_run(start, end)); + out.join(",") +} + +fn render_run(start: u32, end: u32) -> String { + if start == end { + format!("{}", start) + } else { + format!("{}-{}", start, end) + } } /// Check whether a cron schedule should fire right now. @@ -435,6 +637,258 @@ mod tests { use super::*; use chrono::{Datelike, Timelike}; + // --- POSIX day-of-week translator --- + + #[test] + fn translate_dow_star_passes_through() { + assert_eq!(translate_posix_dow_field("*").unwrap(), "*"); + } + + #[test] + fn translate_dow_single_sunday_zero() { + assert_eq!(translate_posix_dow_field("0").unwrap(), "1"); + } + + #[test] + fn translate_dow_single_sunday_seven() { + assert_eq!(translate_posix_dow_field("7").unwrap(), "1"); + } + + #[test] + fn translate_dow_single_monday() { + assert_eq!(translate_posix_dow_field("1").unwrap(), "2"); + } + + #[test] + fn translate_dow_single_saturday() { + assert_eq!(translate_posix_dow_field("6").unwrap(), "7"); + } + + #[test] + fn translate_dow_weekday_range() { + // POSIX 1-5 (Mon-Fri) -> cron crate 2-6 + assert_eq!(translate_posix_dow_field("1-5").unwrap(), "2-6"); + } + + #[test] + fn translate_dow_all_days_zero_to_six() { + assert_eq!(translate_posix_dow_field("0-6").unwrap(), "*"); + } + + #[test] + fn translate_dow_all_days_zero_to_seven() { + // POSIX `0-7` is a quirky but valid "all days" expression. + assert_eq!(translate_posix_dow_field("0-7").unwrap(), "*"); + } + + #[test] + fn translate_dow_all_days_one_to_seven() { + // POSIX `1-7` covers Mon..Sun = all 7 days. + assert_eq!(translate_posix_dow_field("1-7").unwrap(), "*"); + } + + #[test] + fn translate_dow_range_three_to_five() { + // POSIX 3-5 (Wed-Fri) -> cron crate 4-6 + assert_eq!(translate_posix_dow_field("3-5").unwrap(), "4-6"); + } + + #[test] + fn translate_dow_list_dedupes_zero_and_seven() { + // Both 0 and 7 = Sunday; output is a single value. + assert_eq!(translate_posix_dow_field("0,7").unwrap(), "1"); + } + + #[test] + fn translate_dow_list_non_contiguous() { + // POSIX 1,3,5 (Mon,Wed,Fri) -> cron crate 2,4,6 + assert_eq!(translate_posix_dow_field("1,3,5").unwrap(), "2,4,6"); + } + + #[test] + fn translate_dow_list_compacts_contiguous_runs() { + // POSIX 1,2,4,5 -> cron crate 2,3,5,6 -> "2-3,5-6" + assert_eq!(translate_posix_dow_field("1,2,4,5").unwrap(), "2-3,5-6"); + } + + #[test] + fn translate_dow_step_from_star() { + // POSIX */2 = 0,2,4,6 = Sun,Tue,Thu,Sat -> cron crate 1,3,5,7 + assert_eq!(translate_posix_dow_field("*/2").unwrap(), "1,3,5,7"); + } + + #[test] + fn translate_dow_step_from_range() { + // POSIX 1-5/2 = 1,3,5 = Mon,Wed,Fri -> cron crate 2,4,6 + assert_eq!(translate_posix_dow_field("1-5/2").unwrap(), "2,4,6"); + } + + #[test] + fn translate_dow_names_pass_through() { + assert_eq!(translate_posix_dow_field("Mon-Fri").unwrap(), "Mon-Fri"); + assert_eq!( + translate_posix_dow_field("Mon,Wed,Fri").unwrap(), + "Mon,Wed,Fri" + ); + assert_eq!(translate_posix_dow_field("Sun").unwrap(), "Sun"); + } + + #[test] + fn translate_dow_step_from_singleton() { + // POSIX 1/2 = from Mon through Sat, step 2 = {1,3,5} = Mon,Wed,Fri -> cron crate 2,4,6 + assert_eq!(translate_posix_dow_field("1/2").unwrap(), "2,4,6"); + } + + #[test] + fn translate_dow_step_from_singleton_sunday() { + // POSIX 0/3 = from Sun through Sat, step 3 = {0,3,6} = Sun,Wed,Sat -> cron crate 1,4,7 + assert_eq!(translate_posix_dow_field("0/3").unwrap(), "1,4,7"); + } + + #[test] + fn translate_dow_step_from_singleton_seven() { + // POSIX 7/2 = Sunday alias, same as 0/2 = {0,2,4,6} = Sun,Tue,Thu,Sat -> cron crate 1,3,5,7 + assert_eq!(translate_posix_dow_field("7/2").unwrap(), "1,3,5,7"); + } + + #[test] + fn translate_dow_rejects_mixed_notation() { + assert!(translate_posix_dow_field("1,Mon").is_err()); + assert!(translate_posix_dow_field("Mon,1").is_err()); + assert!(translate_posix_dow_field("1-Fri").is_err()); + } + + #[test] + fn translate_dow_rejects_out_of_range() { + assert!(translate_posix_dow_field("8").is_err()); + assert!(translate_posix_dow_field("0-8").is_err()); + } + + #[test] + fn translate_dow_rejects_reversed_range() { + assert!(translate_posix_dow_field("5-3").is_err()); + } + + #[test] + fn translate_dow_rejects_empty() { + assert!(translate_posix_dow_field("").is_err()); + assert!(translate_posix_dow_field(",1").is_err()); + assert!(translate_posix_dow_field("1,").is_err()); + } + + #[test] + fn translate_dow_rejects_zero_step() { + assert!(translate_posix_dow_field("*/0").is_err()); + } + + // --- parse_cron_expr rejects wrong number of fields --- + + #[test] + fn parse_rejects_too_few_fields() { + assert!(parse_cron_expr("* * * *").is_err()); + } + + // --- POSIX-semantic Schedule behavior (regression for #784) --- + + #[test] + fn weekday_schedule_does_not_fire_on_sunday() { + use chrono::TimeZone; + // Regression for the reported bug: "0 7 * * 1-5" with timezone Asia/Taipei + // was firing on Sunday 2026-05-10 because the cron crate's `1-5` means + // Sun-Thu without translation. + let schedule = parse_cron_expr("0 7 * * 1-5").unwrap(); + let tz: Tz = "Asia/Taipei".parse().unwrap(); + let sunday = tz.with_ymd_and_hms(2026, 5, 10, 7, 0, 0).unwrap(); + let before = sunday - chrono::Duration::seconds(1); + let next = schedule.after(&before).next(); + assert_ne!( + next, + Some(sunday), + "POSIX 1-5 must not fire on Sunday (got next = {:?})", + next + ); + } + + #[test] + fn weekday_schedule_fires_on_monday() { + use chrono::TimeZone; + let schedule = parse_cron_expr("0 7 * * 1-5").unwrap(); + let tz: Tz = "Asia/Taipei".parse().unwrap(); + let monday = tz.with_ymd_and_hms(2026, 5, 11, 7, 0, 0).unwrap(); + let before = monday - chrono::Duration::seconds(1); + let next = schedule.after(&before).next(); + assert_eq!(next, Some(monday), "POSIX 1-5 must fire on Monday"); + } + + #[test] + fn weekday_schedule_fires_on_friday_not_saturday() { + use chrono::TimeZone; + let schedule = parse_cron_expr("0 7 * * 1-5").unwrap(); + let tz: Tz = "Asia/Taipei".parse().unwrap(); + // 2026-05-15 is Friday + let friday = tz.with_ymd_and_hms(2026, 5, 15, 7, 0, 0).unwrap(); + let before = friday - chrono::Duration::seconds(1); + let next = schedule.after(&before).next(); + assert_eq!(next, Some(friday), "POSIX 1-5 must fire on Friday"); + + // 2026-05-16 is Saturday - should not fire + let saturday = tz.with_ymd_and_hms(2026, 5, 16, 7, 0, 0).unwrap(); + let before = saturday - chrono::Duration::seconds(1); + let next = schedule.after(&before).next(); + assert_ne!(next, Some(saturday), "POSIX 1-5 must not fire on Saturday"); + } + + #[test] + fn sunday_schedule_fires_on_sunday_via_zero() { + use chrono::TimeZone; + let schedule = parse_cron_expr("0 7 * * 0").unwrap(); + let tz: Tz = "Asia/Taipei".parse().unwrap(); + let sunday = tz.with_ymd_and_hms(2026, 5, 10, 7, 0, 0).unwrap(); + let before = sunday - chrono::Duration::seconds(1); + let next = schedule.after(&before).next(); + assert_eq!(next, Some(sunday), "POSIX `0` must fire on Sunday"); + } + + #[test] + fn sunday_schedule_fires_on_sunday_via_seven() { + use chrono::TimeZone; + let schedule = parse_cron_expr("0 7 * * 7").unwrap(); + let tz: Tz = "Asia/Taipei".parse().unwrap(); + let sunday = tz.with_ymd_and_hms(2026, 5, 10, 7, 0, 0).unwrap(); + let before = sunday - chrono::Duration::seconds(1); + let next = schedule.after(&before).next(); + assert_eq!(next, Some(sunday), "POSIX `7` must also fire on Sunday"); + } + + #[test] + fn saturday_schedule_fires_on_saturday_via_six() { + use chrono::TimeZone; + let schedule = parse_cron_expr("0 7 * * 6").unwrap(); + let tz: Tz = "Asia/Taipei".parse().unwrap(); + // 2026-05-16 is Saturday + let saturday = tz.with_ymd_and_hms(2026, 5, 16, 7, 0, 0).unwrap(); + let before = saturday - chrono::Duration::seconds(1); + let next = schedule.after(&before).next(); + assert_eq!(next, Some(saturday), "POSIX `6` must fire on Saturday"); + } + + #[test] + fn name_based_weekday_still_works() { + use chrono::TimeZone; + // Name-based notation should be unaffected by the translation. + let schedule = parse_cron_expr("0 7 * * Mon-Fri").unwrap(); + let tz: Tz = "Asia/Taipei".parse().unwrap(); + let monday = tz.with_ymd_and_hms(2026, 5, 11, 7, 0, 0).unwrap(); + let before = monday - chrono::Duration::seconds(1); + let next = schedule.after(&before).next(); + assert_eq!(next, Some(monday)); + + let sunday = tz.with_ymd_and_hms(2026, 5, 10, 7, 0, 0).unwrap(); + let before = sunday - chrono::Duration::seconds(1); + let next = schedule.after(&before).next(); + assert_ne!(next, Some(sunday)); + } + #[test] fn parse_valid_cron_expression() { let schedule = parse_cron_expr("0 9 * * 1-5").unwrap(); From cc9495e6b3bae70066c23c8d060fa311f8c63362 Mon Sep 17 00:00:00 2001 From: chaodufashi Date: Sun, 10 May 2026 21:49:10 -0400 Subject: [PATCH 018/100] feat: add receiver_id to SenderContext schema (#787) Add optional receiver_id field to SenderContext so agents can identify themselves when multiple agents share the same backend runtime. - Discord: injects bot_id from ctx.cache.current_user().id - Slack: injects bot_user_id from get_bot_user_id() - Gateway/Cron: None (no receiver identity available yet) Field is optional and skip_serializing_if None, so this is a non-breaking additive change to openab.sender.v1. Closes #786 Co-authored-by: chaodu-agent --- src/adapter.rs | 4 ++++ src/cron.rs | 1 + src/discord.rs | 7 +++++++ src/gateway.rs | 1 + src/slack.rs | 1 + 5 files changed, 14 insertions(+) diff --git a/src/adapter.rs b/src/adapter.rs index 2820c9aec..c8a2be450 100644 --- a/src/adapter.rs +++ b/src/adapter.rs @@ -195,6 +195,10 @@ pub struct SenderContext { /// via the `[[reply_to:]]` output directive. #[serde(skip_serializing_if = "Option::is_none")] pub message_id: Option, + /// The platform user ID of the receiving bot/agent. + /// Enables agents to identify themselves when multiple agents share the same backend. + #[serde(skip_serializing_if = "Option::is_none")] + pub receiver_id: Option, } // --- ChatAdapter trait --- diff --git a/src/cron.rs b/src/cron.rs index 733c7695c..a570e96e1 100644 --- a/src/cron.rs +++ b/src/cron.rs @@ -602,6 +602,7 @@ async fn fire_cronjob( is_bot: true, timestamp: Some(Utc::now().to_rfc3339()), message_id: None, // cron jobs don't originate from a message + receiver_id: None, // cron jobs are self-triggered, no external receiver }; let sender_json = match serde_json::to_string(&sender) { Ok(j) => j, diff --git a/src/discord.rs b/src/discord.rs index 1370e0719..ce2b5e118 100644 --- a/src/discord.rs +++ b/src/discord.rs @@ -630,6 +630,7 @@ impl EventHandler for Handler { msg.author.bot, &msg.timestamp.to_rfc3339().unwrap_or_default(), &msg.id.to_string(), + &bot_id.to_string(), ); // Build extra content blocks from attachments (audio -> STT, text -> inline, @@ -1370,6 +1371,7 @@ fn build_sender_context( is_bot: bool, timestamp: &str, message_id: &str, + receiver_id: &str, ) -> SenderContext { SenderContext { schema: "openab.sender.v1".into(), @@ -1382,6 +1384,7 @@ fn build_sender_context( is_bot, timestamp: Some(timestamp.to_string()), message_id: Some(message_id.to_string()), + receiver_id: Some(receiver_id.to_string()), } } @@ -1758,12 +1761,14 @@ mod tests { false, "2026-05-01T00:00:00Z", "msg123", + "bot99", ); assert_eq!(ctx.channel_id, "parent_ch"); assert_eq!(ctx.thread_id, Some("thread_ch".to_string())); assert_eq!(ctx.channel, "discord"); assert_eq!(ctx.sender_id, "user1"); assert!(!ctx.is_bot); + assert_eq!(ctx.receiver_id, Some("bot99".to_string())); } /// Non-thread message: channel_id = message channel, thread_id = None. @@ -1778,6 +1783,7 @@ mod tests { false, "2026-05-01T00:00:00Z", "msg456", + "bot99", ); assert_eq!(ctx.channel_id, "main_ch"); assert_eq!(ctx.thread_id, None); @@ -1795,6 +1801,7 @@ mod tests { true, "2026-05-01T00:00:00Z", "msg789", + "bot99", ); assert!(ctx.is_bot); assert_eq!(ctx.channel_id, "parent"); diff --git a/src/gateway.rs b/src/gateway.rs index c0f3077cb..551e787a9 100644 --- a/src/gateway.rs +++ b/src/gateway.rs @@ -643,6 +643,7 @@ pub async fn run_gateway_adapter( event.timestamp.clone() }), message_id: if event.message_id.is_empty() { None } else { Some(event.message_id.clone()) }, + receiver_id: None, // gateway does not yet resolve receiver identity }; let sender_json = serde_json::to_string(&sender_ctx) .unwrap_or_default(); diff --git a/src/slack.rs b/src/slack.rs index 1a38c22fe..cbe101f2e 100644 --- a/src/slack.rs +++ b/src/slack.rs @@ -1064,6 +1064,7 @@ async fn handle_message( is_bot: is_bot_msg, timestamp: Some(crate::timestamp::slack_ts_to_iso8601(&ts)), message_id: Some(ts.clone()), + receiver_id: bot_id.map(|id| id.to_string()), }; let trigger_msg = MessageRef { From 984b8292d25f52830c314aa6dc4beb2f52a83ccb Mon Sep 17 00:00:00 2001 From: "openab-app[bot]" <274185012+openab-app[bot]@users.noreply.github.com> Date: Mon, 11 May 2026 09:49:58 +0800 Subject: [PATCH 019/100] release: v0.8.3-beta.7 (#788) Co-authored-by: openab-app[bot] <274185012+openab-app[bot]@users.noreply.github.com> --- charts/openab/Chart.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/charts/openab/Chart.yaml b/charts/openab/Chart.yaml index 982456bf1..e5f10da65 100644 --- a/charts/openab/Chart.yaml +++ b/charts/openab/Chart.yaml @@ -2,5 +2,5 @@ apiVersion: v2 name: openab description: A lightweight, secure, cloud-native ACP harness that bridges Discord and any ACP-compatible coding CLI. type: application -version: 0.8.3-beta.6 -appVersion: "0.8.3-beta.6" +version: 0.8.3-beta.7 +appVersion: "0.8.3-beta.7" From 2f20fdb24dc9b1bd5f5da9858fbdf1f84886e47a Mon Sep 17 00:00:00 2001 From: chaodufashi Date: Mon, 11 May 2026 13:57:08 -0400 Subject: [PATCH 020/100] feat(discord): add /remind slash command (#797) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(discord): add /remind slash command for one-shot delayed mentions Implements a /remind slash command that lets humans schedule delayed mentions to users/roles in the channel. - Command: /remind - Delay range: 1m to 30d (supports m/h/d and combinations) - Only humans can invoke (bots rejected) - Persistence: reminders.json survives restarts - Re-schedules pending reminders on bot ready Closes #796 * fix(remind): enable chrono/serde, fix broken test, lock-free persist, input validation - Cargo.toml: add serde feature to chrono (fixes E0277 CI failure) - remind.rs: release mutex before sync file I/O to avoid blocking executor - remind.rs: replace unwrap_or_default() with explicit error log on serialization failure - remind.rs: fix test_parse_delay_too_short (remove wrong assertion that bare "30" errors) - discord.rs: add 1800-char cap on reminder message - discord.rs: neutralize @everyone/@here in message via zero-width space * fix(remind): add rate limit, create_dir_all, dedup scheduling, safe removal - F4: Per-user rate limit (max 5 active reminders) - F7: create_dir_all before writing reminders.json - F8: Deduplicate reminder scheduling on reconnect via scheduled_ids - F9: Only remove reminder from store after successful send * docs: update /remind examples to English, add rate limit and length constraints * test(remind): add comprehensive tests for edge cases, store, and validation - parse_delay: empty, invalid unit, case-insensitive, whitespace, boundaries - format_delay: zero, pure units - ReminderStore: add/remove/pending, persistence across reload - sanitize_message: @everyone/@here neutralization - validate_message: length cap enforcement - Extract sanitize_message/validate_message as testable helpers * fix: use validate_message helper in handler to resolve dead_code warning * docs: mention @everyone/@here neutralization in /remind constraints * feat(remind): add max 10 targets limit with test and docs - Add MAX_TARGETS = 10 constant - Reject /remind with >10 mentions (suggest using @role) - Update docs/slash-commands.md with constraint - Add test for constant * fix(remind): insert new reminder ID into scheduled_ids to prevent duplicate on reconnect --------- Co-authored-by: chaodu-agent Co-authored-by: 普渡法師 --- Cargo.toml | 2 +- docs/slash-commands.md | 43 +++++ src/discord.rs | 184 ++++++++++++++++++- src/main.rs | 11 ++ src/remind.rs | 399 +++++++++++++++++++++++++++++++++++++++++ 5 files changed, 636 insertions(+), 3 deletions(-) create mode 100644 src/remind.rs diff --git a/Cargo.toml b/Cargo.toml index da2a7bb4a..8c19bcf5d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -27,7 +27,7 @@ unicode-width = "0.2" pulldown-cmark = { version = "0.13", default-features = false } tokio-tungstenite = { version = "0.21", features = ["rustls-tls-webpki-roots"] } cron = "0.16.0" -chrono = "0.4.44" +chrono = { version = "0.4.44", features = ["serde"] } chrono-tz = "0.10.4" [target.'cfg(unix)'.dependencies] diff --git a/docs/slash-commands.md b/docs/slash-commands.md index 6d24a63a5..040838e51 100644 --- a/docs/slash-commands.md +++ b/docs/slash-commands.md @@ -10,6 +10,7 @@ OpenAB registers Discord slash commands for session control. These work in both | `/agents` | Select the agent mode via dropdown menu | Yes | | `/cancel` | Cancel the current in-flight operation | Yes | | `/reset` | Reset the conversation session (clear history, start fresh) | Yes | +| `/remind` | Set a one-shot delayed reminder to mention users/roles | No | All responses are **ephemeral** — only the user who invoked the command sees the reply. @@ -74,3 +75,45 @@ In addition to slash commands, you can pass built-in CLI commands directly after ``` These are forwarded as-is to the ACP session as a prompt. Any command the underlying CLI supports in its interactive mode works here. This is the recommended workaround for agents that don't expose `configOptions`. + +## `/remind` + +Set a one-shot delayed reminder that mentions users or roles in the channel after a specified delay. + +**Syntax:** +``` +/remind targets:<@user @role ...> message: delay: +``` + +**Parameters:** + +| Parameter | Required | Description | +|-----------|----------|-------------| +| `targets` | Yes | Space-separated @mentions (users and/or roles) | +| `message` | Yes | Reminder text | +| `delay` | Yes | Duration before firing: `1m` to `30d` (supports `m`, `h`, `d` and combinations like `1h30m`) | + +**Constraints:** +- Only humans can use `/remind` (bots are rejected) +- Minimum delay: 1 minute +- Maximum delay: 30 days +- Maximum message length: 1800 characters +- Maximum 5 active reminders per user +- Maximum 10 mention targets per reminder (use a @role for larger groups) +- `@everyone` and `@here` in messages are automatically neutralized (will not trigger mass mentions) +- One-shot only (fires once, then removed) +- Reminders persist across bot restarts (stored in `$HOME/.openab/reminders.json`) + +**Examples:** +``` +/remind targets:@Alice @Bob message:Review PR #42 delay:2h +/remind targets:@Reviewers message:Stand-up time delay:30m +/remind targets:@Charlie message:Check deployment delay:1d +``` + +**When fired, the bot posts:** +``` +⏰ Reminder from @sender: +"Review PR #42" +cc @Alice @Bob +``` diff --git a/src/discord.rs b/src/discord.rs index ce2b5e118..5452e6a9e 100644 --- a/src/discord.rs +++ b/src/discord.rs @@ -5,15 +5,16 @@ use crate::bot_turns::{BotTurnTracker, TurnAction, TurnSeverity}; use crate::config::{AllowBots, AllowUsers, SttConfig}; use crate::format; use crate::media; +use crate::remind::{self, ReminderStore}; use async_trait::async_trait; use serenity::builder::{ - CreateActionRow, CreateButton, CreateCommand, CreateInteractionResponse, + CreateActionRow, CreateButton, CreateCommand, CreateCommandOption, CreateInteractionResponse, CreateInteractionResponseMessage, CreateSelectMenu, CreateSelectMenuKind, CreateSelectMenuOption, CreateThread, EditMessage, }; use serenity::http::Http; use serenity::model::application::ButtonStyle; -use serenity::model::application::{Command, ComponentInteractionDataKind, Interaction}; +use serenity::model::application::{Command, CommandOptionType, ComponentInteractionDataKind, Interaction}; use serenity::model::channel::{AutoArchiveDuration, Message, MessageType, ReactionType}; use serenity::model::gateway::Ready; use serenity::model::id::{ChannelId, MessageId, UserId}; @@ -207,6 +208,10 @@ pub struct Handler { pub allow_dm: bool, /// Per-thread dispatcher (Message mode uses cap=1 for FIFO; Thread/Lane use configured cap). pub dispatcher: Arc, + /// Reminder store for /remind slash command. + pub reminder_store: ReminderStore, + /// Track scheduled reminder IDs to prevent duplicate scheduling on reconnect. + pub scheduled_ids: tokio::sync::Mutex>, } impl Handler { @@ -815,6 +820,23 @@ impl EventHandler for Handler { CreateCommand::new("cancel-all") .description("Cancel current operation and drop all buffered messages"), CreateCommand::new("reset").description("Reset the conversation session"), + CreateCommand::new("remind") + .description("Set a one-shot reminder to mention users/roles after a delay") + .add_option(CreateCommandOption::new( + CommandOptionType::String, + "targets", + "Users/roles to mention (e.g. @user1 @role1)", + ).required(true)) + .add_option(CreateCommandOption::new( + CommandOptionType::String, + "message", + "Reminder message", + ).required(true)) + .add_option(CreateCommandOption::new( + CommandOptionType::String, + "delay", + "Delay before firing (e.g. 30m, 2h, 1d)", + ).required(true)), ]; // Register global commands (works in DMs + all guilds after propagation). @@ -833,6 +855,22 @@ impl EventHandler for Handler { info!(%guild_id, "registered guild slash commands"); } } + + // Re-schedule any pending reminders that survived a restart. + let pending = self.reminder_store.pending().await; + if !pending.is_empty() { + let mut scheduled = self.scheduled_ids.lock().await; + let mut count = 0; + for r in pending { + if scheduled.insert(r.id.clone()) { + remind::schedule_reminder(ctx.http.clone(), self.reminder_store.clone(), r); + count += 1; + } + } + if count > 0 { + info!(count, "re-scheduled pending reminders"); + } + } } async fn interaction_create(&self, ctx: Context, interaction: Interaction) { @@ -854,6 +892,9 @@ impl EventHandler for Handler { Interaction::Command(cmd) if cmd.data.name == "reset" => { self.handle_reset_command(&ctx, &cmd).await; } + Interaction::Command(cmd) if cmd.data.name == "remind" => { + self.handle_remind_command(&ctx, &cmd).await; + } Interaction::Component(comp) if comp.data.custom_id.starts_with("acp_config_") => { self.handle_config_select(&ctx, &comp).await; } @@ -1116,6 +1157,145 @@ impl Handler { } } + async fn handle_remind_command( + &self, + ctx: &Context, + cmd: &serenity::model::application::CommandInteraction, + ) { + // Only humans can use /remind + if cmd.user.bot { + let response = CreateInteractionResponse::Message( + CreateInteractionResponseMessage::new() + .content("⚠️ Only humans can set reminders.") + .ephemeral(true), + ); + let _ = cmd.create_response(&ctx.http, response).await; + return; + } + + // Extract options + let opts = &cmd.data.options; + let targets_raw = opts.iter() + .find(|o| o.name == "targets") + .and_then(|o| o.value.as_str()) + .unwrap_or(""); + let message = opts.iter() + .find(|o| o.name == "message") + .and_then(|o| o.value.as_str()) + .unwrap_or(""); + let delay_raw = opts.iter() + .find(|o| o.name == "delay") + .and_then(|o| o.value.as_str()) + .unwrap_or(""); + + if targets_raw.is_empty() || message.is_empty() || delay_raw.is_empty() { + let response = CreateInteractionResponse::Message( + CreateInteractionResponseMessage::new() + .content("⚠️ All fields (targets, message, delay) are required.") + .ephemeral(true), + ); + let _ = cmd.create_response(&ctx.http, response).await; + return; + } + + // Parse delay + let delay_secs = match remind::parse_delay(delay_raw) { + Ok(s) => s, + Err(e) => { + let response = CreateInteractionResponse::Message( + CreateInteractionResponseMessage::new() + .content(format!("⚠️ Invalid delay: {e}")) + .ephemeral(true), + ); + let _ = cmd.create_response(&ctx.http, response).await; + return; + } + }; + + if let Err(e) = remind::validate_message(message) { + let response = CreateInteractionResponse::Message( + CreateInteractionResponseMessage::new() + .content(format!("⚠️ {e}")) + .ephemeral(true), + ); + let _ = cmd.create_response(&ctx.http, response).await; + return; + } + + // Strip @everyone / @here to prevent unintended mass pings. + let message = remind::sanitize_message(message); + + // Extract mention strings from targets (keep raw — Discord renders them) + let targets: Vec = targets_raw + .split_whitespace() + .filter(|t| t.starts_with("<@") && t.ends_with('>')) + .map(|t| t.to_string()) + .collect(); + + if targets.is_empty() { + let response = CreateInteractionResponse::Message( + CreateInteractionResponseMessage::new() + .content("⚠️ No valid mentions found in targets. Use @user or @role.") + .ephemeral(true), + ); + let _ = cmd.create_response(&ctx.http, response).await; + return; + } + + if targets.len() > remind::MAX_TARGETS { + let response = CreateInteractionResponse::Message( + CreateInteractionResponseMessage::new() + .content(format!("⚠️ Too many targets (max {}). Use a @role instead.", remind::MAX_TARGETS)) + .ephemeral(true), + ); + let _ = cmd.create_response(&ctx.http, response).await; + return; + } + + // F4: Per-user rate limit (max 5 active reminders) + let user_id = cmd.user.id.get(); + let pending = self.reminder_store.pending().await; + let user_count = pending.iter().filter(|r| r.sender_id == user_id).count(); + if user_count >= 5 { + let response = CreateInteractionResponse::Message( + CreateInteractionResponseMessage::new() + .content("⚠️ You already have 5 active reminders. Wait for some to fire before adding more.") + .ephemeral(true), + ); + let _ = cmd.create_response(&ctx.http, response).await; + return; + } + + let fire_at = chrono::Utc::now() + chrono::Duration::seconds(delay_secs as i64); + let reminder = remind::Reminder { + id: uuid::Uuid::new_v4().to_string(), + channel_id: cmd.channel_id.get(), + sender_id: cmd.user.id.get(), + targets: targets.clone(), + message: message.clone(), + fire_at, + created_at: chrono::Utc::now(), + }; + + // Persist and schedule + self.reminder_store.add(reminder.clone()).await; + self.scheduled_ids.lock().await.insert(reminder.id.clone()); + remind::schedule_reminder(ctx.http.clone(), self.reminder_store.clone(), reminder); + + let delay_str = remind::format_delay(delay_secs); + let response = CreateInteractionResponse::Message( + CreateInteractionResponseMessage::new() + .content(format!( + "⏰ Reminder set! Will fire in **{delay_str}** and mention {}", + targets.join(" ") + )) + .ephemeral(true), + ); + if let Err(e) = cmd.create_response(&ctx.http, response).await { + tracing::error!(error = %e, "failed to respond to /remind command"); + } + } + async fn handle_config_select( &self, ctx: &Context, diff --git a/src/main.rs b/src/main.rs index 706079b6d..413eb1147 100644 --- a/src/main.rs +++ b/src/main.rs @@ -11,6 +11,7 @@ mod gateway; mod markdown; mod media; mod reactions; +mod remind; mod setup; mod slack; mod stt; @@ -403,6 +404,14 @@ async fn main() -> anyhow::Result<()> { )); dispatchers.lock().unwrap().push(discord_dispatcher.clone()); + // Initialize reminder store (persists to $HOME/.openab/reminders.json) + let reminder_path = std::env::var("HOME") + .map(std::path::PathBuf::from) + .unwrap_or_default() + .join(".openab") + .join("reminders.json"); + let reminder_store = remind::ReminderStore::load(reminder_path); + let handler = discord::Handler { router, allow_all_channels, @@ -424,6 +433,8 @@ async fn main() -> anyhow::Result<()> { )), allow_dm: discord_cfg.allow_dm, dispatcher: discord_dispatcher, + reminder_store: reminder_store.clone(), + scheduled_ids: tokio::sync::Mutex::new(std::collections::HashSet::new()), }; let intents = GatewayIntents::GUILD_MESSAGES diff --git a/src/remind.rs b/src/remind.rs new file mode 100644 index 000000000..471b08ff4 --- /dev/null +++ b/src/remind.rs @@ -0,0 +1,399 @@ +//! One-shot `/remind` slash command — schedules a delayed mention in a Discord channel. +//! +//! Persistence: reminders are stored in `reminders.json` and reloaded on startup. + +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use serenity::http::Http; +use serenity::model::id::ChannelId; +use std::path::PathBuf; +use std::sync::Arc; +use tokio::sync::Mutex; +use tracing::{error, info, warn}; + +/// A single pending reminder. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Reminder { + pub id: String, + pub channel_id: u64, + pub sender_id: u64, + /// Raw mention strings (e.g. "<@123>", "<@&456>") + pub targets: Vec, + pub message: String, + pub fire_at: DateTime, + pub created_at: DateTime, +} + +/// Shared reminder store with file persistence. +#[derive(Clone)] +pub struct ReminderStore { + reminders: Arc>>, + path: PathBuf, +} + +impl ReminderStore { + /// Load or create the reminder store from the given path. + pub fn load(path: PathBuf) -> Self { + let reminders = match std::fs::read_to_string(&path) { + Ok(data) => serde_json::from_str(&data).unwrap_or_else(|e| { + warn!(error = %e, "failed to parse reminders.json, starting empty"); + Vec::new() + }), + Err(_) => Vec::new(), + }; + info!(count = reminders.len(), path = %path.display(), "loaded reminders"); + Self { + reminders: Arc::new(Mutex::new(reminders)), + path, + } + } + + /// Add a reminder and persist to disk. + pub async fn add(&self, reminder: Reminder) { + let snapshot = { + let mut reminders = self.reminders.lock().await; + reminders.push(reminder); + reminders.clone() + }; + self.persist(&snapshot); + } + + /// Remove a reminder by ID and persist. + pub async fn remove(&self, id: &str) { + let snapshot = { + let mut reminders = self.reminders.lock().await; + reminders.retain(|r| r.id != id); + reminders.clone() + }; + self.persist(&snapshot); + } + + /// Get all pending reminders (for startup re-scheduling). + pub async fn pending(&self) -> Vec { + self.reminders.lock().await.clone() + } + + fn persist(&self, reminders: &[Reminder]) { + match serde_json::to_string_pretty(reminders) { + Ok(data) => { + if let Some(parent) = self.path.parent() { + if let Err(e) = std::fs::create_dir_all(parent) { + error!(error = %e, "failed to create reminders directory"); + return; + } + } + if let Err(e) = std::fs::write(&self.path, data) { + error!(error = %e, "failed to persist reminders.json"); + } + } + Err(e) => { + error!(error = %e, "failed to serialize reminders, skipping persist"); + } + } + } +} + +/// Maximum allowed message length for reminders. +pub const MAX_MESSAGE_LEN: usize = 1800; + +/// Maximum number of mention targets per reminder. +pub const MAX_TARGETS: usize = 10; + +/// Sanitize reminder message: neutralize @everyone/@here. +pub fn sanitize_message(msg: &str) -> String { + msg.replace("@everyone", "@\u{200b}everyone") + .replace("@here", "@\u{200b}here") +} + +/// Validate reminder message length. +pub fn validate_message(msg: &str) -> Result<(), String> { + if msg.len() > MAX_MESSAGE_LEN { + Err(format!("message too long (max {MAX_MESSAGE_LEN} characters)")) + } else { + Ok(()) + } +} + +/// Parse a human delay string like "30m", "2h", "7d" into seconds. +/// Supports combinations: "1h30m", "2d12h". +/// Range: 1m (60s) to 30d (2_592_000s). +pub fn parse_delay(input: &str) -> Result { + let s = input.trim().to_lowercase(); + if s.is_empty() { + return Err("empty delay".into()); + } + + let mut total_secs: u64 = 0; + let mut num_buf = String::new(); + + for ch in s.chars() { + if ch.is_ascii_digit() { + num_buf.push(ch); + } else { + let n: u64 = num_buf.parse().map_err(|_| format!("invalid number in delay: {input}"))?; + num_buf.clear(); + let multiplier = match ch { + 'm' => 60, + 'h' => 3600, + 'd' => 86400, + _ => return Err(format!("unknown unit '{ch}' in delay (use m/h/d)")), + }; + total_secs += n * multiplier; + } + } + + // Handle bare number (default to minutes) + if !num_buf.is_empty() { + let n: u64 = num_buf.parse().map_err(|_| format!("invalid number in delay: {input}"))?; + total_secs += n * 60; // default unit = minutes + } + + if total_secs < 60 { + return Err("minimum delay is 1m".into()); + } + if total_secs > 2_592_000 { + return Err("maximum delay is 30d".into()); + } + + Ok(total_secs) +} + +/// Format seconds into a human-readable string like "2h 30m". +pub fn format_delay(secs: u64) -> String { + let d = secs / 86400; + let h = (secs % 86400) / 3600; + let m = (secs % 3600) / 60; + let mut parts = Vec::new(); + if d > 0 { parts.push(format!("{d}d")); } + if h > 0 { parts.push(format!("{h}h")); } + if m > 0 { parts.push(format!("{m}m")); } + if parts.is_empty() { "< 1m".into() } else { parts.join(" ") } +} + +/// Spawn a tokio task that fires the reminder after the delay. +pub fn schedule_reminder( + http: Arc, + store: ReminderStore, + reminder: Reminder, +) { + let now = Utc::now(); + let delay = if reminder.fire_at > now { + (reminder.fire_at - now).to_std().unwrap_or_default() + } else { + std::time::Duration::ZERO + }; + + let id = reminder.id.clone(); + tokio::spawn(async move { + tokio::time::sleep(delay).await; + + let targets_str = reminder.targets.join(" "); + let content = format!( + "⏰ **Reminder** from <@{}>:\n\"{}\"\ncc {}", + reminder.sender_id, reminder.message, targets_str + ); + + let channel = ChannelId::new(reminder.channel_id); + match channel.say(&http, &content).await { + Ok(_) => { + info!(id = %id, channel = reminder.channel_id, "reminder fired"); + store.remove(&id).await; + } + Err(e) => { + error!(error = %e, id = %id, "failed to send reminder — keeping for retry on next restart"); + } + } + }); +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_parse_delay_minutes() { + assert_eq!(parse_delay("5m").unwrap(), 300); + assert_eq!(parse_delay("1m").unwrap(), 60); + } + + #[test] + fn test_parse_delay_hours() { + assert_eq!(parse_delay("2h").unwrap(), 7200); + } + + #[test] + fn test_parse_delay_days() { + assert_eq!(parse_delay("1d").unwrap(), 86400); + assert_eq!(parse_delay("30d").unwrap(), 2_592_000); + } + + #[test] + fn test_parse_delay_combined() { + assert_eq!(parse_delay("1h30m").unwrap(), 5400); + assert_eq!(parse_delay("1d12h").unwrap(), 129_600); + } + + #[test] + fn test_parse_delay_bare_number_defaults_to_minutes() { + assert_eq!(parse_delay("10").unwrap(), 600); + } + + #[test] + fn test_parse_delay_too_short() { + assert!(parse_delay("0m").is_err()); + assert!(parse_delay("0h").is_err()); + } + + #[test] + fn test_parse_delay_too_long() { + assert!(parse_delay("31d").is_err()); + } + + #[test] + fn test_format_delay() { + assert_eq!(format_delay(3600), "1h"); + assert_eq!(format_delay(5400), "1h 30m"); + assert_eq!(format_delay(90000), "1d 1h"); + } + + #[test] + fn test_parse_delay_empty() { + assert!(parse_delay("").is_err()); + assert!(parse_delay(" ").is_err()); + } + + #[test] + fn test_parse_delay_invalid_unit() { + assert!(parse_delay("2x").is_err()); + assert!(parse_delay("abc").is_err()); + assert!(parse_delay("5s").is_err()); + } + + #[test] + fn test_parse_delay_case_insensitive() { + assert_eq!(parse_delay("2H").unwrap(), 7200); + assert_eq!(parse_delay("1D30M").unwrap(), 88200); + } + + #[test] + fn test_parse_delay_whitespace_trimmed() { + assert_eq!(parse_delay(" 5m ").unwrap(), 300); + } + + #[test] + fn test_parse_delay_bare_number_boundary() { + assert_eq!(parse_delay("1").unwrap(), 60); // 1 min + assert_eq!(parse_delay("30").unwrap(), 1800); // 30 min + } + + #[test] + fn test_parse_delay_exact_boundaries() { + // Exactly 1m (minimum) + assert_eq!(parse_delay("1m").unwrap(), 60); + // Exactly 30d (maximum) + assert_eq!(parse_delay("30d").unwrap(), 2_592_000); + // Just over 30d + assert!(parse_delay("30d1m").is_err()); + } + + #[test] + fn test_format_delay_zero() { + assert_eq!(format_delay(0), "< 1m"); + } + + #[test] + fn test_format_delay_pure_units() { + assert_eq!(format_delay(86400), "1d"); + assert_eq!(format_delay(120), "2m"); + assert_eq!(format_delay(7200), "2h"); + } + + #[tokio::test] + async fn test_reminder_store_add_remove() { + let dir = std::env::temp_dir().join(format!("remind_test_{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("reminders.json"); + + let store = ReminderStore::load(path.clone()); + assert_eq!(store.pending().await.len(), 0); + + let r = Reminder { + id: "test-1".into(), + channel_id: 123, + sender_id: 456, + targets: vec!["<@789>".into()], + message: "hello".into(), + fire_at: Utc::now() + chrono::Duration::hours(1), + created_at: Utc::now(), + }; + + store.add(r).await; + assert_eq!(store.pending().await.len(), 1); + + store.remove("test-1").await; + assert_eq!(store.pending().await.len(), 0); + + // Verify persistence + let store2 = ReminderStore::load(path.clone()); + assert_eq!(store2.pending().await.len(), 0); + + std::fs::remove_dir_all(&dir).ok(); + } + + #[tokio::test] + async fn test_reminder_store_persists_across_reload() { + let dir = std::env::temp_dir().join(format!("remind_test2_{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("reminders.json"); + + let store = ReminderStore::load(path.clone()); + let r = Reminder { + id: "persist-1".into(), + channel_id: 100, + sender_id: 200, + targets: vec!["<@300>".into()], + message: "persist test".into(), + fire_at: Utc::now() + chrono::Duration::hours(2), + created_at: Utc::now(), + }; + store.add(r).await; + + // Reload from disk + let store2 = ReminderStore::load(path.clone()); + let pending = store2.pending().await; + assert_eq!(pending.len(), 1); + assert_eq!(pending[0].id, "persist-1"); + assert_eq!(pending[0].message, "persist test"); + + std::fs::remove_dir_all(&dir).ok(); + } + + #[test] + fn test_sanitize_message_strips_everyone_here() { + assert_eq!(sanitize_message("hello @everyone"), "hello @\u{200b}everyone"); + assert_eq!(sanitize_message("hey @here check"), "hey @\u{200b}here check"); + assert_eq!(sanitize_message("@everyone @here"), "@\u{200b}everyone @\u{200b}here"); + } + + #[test] + fn test_sanitize_message_no_change() { + assert_eq!(sanitize_message("normal message"), "normal message"); + assert_eq!(sanitize_message("<@123> hello"), "<@123> hello"); + } + + #[test] + fn test_validate_message_ok() { + assert!(validate_message("short message").is_ok()); + assert!(validate_message(&"a".repeat(1800)).is_ok()); + } + + #[test] + fn test_validate_message_too_long() { + assert!(validate_message(&"a".repeat(1801)).is_err()); + } + + #[test] + fn test_max_targets_constant() { + assert_eq!(MAX_TARGETS, 10); + } +} From 1a77a5c16c80ffc146da5e8ade7dd9e1ed1b033d Mon Sep 17 00:00:00 2001 From: "openab-app[bot]" <274185012+openab-app[bot]@users.noreply.github.com> Date: Tue, 12 May 2026 02:03:57 +0800 Subject: [PATCH 021/100] release: v0.8.3-beta.8 (#798) Co-authored-by: openab-app[bot] <274185012+openab-app[bot]@users.noreply.github.com> --- charts/openab/Chart.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/charts/openab/Chart.yaml b/charts/openab/Chart.yaml index e5f10da65..81e8d1e7c 100644 --- a/charts/openab/Chart.yaml +++ b/charts/openab/Chart.yaml @@ -2,5 +2,5 @@ apiVersion: v2 name: openab description: A lightweight, secure, cloud-native ACP harness that bridges Discord and any ACP-compatible coding CLI. type: application -version: 0.8.3-beta.7 -appVersion: "0.8.3-beta.7" +version: 0.8.3-beta.8 +appVersion: "0.8.3-beta.8" From 8d312a3c7611ab2768350460dc710c849b8dd1fb Mon Sep 17 00:00:00 2001 From: chaodufashi Date: Mon, 11 May 2026 18:49:14 -0400 Subject: [PATCH 022/100] docs(copilot): document setting auto as default model via settings.json (#799) Co-authored-by: chaodu-agent --- docs/copilot.md | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/docs/copilot.md b/docs/copilot.md index 0e73e58e1..6ad44316e 100644 --- a/docs/copilot.md +++ b/docs/copilot.md @@ -140,13 +140,20 @@ helm install openab-copilot openab/openab \ ## Model Selection -Copilot CLI defaults to Claude Sonnet 4.6. Other available models include: +The default model is defined in `~/.copilot/settings.json`. -- Claude Opus 4.6, Claude Haiku 4.5 (Anthropic) -- GPT-5.3-Codex (OpenAI) -- Gemini 3 Pro (Google) +To set `auto` as the default model, exec into the container and create the file: -Model selection is controlled by Copilot CLI itself (via `/model` in interactive mode). In ACP mode, the default model is used. +```bash +kubectl exec -it deployment/openab-copilot-copilot -- bash -c ' +cat << EOF > ~/.copilot/settings.json +{ + "model": "auto" +} +EOF' +``` + +The `auto` setting lets Copilot automatically select the best model for each request. This persists across pod restarts when `persistence.enabled=true` (the home directory is on a PVC). ## Known Limitations From 8416166f530f8a0b3dbea893789961ea73ff6138 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E8=AA=9E=E5=AB=A3?= Date: Wed, 13 May 2026 07:14:52 +0800 Subject: [PATCH 023/100] feat(gateway): support [[reply_to]] directive for gateway platforms (#783) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(gateway): support [[reply_to]] directive for gateway platforms - Add quote_message_id field to GatewayReply protocol - Override send_message_with_reply in GatewayAdapter (refactored via send_gateway_reply helper to eliminate duplication) - Feishu: use quote_message_id as reply target with fallback to plain send on failure - Update docs/feishu.md and docs/output-directives.md for Feishu support When an agent outputs [[reply_to:message_id]], the gateway sends the message as a native reply/quote on the platform. Feishu shows it with the quote reference UI via POST /im/v1/messages/{id}/reply. Tested: 274 core + 126 gateway tests pass. E2E verified on Feishu. * fix(gateway): prevent pending map leak and add chunked reply fallback - src/gateway.rs: clean up pending map entry before returning error on WebSocket send failure (prevents slow memory leak) - gateway/src/adapters/feishu.rs: retry chunked messages with thread_id when quote_message_id causes all chunks to fail (prevents silent message loss) - Improve fallback warn logs with structured fields for debugging * fix(gateway): improve doc comments, timeout logging, and extract constant - Add doc comments distinguishing reply_to (routing) from quote_message_id (visual reply UI) in both core and gateway schema - Split timeout catch-all into distinct arms with warn-level logging for failure, channel closed, and timeout cases - Extract 5s timeout to GATEWAY_REPLY_TIMEOUT_SECS constant * fix(gateway): address review findings on #783 - F1: Add comment explaining defensive pending.remove() calls - F2: Add 3 unit tests for quote_message_id priority logic (quote > thread_id > None) * fix: address review findings — remove redundant pending.remove, add fallback test - F1: Remove redundant pending.lock().await.remove(id) in Ok(Ok(_resp)) and Ok(Err(_)) branches of send_gateway_reply. The response handler (line 616) already removes the entry before sending on the oneshot channel. Only the timeout branch needs the remove. - F2: Add quote_message_id_fallback_on_reply_failure test verifying that send_post_message returns None for an invalid reply target and succeeds on retry without quote (the same pattern handle_reply uses). * fix: upgrade F2 test to exercise handle_reply fallback path directly Per 普渡法師 feedback: the previous test called send_post_message directly twice, which didn't cover the quote_message_id.is_some() guard in handle_reply. New test: - Adds api_base_override to FeishuConfig (always None in prod) - Constructs a full FeishuAdapter with mock server - Calls handle_reply with quote_message_id=Some("om_invalid") - wiremock expect(1) verifies both the reply API call AND the fallback plain send were triggered by handle_reply's guard logic --------- Co-authored-by: wangyuyan-agent <265828726+wangyuyan-agent@users.noreply.github.com> Co-authored-by: CHC-Agent Co-authored-by: chaodu-agent --- docs/feishu.md | 16 +++ docs/output-directives.md | 3 +- gateway/src/adapters/feishu.rs | 177 ++++++++++++++++++++++++++++- gateway/src/adapters/googlechat.rs | 8 ++ gateway/src/main.rs | 1 + gateway/src/schema.rs | 6 + src/gateway.rs | 139 ++++++++++++++-------- 7 files changed, 298 insertions(+), 52 deletions(-) diff --git a/docs/feishu.md b/docs/feishu.md index f1139d08e..816696871 100644 --- a/docs/feishu.md +++ b/docs/feishu.md @@ -200,6 +200,22 @@ To start a threaded conversation: reply to any bot message in a group chat (long Streaming (typewriter) mode works in threads — edits target the same message regardless of thread context. +## Agent-Controlled Reply-To + +Agents can reply to a specific message using the `[[reply_to:message_id]]` output directive (see [docs/output-directives.md](output-directives.md)). The gateway sends the reply via Feishu's native Reply API, showing a quote reference in the UI. + +``` +Agent output: + [[reply_to:om_xxx]] + This is my reply to that specific message. +``` + +**How agents get message IDs:** Every incoming message includes `message_id` in the `SenderContext` injected into the agent prompt. Agents can store and reference these IDs to reply to specific messages. + +**Fallback:** If the specified message ID is invalid or the Reply API fails, the gateway automatically falls back to a plain send (no quote). + +**Use case:** In multi-bot threads, each bot can reply to a different message, creating clear visual conversation threads within a Feishu thread. + ## Bot-to-Bot Collaboration (Gateway-Side Only) The gateway adapter includes bot identification and filtering scaffolding (`AllowBots` enum, `FEISHU_TRUSTED_BOT_IDS`, `FEISHU_MAX_BOT_TURNS` with human-reset safety valve), matching Discord's `allow_bot_messages` design. diff --git a/docs/output-directives.md b/docs/output-directives.md index 797116587..9b5876acf 100644 --- a/docs/output-directives.md +++ b/docs/output-directives.md @@ -35,6 +35,7 @@ Here is my reply to that specific message. **Behavior**: - Discord: sends with `message_reference`, showing the native "replying to..." UI +- Feishu: sends via Reply API (`POST /im/v1/messages/{id}/reply`), showing native quote UI - Invalid/non-existent message ID: silently falls back to plain send - Works in both streaming and send-once modes @@ -73,4 +74,4 @@ This creates clear visual conversation threads within a Discord thread — essen | Hermes Agent | `DISCORD_REPLY_TO_MODE` env var | ❌ Platform decides, always to trigger msg | | **OAB** | `[[reply_to:message_id]]` directive | ✅ Agent chooses any message | -> **Note:** `reply_to` is currently implemented for Discord only. Slack message IDs (ts format like `1234567890.123456`) are accepted by the parser but the Slack adapter does not yet send threaded replies via this directive — it falls back to plain send. Slack support can be added in a future PR. +> **Note:** `reply_to` is currently implemented for Discord and Feishu (gateway). Slack message IDs (ts format like `1234567890.123456`) are accepted by the parser but the Slack adapter does not yet send threaded replies via this directive — it falls back to plain send. Slack support can be added in a future PR. diff --git a/gateway/src/adapters/feishu.rs b/gateway/src/adapters/feishu.rs index 09e97fe0d..061777ccc 100644 --- a/gateway/src/adapters/feishu.rs +++ b/gateway/src/adapters/feishu.rs @@ -106,6 +106,9 @@ pub struct FeishuConfig { /// tracking entirely — all messages will require @mention. /// Converted from `FEISHU_SESSION_TTL_HOURS` (user-facing, in hours) to seconds internally. pub session_ttl_secs: u64, + /// Override the API base URL. Used in tests to point at a mock server. + /// Always None in production (not read from env). + pub api_base_override: Option, } impl FeishuConfig { @@ -192,11 +195,15 @@ impl FeishuConfig { dedupe_ttl_secs, message_limit, session_ttl_secs, + api_base_override: None, }) } /// API base URL for the configured domain. pub fn api_base(&self) -> String { + if let Some(ref base) = self.api_base_override { + return base.clone(); + } if self.domain == "lark" { "https://open.larksuite.com".into() } else { @@ -1904,6 +1911,9 @@ pub async fn handle_reply( let api_base = adapter.config.api_base(); let text = &reply.content.text; let limit = adapter.config.message_limit; + // quote_message_id (agent-controlled reply-to) takes priority over thread_id + let reply_target = reply.quote_message_id.as_deref() + .or(reply.channel.thread_id.as_deref()); let thread_id = reply.channel.thread_id.as_deref(); // Split long messages; store sent message_ids in dedupe to prevent @@ -1911,7 +1921,15 @@ pub async fn handle_reply( // Use post (rich text) format for markdown rendering. // When in a thread (thread_id present), use reply API to stay in the same thread. if text.len() <= limit { - match send_post_message(&adapter.client, &api_base, &token, &reply.channel.id, thread_id, text).await { + let result = send_post_message(&adapter.client, &api_base, &token, &reply.channel.id, reply_target, text).await; + // Fallback: if quote_message_id caused failure, retry without it + let result = if result.is_none() && reply.quote_message_id.is_some() { + tracing::warn!(quote_message_id = ?reply.quote_message_id, channel_id = %reply.channel.id, "reply-to failed, falling back to plain send"); + send_post_message(&adapter.client, &api_base, &token, &reply.channel.id, thread_id, text).await + } else { + result + }; + match result { Some(msg_id) => { adapter.dedupe.is_duplicate(&msg_id); // Record thread participation for mention bypass @@ -1953,11 +1971,21 @@ pub async fn handle_reply( } else { let mut sent_any = false; for chunk in split_text(text, limit) { - if let Some(msg_id) = send_post_message(&adapter.client, &api_base, &token, &reply.channel.id, thread_id, chunk).await { + if let Some(msg_id) = send_post_message(&adapter.client, &api_base, &token, &reply.channel.id, reply_target, chunk).await { adapter.dedupe.is_duplicate(&msg_id); sent_any = true; } } + // Fallback: if quote_message_id caused all chunks to fail, retry without it + if !sent_any && reply.quote_message_id.is_some() { + tracing::warn!(quote_message_id = ?reply.quote_message_id, channel_id = %reply.channel.id, "chunked reply-to failed, falling back to plain send"); + for chunk in split_text(text, limit) { + if let Some(msg_id) = send_post_message(&adapter.client, &api_base, &token, &reply.channel.id, thread_id, chunk).await { + adapter.dedupe.is_duplicate(&msg_id); + sent_any = true; + } + } + } if sent_any { if let Some(tid) = thread_id { record_participation(&adapter.participated_threads, tid, adapter.config.session_ttl_secs); @@ -2318,6 +2346,7 @@ mod tests { dedupe_ttl_secs: 300, message_limit: 4000, session_ttl_secs: 86400, + api_base_override: None, } } @@ -2942,4 +2971,148 @@ mod tests { // (caller would pass false because Mentions mode always returns false) assert!(parse_message_event(&env, Some("ou_bot"), &cfg, false).is_none()); } + + #[test] + fn quote_message_id_takes_priority_over_thread_id() { + use crate::schema::{GatewayReply, ReplyChannel, Content}; + let reply = GatewayReply { + schema: "openab.gateway.reply.v1".into(), + reply_to: "evt_123".into(), + platform: "feishu".into(), + channel: ReplyChannel { + id: "chat_123".into(), + thread_id: Some("om_root".into()), + }, + content: Content { + content_type: "text".into(), + text: "hello".into(), + attachments: vec![], + }, + command: None, + request_id: None, + quote_message_id: Some("om_specific".into()), + }; + // quote_message_id should take priority + let reply_target = reply.quote_message_id.as_deref() + .or(reply.channel.thread_id.as_deref()); + assert_eq!(reply_target, Some("om_specific")); + } + + #[test] + fn reply_target_falls_back_to_thread_id_when_no_quote() { + use crate::schema::{GatewayReply, ReplyChannel, Content}; + let reply = GatewayReply { + schema: "openab.gateway.reply.v1".into(), + reply_to: "evt_123".into(), + platform: "feishu".into(), + channel: ReplyChannel { + id: "chat_123".into(), + thread_id: Some("om_root".into()), + }, + content: Content { + content_type: "text".into(), + text: "hello".into(), + attachments: vec![], + }, + command: None, + request_id: None, + quote_message_id: None, + }; + let reply_target = reply.quote_message_id.as_deref() + .or(reply.channel.thread_id.as_deref()); + assert_eq!(reply_target, Some("om_root")); + } + + #[test] + fn reply_target_is_none_when_both_absent() { + use crate::schema::{GatewayReply, ReplyChannel, Content}; + let reply = GatewayReply { + schema: "openab.gateway.reply.v1".into(), + reply_to: "evt_123".into(), + platform: "feishu".into(), + channel: ReplyChannel { + id: "chat_123".into(), + thread_id: None, + }, + content: Content { + content_type: "text".into(), + text: "hello".into(), + attachments: vec![], + }, + command: None, + request_id: None, + quote_message_id: None, + }; + let reply_target = reply.quote_message_id.as_deref() + .or(reply.channel.thread_id.as_deref()); + assert_eq!(reply_target, None); + } + + #[tokio::test] + async fn quote_message_id_fallback_on_reply_failure() { + // Tests the actual handle_reply fallback path: when quote_message_id + // is set and the reply API fails, handle_reply retries as plain send. + let server = MockServer::start().await; + + // Token endpoint + Mock::given(method("POST")) + .and(path("/open-apis/auth/v3/tenant_access_token/internal")) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "code": 0, + "tenant_access_token": "t-test", + "expire": 7200 + }))) + .mount(&server) + .await; + + // Reply API returns 400 (invalid quote_message_id) + Mock::given(method("POST")) + .and(path("/open-apis/im/v1/messages/om_invalid/reply")) + .respond_with(ResponseTemplate::new(400).set_body_string("invalid message_id")) + .expect(1) + .named("reply_api_fail") + .mount(&server) + .await; + + // Plain send endpoint succeeds (fallback path) + Mock::given(method("POST")) + .and(path("/open-apis/im/v1/messages")) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "code": 0, + "data": {"message_id": "om_fallback_ok"} + }))) + .expect(1) + .named("plain_send_fallback") + .mount(&server) + .await; + + let mut config = test_config(); + config.api_base_override = Some(server.uri()); + let adapter = FeishuAdapter::new(config); + + let (event_tx, _rx) = tokio::sync::broadcast::channel(16); + + let reply = crate::schema::GatewayReply { + schema: "openab.gateway.reply.v1".into(), + reply_to: "evt_123".into(), + platform: "feishu".into(), + channel: crate::schema::ReplyChannel { + id: "oc_chat1".into(), + thread_id: None, + }, + content: crate::schema::Content { + content_type: "text".into(), + text: "hello from fallback test".into(), + attachments: vec![], + }, + command: None, + request_id: None, + quote_message_id: Some("om_invalid".into()), + }; + + handle_reply(&reply, &adapter, &event_tx).await; + // wiremock expect(1) on both mocks verifies: + // 1. Reply API was called (and failed) + // 2. Plain send was called (fallback triggered by quote_message_id.is_some() guard) + } } diff --git a/gateway/src/adapters/googlechat.rs b/gateway/src/adapters/googlechat.rs index 73787089a..20a884c3b 100644 --- a/gateway/src/adapters/googlechat.rs +++ b/gateway/src/adapters/googlechat.rs @@ -1375,6 +1375,7 @@ mod tests { }, command: None, request_id: Some("req_123".into()), + quote_message_id: None, }; adapter.handle_reply(&reply, &event_tx).await; @@ -1418,6 +1419,7 @@ mod tests { }, command: None, request_id: Some("req_fail".into()), + quote_message_id: None, }; adapter.handle_reply(&reply, &event_tx).await; @@ -1465,6 +1467,7 @@ mod tests { }, command: None, request_id: Some("req_empty".into()), + quote_message_id: None, }; adapter.handle_reply(&reply, &event_tx).await; @@ -1509,6 +1512,7 @@ mod tests { }, command: None, request_id: Some("req_multi_fail".into()), + quote_message_id: None, }; adapter.handle_reply(&reply, &event_tx).await; @@ -1543,6 +1547,7 @@ mod tests { }, command: None, request_id: Some("req_notoken".into()), + quote_message_id: None, }; adapter.handle_reply(&reply, &event_tx).await; @@ -1588,6 +1593,7 @@ mod tests { }, command: Some("edit_message".into()), request_id: None, + quote_message_id: None, }; adapter.handle_reply(&reply, &event_tx).await; @@ -1630,6 +1636,7 @@ mod tests { }, command: None, request_id: Some("req_multi".into()), + quote_message_id: None, }; adapter.handle_reply(&reply, &event_tx).await; @@ -1687,6 +1694,7 @@ mod tests { }, command: None, request_id: Some("req_partial".into()), + quote_message_id: None, }; adapter.handle_reply(&reply, &event_tx).await; diff --git a/gateway/src/main.rs b/gateway/src/main.rs index 3df4ab1a1..eaaf69997 100644 --- a/gateway/src/main.rs +++ b/gateway/src/main.rs @@ -427,6 +427,7 @@ mod tests { }, command: None, request_id: None, + quote_message_id: None, } } diff --git a/gateway/src/schema.rs b/gateway/src/schema.rs index a38554df4..560648a06 100644 --- a/gateway/src/schema.rs +++ b/gateway/src/schema.rs @@ -64,6 +64,12 @@ pub struct GatewayReply { pub command: Option, #[serde(default)] pub request_id: Option, + /// When set, send this message as a reply/quote to the specified platform message ID. + /// Unlike `reply_to` (which identifies the triggering event for routing/dedup), + /// this field controls the visual reply/quote UI on the platform. + /// If quoting fails, the gateway MUST fall back to sending without quoting. + #[serde(default)] + pub quote_message_id: Option, } #[derive(Clone, Debug, Serialize, Deserialize)] diff --git a/src/gateway.rs b/src/gateway.rs index 551e787a9..bd3254e85 100644 --- a/src/gateway.rs +++ b/src/gateway.rs @@ -10,6 +10,9 @@ use tokio::sync::Mutex; use tokio_tungstenite::tungstenite::Message; use tracing::{error, info, warn}; +/// Timeout for waiting on gateway reply acknowledgement. +const GATEWAY_REPLY_TIMEOUT_SECS: u64 = 5; + // --- Gateway event/reply schemas (mirrors gateway service) --- #[derive(Clone, Debug, Deserialize)] @@ -77,6 +80,11 @@ struct GatewayReply { command: Option, #[serde(skip_serializing_if = "Option::is_none")] request_id: Option, + /// When set, the gateway should send this message as a reply/quote to the specified message ID. + /// Unlike `reply_to` (routing/dedup identifier for the triggering event), this field controls + /// the visual reply/quote UI on the platform. Falls back to plain send on failure. + #[serde(skip_serializing_if = "Option::is_none")] + quote_message_id: Option, } #[derive(Serialize)] @@ -139,6 +147,74 @@ impl GatewayAdapter { streaming, } } + + /// Internal helper for send_message / send_message_with_reply. + async fn send_gateway_reply( + &self, + channel: &ChannelRef, + content: &str, + quote_message_id: Option<&str>, + ) -> Result { + let req_id = if self.streaming { + Some(format!("req_{}", uuid::Uuid::new_v4())) + } else { + None + }; + let pending_rx = if let Some(ref id) = req_id { + let (tx, rx) = tokio::sync::oneshot::channel(); + self.pending.lock().await.insert(id.clone(), tx); + Some(rx) + } else { + None + }; + let reply = GatewayReply { + schema: "openab.gateway.reply.v1".into(), + reply_to: channel.origin_event_id.clone().unwrap_or_default(), + platform: channel.platform.clone(), + channel: ReplyChannel { + id: channel.channel_id.clone(), + thread_id: channel.thread_id.clone(), + }, + content: ReplyContent { + content_type: "text".into(), + text: content.into(), + }, + command: None, + request_id: req_id.clone(), + quote_message_id: quote_message_id.map(|s| s.to_string()), + }; + let json = serde_json::to_string(&reply)?; + if let Err(e) = self.ws_tx.lock().await.send(Message::Text(json)).await { + if let Some(ref id) = req_id { + self.pending.lock().await.remove(id); + } + return Err(e.into()); + } + let msg_id = if let (Some(rx), Some(ref id)) = (pending_rx, &req_id) { + match tokio::time::timeout(std::time::Duration::from_secs(GATEWAY_REPLY_TIMEOUT_SECS), rx).await { + Ok(Ok(resp)) if resp.success => resp.message_id.unwrap_or_else(|| "gw_sent".into()), + Ok(Ok(_resp)) => { + tracing::warn!(request_id = %id, "gateway replied with failure"); + "gw_sent".into() + } + Ok(Err(_)) => { + tracing::warn!(request_id = %id, "gateway response channel closed"); + "gw_sent".into() + } + Err(_) => { + tracing::warn!(request_id = %id, "gateway reply timed out"); + self.pending.lock().await.remove(id); + "gw_sent".into() + } + } + } else { + "gw_sent".into() + }; + Ok(MessageRef { + channel: channel.clone(), + message_id: msg_id, + }) + } } /// Send a fire-and-forget reply via the shared WebSocket (no request-response). @@ -162,6 +238,7 @@ async fn send_fire_and_forget( }, command: None, request_id: None, + quote_message_id: None, }; let json = serde_json::to_string(&reply)?; ws_tx.lock().await.send(Message::Text(json)).await?; @@ -305,56 +382,16 @@ impl ChatAdapter for GatewayAdapter { } async fn send_message(&self, channel: &ChannelRef, content: &str) -> Result { - let req_id = if self.streaming { - Some(format!("req_{}", uuid::Uuid::new_v4())) - } else { - None - }; - - let pending_rx = if let Some(ref id) = req_id { - let (tx, rx) = tokio::sync::oneshot::channel(); - self.pending.lock().await.insert(id.clone(), tx); - Some(rx) - } else { - None - }; - - let reply = GatewayReply { - schema: "openab.gateway.reply.v1".into(), - reply_to: channel.origin_event_id.clone().unwrap_or_default(), - platform: channel.platform.clone(), - channel: ReplyChannel { - id: channel.channel_id.clone(), - thread_id: channel.thread_id.clone(), - }, - content: ReplyContent { - content_type: "text".into(), - text: content.into(), - }, - command: None, - request_id: req_id.clone(), - }; - let json = serde_json::to_string(&reply)?; - self.ws_tx.lock().await.send(Message::Text(json)).await?; - - // When streaming is enabled, wait for gateway to return real message_id - // (needed for edit_message). Otherwise fire-and-forget. - let msg_id = if let (Some(rx), Some(ref id)) = (pending_rx, &req_id) { - match tokio::time::timeout(std::time::Duration::from_secs(5), rx).await { - Ok(Ok(resp)) if resp.success => resp.message_id.unwrap_or_else(|| "gw_sent".into()), - _ => { - self.pending.lock().await.remove(id); - "gw_sent".into() - } - } - } else { - "gw_sent".into() - }; + self.send_gateway_reply(channel, content, None).await + } - Ok(MessageRef { - channel: channel.clone(), - message_id: msg_id, - }) + async fn send_message_with_reply( + &self, + channel: &ChannelRef, + content: &str, + reply_to_message_id: &str, + ) -> Result { + self.send_gateway_reply(channel, content, Some(reply_to_message_id)).await } async fn create_thread( @@ -382,6 +419,7 @@ impl ChatAdapter for GatewayAdapter { }, command: Some("create_topic".into()), request_id: Some(req_id.clone()), + quote_message_id: None, }; let json = serde_json::to_string(&reply)?; self.ws_tx.lock().await.send(Message::Text(json)).await?; @@ -421,6 +459,7 @@ impl ChatAdapter for GatewayAdapter { text: emoji.into(), }, command: Some("add_reaction".into()), + quote_message_id: None, request_id: None, }; let json = serde_json::to_string(&reply)?; @@ -442,6 +481,7 @@ impl ChatAdapter for GatewayAdapter { text: emoji.into(), }, command: Some("remove_reaction".into()), + quote_message_id: None, request_id: None, }; let json = serde_json::to_string(&reply)?; @@ -463,6 +503,7 @@ impl ChatAdapter for GatewayAdapter { text: content.into(), }, command: Some("edit_message".into()), + quote_message_id: None, request_id: None, }; let json = serde_json::to_string(&reply)?; From 1a5a9987e61c16259fa2530a4e27d7a2738082c5 Mon Sep 17 00:00:00 2001 From: Can Date: Wed, 13 May 2026 07:28:24 +0800 Subject: [PATCH 024/100] =?UTF-8?q?feat(gateway):=20add=20WeCom=20(?= =?UTF-8?q?=E4=BC=81=E4=B8=9A=E5=BE=AE=E4=BF=A1)=20channel=20adapter=20(#7?= =?UTF-8?q?69)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(gateway): add WeCom (企业微信) channel adapter Implement WeCom as a new gateway platform for receiving and sending messages via enterprise app callback API. Features: - AES-256-CBC message decryption (WeCom uses PKCS7 block_size=32) - SHA1 signature verification with constant-time comparison - Access token cache with auto-refresh and expiry margin - Message deduplication (30s TTL, 10k max entries) - Long message splitting at line boundaries (2048 byte limit) - GatewayResponse with message_id for OAB core integration Env vars: WECOM_CORP_ID, WECOM_SECRET, WECOM_TOKEN, WECOM_ENCODING_AES_KEY, WECOM_AGENT_ID, WECOM_WEBHOOK_PATH Note: Streaming (edit_message) is intentionally disabled — WeCom has no native message edit API; recall+resend shows disruptive notifications. Uses plain text msgtype since WeCom app message markdown is too limited (no code blocks, tables, or lists). Co-Authored-By: Claude Opus 4.6 * docs(wecom): add setup guide and update READMEs Add comprehensive WeCom setup documentation covering: - Prerequisites and enterprise app creation - Callback URL configuration - Environment variables reference - Docker/Kubernetes deployment - Troubleshooting guide Co-Authored-By: Claude Opus 4.6 * feat(wecom): re-enable streaming with recall+resend and msg_id tracking Add handle_edit_message and recall_message methods to support OAB streaming. When OAB sends edit_message commands, the adapter recalls the previous message and re-sends updated content, tracking message ID changes via msg_id_map to handle the WeCom limitation of no native edit API. Co-Authored-By: Claude Opus 4.6 * feat(wecom): add image receiving support Download images from WeCom PicUrl, resize/compress via image crate, and forward as base64 attachment in gateway event. Also fix upstream googlechat test compilation (missing attachments field). Co-Authored-By: Claude Opus 4.6 * feat(wecom): replace recall+resend streaming with thinking placeholder + debounce flush Instead of recalling and resending on every streaming update, send a single "⏳..." placeholder and buffer all edits. After 3 seconds of inactivity, recall the placeholder once and send the complete response. Co-Authored-By: Claude Opus 4.6 * feat(wecom): add text file receiving support Download files via WeCom media API and forward text-based files (code, config, data files) as text_file attachments to OAB. Also fix split_text tests to match renamed split_text_lines function. Co-Authored-By: Claude Opus 4.6 * fix(wecom): address PR review feedback - Add 5-minute max timeout to debounce flush task to prevent leaks - Make WECOM_AGENT_ID required (was defaulting to "0") - Align Helm chart: require token + encodingAesKey in $hasWecom condition - Fix README: mark WeCom env vars as required - Update docs/wecom.md feature matrix to reflect implemented features - Add WeCom vars to "no adapters configured" warning message Co-Authored-By: Claude Opus 4.6 * fix(wecom): split long lines at char boundaries and add flush debug logging split_text_lines now handles single lines exceeding the limit by splitting at UTF-8 char boundaries. Previously long lines were sent as-is, causing WeCom to silently truncate messages. Also add detailed logging to flush_thinking for easier debugging. Co-Authored-By: Claude Opus 4.6 * fix(wecom): address Copilot review feedback (round 2) - Validate PKCS#7 padding bytes match before stripping - Validate WECOM_AGENT_ID is numeric at startup (fail-fast) - Gate group messages: drop when no @mention present (group_require_mention) - Add agentId to Helm $hasWecom condition (required field) - Fix docs: "2048 chars" → "2048 bytes" Co-Authored-By: Claude Opus 4.6 * docs(wecom): simplify deployment section and add group verification note - Remove standalone K8s manifest (Helm chart is the canonical way) - Keep docker run as env var quick-start example (matches other adapters) - Add note: group chat requires enterprise real-name verification Co-Authored-By: Claude Opus 4.6 * fix(wecom): address reviewer feedback on group support and config validation - Remove WECOM_GROUP_REQUIRE_MENTION and strip_bot_mention. WeCom self-built app callbacks only deliver 1:1 DMs; the previous default of true silently dropped all DM messages without an @-prefix. Group chat support requires the appchat API and is deferred. - Validate decode_aes_key output is 32 bytes to prevent panics on malformed base64 input. - Add Default derive to Content and Attachment so future schema extensions don't force every adapter test fixture to update. - Update docs to declare group chat as not supported. - Wire gateway/** into CI: add cargo check/clippy/test job for the gateway crate so PRs touching gateway/ run the existing 22 tests. Co-Authored-By: Claude Opus 4.7 (1M context) * ci(gateway): drop clippy from gateway CI job cargo clippy -- -D warnings surfaces ~14 pre-existing warnings across feishu.rs and googlechat.rs (collapsible_if, dead_code, manual_strip, needless_range_loop, too_many_arguments). Fixing those is unrelated to this PR's scope; track separately. Keep cargo check + cargo test, which covers the 22 wecom unit tests reviewers asked us to wire up. Co-Authored-By: Claude Opus 4.7 (1M context) * feat(wecom): make streaming opt-in and debounce configurable Per chaodu-agent's review, the recall+resend streaming pattern causes a brief client flicker on WeCom (recall toast + new-message notification). Default the placeholder/recall path off and let operators opt in with WECOM_STREAMING_ENABLED=true once they've understood the tradeoff. With streaming disabled, chunks are still buffered via the same debounce channel, so the agent transparently sees one consolidated final reply without any UI artifact. Also expose the debounce quiet-period as WECOM_DEBOUNCE_SECS so 1-1.5s deployments can reduce perceived latency without forking the code. flush_thinking() now takes Option<&str> for thinking_msg_id to skip the recall API call when no placeholder was sent. Co-Authored-By: Claude Opus 4.7 (1M context) * docs(wecom): explain decode_aes_key base64 config Per wangyuyan-agent round 1 NIT: clarify why decode_aes_key uses Indifferent padding mode + allow_trailing_bits. WeCom's EncodingAESKey is a 43-char base64 string (not 44 with trailing =) whose 43rd char carries 2 unused bits. The default base64 decoder rejects this; we relax both knobs so we can decode the spec-compliant input as-is. Co-Authored-By: Claude Opus 4.7 (1M context) * fix(wecom): unify token-expiry retry across send paths Per chaodu-agent's latest review (F1, blocking): flush_thinking() fetched a token via get_token() and POSTed directly without checking errcode 42001 or retrying. A long streaming session whose cached token expired mid-flight would silently lose its accumulated reply — the recall would fail, the final chunk would never reach the user. Extract the retry-on-token-expiry pattern into post_with_token_retry() so send_text and flush_thinking share one code path. Both recall and final-chunk send in flush_thinking now go through the helper. Also (F3, NIT): the envelope ToUserName field was parsed but never read, so it carried #[allow(dead_code)]. Use it to validate that inbound callbacks are actually addressed to our configured Corp ID before crypto runs — surfaces misrouting earlier and removes the dead-code allow. Co-Authored-By: Claude Opus 4.7 (1M context) * fix(wecom): close chaodu's defense-in-depth gaps (F1-F4) F1: Streaming race — debounce task could miss late writes that arrive between the timeout firing and pending.remove(). Now the task acquires the pending lock first, captures any final rx.borrow().clone() while holding the lock (which blocks handle_reply from sending more chunks), then removes the entry. F2: Replay protection — reject callbacks whose timestamp is more than 5 minutes off from now. WeCom's signature doesn't bind freshness, so without this an attacker who captured a signed payload could replay it indefinitely after the 30s dedup window. F3: SSRF defense-in-depth — only fetch pic_url over HTTPS. WeCom's CDN is HTTPS; rejecting non-HTTPS prevents attacks if the AES key leaks and an attacker forges callbacks pointing at internal hosts. F4: Helm gap — expose WECOM_STREAMING_ENABLED and WECOM_DEBOUNCE_SECS in values.yaml + gateway.yaml so deployers don't need extraEnv. Co-Authored-By: Claude Opus 4.7 (1M context) * fix(wecom): close TOCTOU and add token retry to file download Per chaodu-agent's 4th re-review: F1: download_wecom_file used a token directly without retry. If the cached token expired between get_token() and the GET, the file silently failed. Added fetch_media_with_retry() which sniffs the response Content-Type — WeCom's media API returns JSON {errcode:42001,...} on token expiry instead of binary — and retries once after a forced refresh. download_wecom_file now takes &WecomTokenCache and runs the retry helper itself. F2: TOCTOU in handle_reply's has_pending branch. The first has_pending read happens under a lock that's then released; by the time we re-take the lock to append, the debounce task may have removed the entry, and we'd silently drop the chunk. Now: re-check inside the second lock and, if the entry is gone, fall through to the direct-send path so the chunk still reaches the user. Co-Authored-By: Claude Opus 4.7 (1M context) * fix(wecom): address chaodu round-5 NITs (F2, F3, F4) F2 (debounceSecs:0 silently ignored): document the minimum is 1 in docs/wecom.md and values.yaml. The Helm template's truthy check treats 0 as unset; since 0-second debounce defeats the buffer purpose anyway, documenting the floor is more honest than reshaping the truthy check to accept a value with no real use case. F3 (env::set_var in tests is parallel-unsafe): refactor from_env to delegate to from_reader, which takes a closure. Tests now build a HashMap-backed reader and never touch process-wide env vars, so cargo's parallel runner can't race them. Also fixes the wecom collapsible_match clippy warning while we're in there (XML parser nested if-in-match collapsed to match guards). F4 (no rate-limiting docs): added a "Production Hardening" section explaining that the timestamp-freshness check rejects stale replays cheaply but fresh-but-invalid requests still consume CPU, and pointing to edge / LB / reverse-proxy layer for IP-level rate limits, plus the WeCom Trusted IP allowlist as the strongest control. F1 (clippy CI gap) and F5 (mutex poison logging) intentionally not addressed — see the follow-up reply for rationale. Co-Authored-By: Claude Opus 4.7 (1M context) * ci(gateway): enable strict clippy and clear pre-existing warnings Restores the strict-clippy parity that the root crate has and that reviewers (chaodu round 4 and 5) flagged as a CI consistency gap. The mechanical fixes don't change behavior: - dead_code on serde-deserialize fields → #[allow(dead_code)] with fact-only "parsed by serde, not consumed in current code paths" (no speculation about future intent) - needless_range_loop in markdown rendering → buf.extend(slice.iter()) - manual_strip in fenced code block detection → strip_prefix - useless_conversion on tungstenite Message::Binary → drop the .into() - too_many_arguments on ws_connect_loop / handle_ws_message → #[allow(clippy::too_many_arguments)]; refactoring 11-arg async fn signatures is a larger change that doesn't belong here Any further warnings exposed by this strict job will be visible in the CI run and addressed in a follow-up. Co-Authored-By: Claude Opus 4.7 (1M context) * fix(gateway): clear remaining clippy warnings exposed by strict CI After merging upstream main and enabling strict clippy on the gateway job, 5 more warnings surfaced: - feishu.rs: collapsible_if — flatten outer + !(in_thread && bypass_mention_gating) into one if condition - feishu.rs: nonminimal_bool — !is_some_and(<) → is_none_or(>=) - wecom.rs: too_many_arguments on flush_thinking (8 args) → #[allow(clippy::too_many_arguments)] - wecom.rs: useless_vec on parts vec → use 4-element array, sort_unstable - main.rs: explicit_auto_deref on &*text → &text Co-Authored-By: Claude Opus 4.7 (1M context) * docs(wecom): address chaodu round-6 NITs (F1, F2 comments + F3/F4 known limits) F1 (corpsecret in URL): WeCom's gettoken API requires the secret as a query param; we cannot move it to a header. Added a comment in code clarifying the protocol constraint and a "Redact corpsecret from access logs" section in docs/wecom.md instructing operators to redact query strings at the proxy layer for /cgi-bin/gettoken outbound calls. F2 (byte-vs-char comment): added a doc comment to split_text_lines making explicit that the limit and all len() comparisons are in bytes (matching WeCom's server-side truncation), and that lines exceeding the limit are split at UTF-8 char boundaries. F3 (streaming task lifetime on shutdown): documented as known limitation. The fix would add a JoinSet/CancellationToken on the adapter; non-trivial scope, and impact is bounded since streaming defaults off. Recorded in the new "Known limitations" docs section. F4 (DedupeCache eviction is lazy): unchanged, documented under known limitations. ~500 KB max memory bound is acceptable; correctness (dedup window honored) is unaffected. Repeated finding from Copilot/chaodu earlier rounds; canyugs's prior "won't fix" rationale still applies. Co-Authored-By: Claude Opus 4.7 (1M context) --------- Co-authored-by: Claude Opus 4.6 --- .github/workflows/ci.yml | 26 + README.md | 7 + charts/openab/templates/gateway-secret.yaml | 8 +- charts/openab/templates/gateway.yaml | 34 + charts/openab/values.yaml | 11 + docs/wecom.md | 187 +++ gateway/Cargo.lock | 13 +- gateway/Cargo.toml | 2 + gateway/README.md | 14 + gateway/src/adapters/feishu.rs | 46 +- gateway/src/adapters/googlechat.rs | 2 + gateway/src/adapters/mod.rs | 1 + gateway/src/adapters/wecom.rs | 1654 +++++++++++++++++++ gateway/src/main.rs | 26 +- gateway/src/schema.rs | 4 +- 15 files changed, 2009 insertions(+), 26 deletions(-) create mode 100644 docs/wecom.md create mode 100644 gateway/src/adapters/wecom.rs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4239edd95..b2c13804a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -4,6 +4,7 @@ on: pull_request: paths: - "src/**" + - "gateway/**" - "Cargo.toml" - "Cargo.lock" - "Dockerfile*" @@ -31,3 +32,28 @@ jobs: - name: cargo test run: cargo test + + gateway: + runs-on: ubuntu-latest + defaults: + run: + working-directory: gateway + steps: + - uses: actions/checkout@v6 + + - uses: dtolnay/rust-toolchain@stable + with: + components: clippy + + - uses: Swatinem/rust-cache@v2 + with: + workspaces: gateway + + - name: cargo check + run: cargo check + + - name: cargo clippy + run: cargo clippy -- -D warnings + + - name: cargo test + run: cargo test diff --git a/README.md b/README.md index 58f59e61a..b7110cde6 100644 --- a/README.md +++ b/README.md @@ -112,6 +112,13 @@ See [docs/google-chat.md](docs/google-chat.md) for the full setup guide. Require +
+WeCom (企业微信) (via Custom Gateway) + +See [docs/wecom.md](docs/wecom.md) for the full setup guide. Requires the standalone [Custom Gateway](gateway/) service. + +
+ ### 2. Install with Helm (Kiro CLI — default) ```bash diff --git a/charts/openab/templates/gateway-secret.yaml b/charts/openab/templates/gateway-secret.yaml index 7d4869bdc..3c2c05d00 100644 --- a/charts/openab/templates/gateway-secret.yaml +++ b/charts/openab/templates/gateway-secret.yaml @@ -8,7 +8,8 @@ {{- $hasTelegram := (($cfg.gateway).telegram).botToken }} {{- $hasLine := (($cfg.gateway).line).channelSecret }} {{- $hasGoogleChat := or (($cfg.gateway).googleChat).saKeyJson (($cfg.gateway).googleChat).accessToken }} -{{- if or $hasTeams $hasFeishu $hasTelegram $hasLine $hasGoogleChat }} +{{- $hasWecom := and (($cfg.gateway).wecom).corpId (($cfg.gateway).wecom).agentId (($cfg.gateway).wecom).secret (($cfg.gateway).wecom).token (($cfg.gateway).wecom).encodingAesKey }} +{{- if or $hasTeams $hasFeishu $hasTelegram $hasLine $hasGoogleChat $hasWecom }} --- apiVersion: v1 kind: Secret @@ -52,6 +53,11 @@ data: google-chat-access-token: {{ ($cfg.gateway).googleChat.accessToken | b64enc | quote }} {{- end }} {{- end }} + {{- if $hasWecom }} + wecom-secret: {{ ($cfg.gateway).wecom.secret | b64enc | quote }} + wecom-token: {{ ($cfg.gateway).wecom.token | b64enc | quote }} + wecom-encoding-aes-key: {{ ($cfg.gateway).wecom.encodingAesKey | b64enc | quote }} + {{- end }} {{- end }} {{- end }} {{- end }} diff --git a/charts/openab/templates/gateway.yaml b/charts/openab/templates/gateway.yaml index 057937dcf..2a89dc79a 100644 --- a/charts/openab/templates/gateway.yaml +++ b/charts/openab/templates/gateway.yaml @@ -184,6 +184,40 @@ spec: value: {{ ($cfg.gateway).googleChat.webhookPath | quote }} {{- end }} {{- end }} + {{- $hasWecom := and (($cfg.gateway).wecom).corpId (($cfg.gateway).wecom).agentId (($cfg.gateway).wecom).secret (($cfg.gateway).wecom).token (($cfg.gateway).wecom).encodingAesKey }} + {{- if $hasWecom }} + - name: WECOM_CORP_ID + value: {{ ($cfg.gateway).wecom.corpId | quote }} + - name: WECOM_AGENT_ID + value: {{ ($cfg.gateway).wecom.agentId | quote }} + - name: WECOM_SECRET + valueFrom: + secretKeyRef: + name: {{ include "openab.agentFullname" $d }} + key: wecom-secret + - name: WECOM_TOKEN + valueFrom: + secretKeyRef: + name: {{ include "openab.agentFullname" $d }} + key: wecom-token + - name: WECOM_ENCODING_AES_KEY + valueFrom: + secretKeyRef: + name: {{ include "openab.agentFullname" $d }} + key: wecom-encoding-aes-key + {{- if (($cfg.gateway).wecom).webhookPath }} + - name: WECOM_WEBHOOK_PATH + value: {{ ($cfg.gateway).wecom.webhookPath | quote }} + {{- end }} + {{- if (($cfg.gateway).wecom).streamingEnabled }} + - name: WECOM_STREAMING_ENABLED + value: {{ ($cfg.gateway).wecom.streamingEnabled | quote }} + {{- end }} + {{- if (($cfg.gateway).wecom).debounceSecs }} + - name: WECOM_DEBOUNCE_SECS + value: {{ ($cfg.gateway).wecom.debounceSecs | quote }} + {{- end }} + {{- end }} - name: RUST_LOG value: {{ ($cfg.gateway).rustLog | default "info" | quote }} livenessProbe: diff --git a/charts/openab/values.yaml b/charts/openab/values.yaml index 8a83e963a..50b659159 100644 --- a/charts/openab/values.yaml +++ b/charts/openab/values.yaml @@ -309,6 +309,17 @@ agents: saKeyJson: "" # Service account key JSON string → GOOGLE_CHAT_SA_KEY_JSON (recommended, auto-refresh) accessToken: "" # Static OAuth2 access token → GOOGLE_CHAT_ACCESS_TOKEN (fallback, 1-hour TTL) webhookPath: "" # Gateway default: /webhook/googlechat → GOOGLE_CHAT_WEBHOOK_PATH + # WeCom (企业微信) adapter config (gateway-side env vars) + # See docs/wecom.md for full setup guide + wecom: + corpId: "" # Enterprise Corp ID → WECOM_CORP_ID + secret: "" # App Secret → WECOM_SECRET (use --set-literal or external secret mgmt) + token: "" # Callback verification token → WECOM_TOKEN + encodingAesKey: "" # 43-char AES key → WECOM_ENCODING_AES_KEY + agentId: "" # Agent ID → WECOM_AGENT_ID (required) + webhookPath: "" # Gateway default: /webhook/wecom → WECOM_WEBHOOK_PATH + streamingEnabled: "" # Enable thinking-placeholder + recall streaming (causes brief client flicker) → WECOM_STREAMING_ENABLED. Default off. + debounceSecs: "" # Debounce quiet-period seconds before flushing streamed text → WECOM_DEBOUNCE_SECS. Default 3, minimum 1 (0 is treated as unset by Helm). # Scheduled messages — config-driven cron (ADR: basic-cronjob) # Each entry sends a message to the agent at the specified schedule. # Example: diff --git a/docs/wecom.md b/docs/wecom.md new file mode 100644 index 000000000..ad8efec8f --- /dev/null +++ b/docs/wecom.md @@ -0,0 +1,187 @@ +# WeCom (企业微信) Setup + +Connect a WeCom (Enterprise WeChat) bot to OpenAB via the Custom Gateway. + +``` +WeCom ──POST──▶ Gateway (:8080) ◀──WebSocket── OAB Pod + (OAB connects out) +``` + +## Prerequisites + +- A running OAB instance (with any ACP agent authenticated) +- The Custom Gateway deployed ([gateway/README.md](../gateway/README.md)) +- A WeCom enterprise account with admin access + +## 1. Create a WeCom App + +1. Log in to [WeCom Admin Console](https://work.weixin.qq.com/wework_admin/frame) +2. Go to **应用管理** (App Management) → **自建** (Self-built) → **创建应用** (Create App) +3. Fill in the app name and description, select visible scope +4. After creation, note down: + - **AgentId** — on the app detail page + - **Secret** — click to view/copy on the app detail page +5. Go to **我的企业** (My Enterprise) → copy the **企业ID** (Corp ID) + +## 2. Configure the Callback URL + +1. In the app detail page, scroll to **接收消息** (Receive Messages) +2. Click **设置API接收** (Set API Receive) +3. Fill in: + - **URL**: `https://your-gateway-host/webhook/wecom` (must be HTTPS) + - **Token**: click "随机获取" (Random Generate) or set your own + - **EncodingAESKey**: click "随机获取" (Random Generate) or set your own +4. **Do NOT click Save yet** — you need the gateway running first to verify the URL + +## 3. Configure the Gateway + +Set the following environment variables: + +| Variable | Required | Description | +|---|---|---| +| `WECOM_CORP_ID` | Yes | Enterprise Corp ID (from My Enterprise page) | +| `WECOM_AGENT_ID` | Yes | App Agent ID | +| `WECOM_SECRET` | Yes | App Secret | +| `WECOM_TOKEN` | Yes | Callback Token (from step 2) | +| `WECOM_ENCODING_AES_KEY` | Yes | Callback EncodingAESKey (43 characters) | +| `WECOM_WEBHOOK_PATH` | No | Webhook path (default: `/webhook/wecom`) | +| `WECOM_STREAMING_ENABLED` | No | Stream replies via "thinking" placeholder + recall + resend (default: `false`). WeCom has no edit-message API; enabling this causes a brief client flicker during streaming. | +| `WECOM_DEBOUNCE_SECS` | No | Quiet-period seconds before flushing buffered streamed text (default: `3`, minimum: `1` — `0` is silently ignored by Helm's truthy check and disables the buffer purpose) | + +```bash +docker run -d --name openab-gateway \ + -e WECOM_CORP_ID="ww1234567890abcdef" \ + -e WECOM_AGENT_ID="1000002" \ + -e WECOM_SECRET="your-app-secret" \ + -e WECOM_TOKEN="your-callback-token" \ + -e WECOM_ENCODING_AES_KEY="your-43-char-encoding-aes-key" \ + -p 8080:8080 \ + ghcr.io/openabdev/openab-gateway:latest +``` + +For Kubernetes with Helm, see [`charts/openab/values.yaml`](../charts/openab/values.yaml) — set values under `agents..gateway.wecom`. + +## 4. Verify the Callback URL + +Once the gateway is running with the correct env vars: + +1. Go back to the WeCom Admin Console → App → 接收消息 → 设置API接收 +2. Click **保存** (Save) +3. WeCom will send a verification request to your URL — if the gateway decrypts and responds correctly, you'll see "保存成功" (Save Successful) + +If verification fails: +- Check that the gateway is reachable over HTTPS +- Verify `WECOM_TOKEN` and `WECOM_ENCODING_AES_KEY` match exactly what's shown in the WeCom console +- Check gateway logs for errors + +## 5. Configure OAB + +```toml +[gateway] +url = "ws://openab-gateway:8080/ws" +platform = "wecom" +allow_all_channels = true +allow_all_users = true + +[agent] +command = "claude-agent-acp" +args = [] +working_dir = "/home/node" +env = { CLAUDE_CODE_OAUTH_TOKEN = "${OPENAB_AUTH_TOKEN}" } + +[pool] +max_sessions = 10 +``` + +| Key | Required | Description | +|---|---|---| +| `url` | Yes | WebSocket URL of the gateway | +| `platform` | No | Session key namespace (default: `wecom`) | +| `allow_all_channels` | No | Allow messages from all channels (default: `false`) | +| `allow_all_users` | No | Allow messages from all users (default: `false`) | + +## 6. Expose the Gateway (HTTPS) + +WeCom requires a publicly accessible HTTPS URL for callbacks. + +### Option A: Zeabur (one-click HTTPS for quick testing) + +Deploy the gateway to [Zeabur](https://zeabur.com) — HTTPS is automatically provisioned. + +### Option B: Cloudflare Tunnel + +```bash +cloudflared tunnel --url http://localhost:8080 +``` + +### Option C: Reverse proxy (production) + +Use nginx, Caddy, or a cloud load balancer with TLS termination pointing to the gateway's `:8080`. + +## 7. Set Trusted IP (Optional) + +For production, restrict the callback to WeCom's IP ranges: + +1. In the WeCom Admin Console → App → **企业可信IP** (Trusted IP) +2. Add your gateway's public IP + +## Usage + +Send a direct message to the bot in the WeCom mobile or desktop app: + +``` +你好,帮我解释一下这段代码 +``` + +The bot will reply directly in the same conversation. + +> **Note on group chats:** WeCom self-built enterprise apps only deliver **1:1 direct messages** to the callback URL. Group chat messages are not forwarded by this API path; group chat support would require the `appchat` API (not yet implemented). For group chat use cases, see the WeCom AI Bot WebSocket API as a future adapter. + +## Features + +| Feature | Status | +|---|---| +| Direct message (1:1) | ✅ | +| Text message receive/reply | ✅ | +| AES-256-CBC message decryption | ✅ | +| Message deduplication | ✅ | +| Auto-split long replies (2048 bytes) | ✅ | +| Access token auto-refresh | ✅ | +| Image receive | ✅ | +| Text file receive | ✅ | +| Streaming replies (thinking placeholder + debounce flush) | ✅ | +| Group chat | ❌ Not supported (callback API limitation) | +| Voice/video messages | Planned | +| Markdown card replies | Planned | + +## Production Hardening + +The gateway does no application-level rate limiting on `/webhook/wecom`. Each request triggers an XML envelope parse, a SHA1 signature computation, and (if signature passes) AES-256-CBC decryption. A 5-minute timestamp freshness check rejects stale callbacks before any crypto runs, so old replays are cheap to drop, but fresh-but-invalid requests still consume CPU. + +Run the gateway behind a reverse proxy or load balancer that enforces rate limits at the IP / connection level: + +| Layer | Example | +|---|---| +| Edge / CDN | Cloudflare WAF rate limiting rules on `/webhook/wecom` | +| Cloud LB | AWS ALB rate-based rules, GCP Cloud Armor | +| Reverse proxy | nginx `limit_req_zone`, Caddy `rate_limit` directive | + +In addition, restrict the callback URL to WeCom's published IP ranges via the **企业可信IP** (Trusted IP) list in the WeCom Admin Console. This is the most effective control because all legitimate callbacks originate from those ranges. + +### Redact `corpsecret` from access logs + +WeCom's `gettoken` API mandates `corpsecret` as a query parameter (the protocol does not support a header alternative). The gateway itself does not log this URL, but if the gateway sits behind a reverse proxy with default access logging enabled, the secret will appear in access logs. Configure the proxy to redact query strings on `/cgi-bin/gettoken` outbound calls (or sanitize at log-shipping time). + +### Known limitations + +- **Streaming task lifetime on shutdown** — the optional streaming mode (`WECOM_STREAMING_ENABLED=true`) spawns one debounce task per in-flight reply. On SIGTERM these tasks are dropped by the tokio runtime; any text buffered but not yet flushed is lost. The agent will typically re-emit on the next interaction. If you need flush-on-shutdown semantics, keep streaming off (default) so each reply is sent synchronously. +- **DedupeCache eviction is lazy** — entries are TTL-checked on lookup and bulk-evicted only when the cache reaches `DEDUPE_MAX_SIZE` (10K). For low-traffic deployments the HashMap can sit just below the cap with stale entries; max memory is bounded (~500 KB) and the dedup window itself is honored, so this does not affect correctness. + +## Troubleshooting + +| Symptom | Cause | Fix | +|---|---|---| +| Callback verification fails | Token/EncodingAESKey mismatch | Double-check values match WeCom console exactly | +| Bot receives but doesn't reply | Agent auth token not configured | Set `env = { CLAUDE_CODE_OAUTH_TOKEN = "${OPENAB_AUTH_TOKEN}" }` in OAB config | +| Intermittent "no response" | WeCom disabled callback after errors | Re-save callback config in WeCom console to re-verify | +| "IP not in whitelist" on reply | Trusted IP not set | Add gateway IP to app's trusted IP list, or leave it empty for dev | diff --git a/gateway/Cargo.lock b/gateway/Cargo.lock index b0fa728bb..b0e24b921 100644 --- a/gateway/Cargo.lock +++ b/gateway/Cargo.lock @@ -1112,7 +1112,7 @@ checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" [[package]] name = "openab-gateway" -version = "0.1.0" +version = "0.4.0" dependencies = [ "aes", "anyhow", @@ -1125,9 +1125,11 @@ dependencies = [ "image", "jsonwebtoken", "prost", + "quick-xml", "reqwest", "serde", "serde_json", + "sha1", "sha2", "subtle", "tokio", @@ -1274,6 +1276,15 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3" +[[package]] +name = "quick-xml" +version = "0.37.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "331e97a1af0bf59823e6eadffe373d7b27f485be8748f71471c662c1f269b7fb" +dependencies = [ + "memchr", +] + [[package]] name = "quinn" version = "0.11.9" diff --git a/gateway/Cargo.toml b/gateway/Cargo.toml index 76746e0bc..eed46efb7 100644 --- a/gateway/Cargo.toml +++ b/gateway/Cargo.toml @@ -24,6 +24,8 @@ aes = "0.8" cbc = "0.1" prost = "0.13" subtle = "2" +sha1 = "0.10" +quick-xml = "0.37" image = { version = "0.25", default-features = false, features = ["jpeg", "png", "gif", "webp"] } [dev-dependencies] diff --git a/gateway/README.md b/gateway/README.md index aa36cbf6a..79c492a28 100644 --- a/gateway/README.md +++ b/gateway/README.md @@ -67,6 +67,14 @@ url = "ws://gateway:8080/ws" | `GOOGLE_CHAT_SA_KEY_FILE` | (optional) | Path to service account key JSON file (alternative to `SA_KEY_JSON`) | | `GOOGLE_CHAT_ACCESS_TOKEN` | (optional) | Static OAuth2 access token (fallback, expires in 1 hour) | | `GOOGLE_CHAT_WEBHOOK_PATH` | `/webhook/googlechat` | Webhook endpoint path | +| `WECOM_CORP_ID` | (required*) | WeCom Corp ID — enables wecom adapter | +| `WECOM_AGENT_ID` | (required*) | WeCom App Agent ID | +| `WECOM_SECRET` | (required*) | WeCom App Secret | +| `WECOM_TOKEN` | (required*) | Callback verification Token | +| `WECOM_ENCODING_AES_KEY` | (required*) | Callback EncodingAESKey (43 chars) | +| `WECOM_WEBHOOK_PATH` | `/webhook/wecom` | Webhook endpoint path | +| `WECOM_STREAMING_ENABLED` | `false` | Enable thinking-placeholder + recall streaming (causes brief client flicker) | +| `WECOM_DEBOUNCE_SECS` | `3` | Debounce quiet-period seconds before flushing buffered streamed text | ### Endpoints @@ -76,6 +84,8 @@ url = "ws://gateway:8080/ws" | `POST /webhook/line` | LINE webhook receiver | | `POST /webhook/feishu` | Feishu webhook receiver (when `FEISHU_CONNECTION_MODE=webhook`) | | `POST /webhook/googlechat` | Google Chat webhook receiver | +| `GET /webhook/wecom` | WeCom callback URL verification | +| `POST /webhook/wecom` | WeCom message callback receiver | | `GET /ws` | WebSocket server (OAB connects here) | | `GET /health` | Health check | @@ -117,6 +127,10 @@ See [docs/feishu.md](../docs/feishu.md) for the full setup guide. See [docs/google-chat.md](../docs/google-chat.md) for the full setup guide. +### WeCom (企业微信) + +See [docs/wecom.md](../docs/wecom.md) for the full setup guide. + ### Other Platforms GitHub webhooks, CI/CD events, monitoring alerts — any HTTP event source can be added as a gateway adapter. See the ADR for the adapter interface. diff --git a/gateway/src/adapters/feishu.rs b/gateway/src/adapters/feishu.rs index 061777ccc..2419d63cc 100644 --- a/gateway/src/adapters/feishu.rs +++ b/gateway/src/adapters/feishu.rs @@ -233,6 +233,8 @@ mod event_types { pub header: Option, pub event: Option, pub challenge: Option, + // Parsed by serde, not consumed in current code paths. + #[allow(dead_code)] #[serde(rename = "type")] pub event_type_field: Option, } @@ -240,6 +242,8 @@ mod event_types { #[derive(Debug, Deserialize)] pub struct FeishuEventHeader { pub event_id: Option, + // Parsed by serde, not consumed in current code paths. + #[allow(dead_code)] pub event_type: Option, } @@ -276,6 +280,8 @@ mod event_types { pub struct FeishuMention { pub key: Option, pub id: Option, + // Parsed by serde, not consumed in current code paths. + #[allow(dead_code)] pub name: Option, } @@ -466,13 +472,15 @@ mod event_types { // Bypass: if bot has previously replied in this thread (participated), // no @mention needed (like Discord's "involved" mode). let in_thread = thread_id.is_some(); - if channel_type == "group" && !is_bot_sender && config.require_mention { - if !(in_thread && bypass_mention_gating) { - if let Some(bot_id) = bot_open_id { - let bot_mentioned = mention_ids.iter().any(|id| id == bot_id); - if !bot_mentioned { - return None; - } + if channel_type == "group" + && !is_bot_sender + && config.require_mention + && !(in_thread && bypass_mention_gating) + { + if let Some(bot_id) = bot_open_id { + let bot_mentioned = mention_ids.iter().any(|id| id == bot_id); + if !bot_mentioned { + return None; } } } @@ -850,6 +858,7 @@ pub async fn start_websocket( } /// Single WebSocket connection lifecycle. +#[allow(clippy::too_many_arguments)] async fn ws_connect_loop( token_cache: &Arc, bot_open_id_store: &Arc>>, @@ -919,7 +928,7 @@ async fn ws_connect_loop( ack.payload = Some(b"{\"code\":200}".to_vec()); let ack_bytes = ack.encode_to_vec(); let _ = ws_tx.send( - tokio_tungstenite::tungstenite::Message::Binary(ack_bytes.into()) + tokio_tungstenite::tungstenite::Message::Binary(ack_bytes) ).await; } } @@ -940,6 +949,7 @@ async fn ws_connect_loop( } /// Process a single WebSocket text message. +#[allow(clippy::too_many_arguments)] async fn handle_ws_message( text: &str, bot_open_id_store: &Arc>>, @@ -1164,8 +1174,8 @@ fn markdown_to_post(md: &str) -> serde_json::Value { let line = raw_lines[li]; // Detect fenced code block let trimmed = line.trim_start(); - if trimmed.starts_with("```") { - let lang = trimmed[3..].trim().to_string(); + if let Some(after_fence) = trimmed.strip_prefix("```") { + let lang = after_fence.trim().to_string(); let mut code = String::new(); li += 1; while li < raw_lines.len() { @@ -1238,9 +1248,7 @@ fn parse_inline(line: &str) -> Vec { } if close_ticks == ticks { // Found matching close — content between is literal - for j in i..end { - buf.push(chars[j]); - } + buf.extend(chars[i..end].iter().copied()); i = end + close_ticks; break 'outer; } @@ -1251,9 +1259,7 @@ fn parse_inline(line: &str) -> Vec { } if end >= len { // No matching close — treat backticks as literal - for j in i..len { - buf.push(chars[j]); - } + buf.extend(chars[i..len].iter().copied()); i = len; } continue; @@ -1278,9 +1284,7 @@ fn parse_inline(line: &str) -> Vec { } if close_run == run { // Found matching close — strip both, keep inner text - for j in after..scan { - buf.push(chars[j]); - } + buf.extend(chars[after..scan].iter().copied()); i = scan + close_run; found_close = true; break; @@ -1823,7 +1827,9 @@ fn detect_and_mark_multibot( thread_id_for_check .map(|tid| { let cache = multibot_threads.lock().unwrap_or_else(|e| e.into_inner()); - !cache.get(tid).is_some_and(|ts| ts.elapsed().as_secs() < config.session_ttl_secs) + cache + .get(tid) + .is_none_or(|ts| ts.elapsed().as_secs() >= config.session_ttl_secs) }) .unwrap_or(true) } diff --git a/gateway/src/adapters/googlechat.rs b/gateway/src/adapters/googlechat.rs index 20a884c3b..90442f975 100644 --- a/gateway/src/adapters/googlechat.rs +++ b/gateway/src/adapters/googlechat.rs @@ -64,6 +64,8 @@ pub struct GoogleChatSpace { pub name: String, #[serde(rename = "type")] pub space_type: Option, + // Parsed by serde, not consumed in current code paths. + #[allow(dead_code)] pub space_type_renamed: Option, } diff --git a/gateway/src/adapters/mod.rs b/gateway/src/adapters/mod.rs index f261efe68..94a2a8a79 100644 --- a/gateway/src/adapters/mod.rs +++ b/gateway/src/adapters/mod.rs @@ -3,3 +3,4 @@ pub mod googlechat; pub mod line; pub mod teams; pub mod telegram; +pub mod wecom; diff --git a/gateway/src/adapters/wecom.rs b/gateway/src/adapters/wecom.rs new file mode 100644 index 000000000..a33a71e53 --- /dev/null +++ b/gateway/src/adapters/wecom.rs @@ -0,0 +1,1654 @@ +use anyhow::Result; +use axum::extract::State; +use std::sync::Arc; +use tokio::sync::RwLock; +use tracing::{info, warn}; + +pub struct WecomConfig { + pub corp_id: String, + pub agent_id: String, + pub secret: String, + pub token: String, + pub encoding_aes_key: String, + pub webhook_path: String, + pub streaming_enabled: bool, + pub debounce_secs: u64, +} + +impl WecomConfig { + pub fn from_env() -> Option { + Self::from_reader(|k| std::env::var(k).ok()) + } + + /// Build config from an arbitrary string reader. Tests use this with a + /// HashMap so they don't mutate process-wide environment variables — + /// `env::set_var` races other tests under cargo's parallel runner. + fn from_reader Option>(read: F) -> Option { + let corp_id = read("WECOM_CORP_ID")?; + let secret = read("WECOM_SECRET")?; + let token = read("WECOM_TOKEN")?; + let encoding_aes_key = read("WECOM_ENCODING_AES_KEY")?; + let agent_id = read("WECOM_AGENT_ID")?; + if agent_id.parse::().is_err() { + warn!("WECOM_AGENT_ID must be a numeric value, got '{}'", agent_id); + return None; + } + let webhook_path = read("WECOM_WEBHOOK_PATH").unwrap_or_else(|| "/webhook/wecom".into()); + // Streaming opts-in: WeCom callback mode has no edit-message API, so + // streaming is implemented via thinking-placeholder + recall + resend, + // which causes a brief client flicker. Default off; set to true only if + // the UX tradeoff is acceptable. + let streaming_enabled = read("WECOM_STREAMING_ENABLED") + .map(|v| v == "true" || v == "1") + .unwrap_or(false); + let debounce_secs = read("WECOM_DEBOUNCE_SECS") + .and_then(|v| v.parse::().ok()) + .unwrap_or(3); + + if encoding_aes_key.len() != 43 { + warn!("WECOM_ENCODING_AES_KEY must be 43 characters, got {}", encoding_aes_key.len()); + return None; + } + + info!( + corp_id = %corp_id, + agent_id = %agent_id, + streaming_enabled, + debounce_secs, + "wecom adapter configured" + ); + Some(Self { + corp_id, + agent_id, + secret, + token, + encoding_aes_key, + webhook_path, + streaming_enabled, + debounce_secs, + }) + } +} + +fn decode_aes_key(encoding_aes_key: &str) -> anyhow::Result> { + use base64::engine::{DecodePaddingMode, GeneralPurpose, GeneralPurposeConfig}; + use base64::Engine; + // WeCom's EncodingAESKey is 43 base64 chars without trailing padding. + // Append "=" to make it a 44-char standard base64 string before decoding. + // Indifferent + allow_trailing_bits accommodate WeCom's non-standard + // encoding: the 43rd char's last 2 bits are not part of the output and + // must be ignored rather than rejected. + let padded = format!("{}=", encoding_aes_key); + let config = GeneralPurposeConfig::new() + .with_decode_padding_mode(DecodePaddingMode::Indifferent) + .with_decode_allow_trailing_bits(true); + let engine = GeneralPurpose::new(&base64::alphabet::STANDARD, config); + let key = engine + .decode(&padded) + .map_err(|e| anyhow::anyhow!("encoding_aes_key base64 decode failed: {e}"))?; + anyhow::ensure!( + key.len() == 32, + "encoding_aes_key must decode to 32 bytes, got {}", + key.len() + ); + Ok(key) +} + +fn compute_signature(token: &str, timestamp: &str, nonce: &str, encrypt: &str) -> String { + use sha1::Digest; + let mut parts = [token, timestamp, nonce, encrypt]; + parts.sort_unstable(); + let joined: String = parts.concat(); + let hash = sha1::Sha1::digest(joined.as_bytes()); + format!("{:x}", hash) +} + +fn verify_signature( + token: &str, + timestamp: &str, + nonce: &str, + encrypt: &str, + expected: &str, +) -> bool { + let computed = compute_signature(token, timestamp, nonce, encrypt); + tracing::debug!( + computed = %computed, + expected = %expected, + token_len = token.len(), + encrypt_len = encrypt.len(), + "signature comparison" + ); + subtle::ConstantTimeEq::ct_eq(computed.as_bytes(), expected.as_bytes()).into() +} + +fn decrypt_message( + encoding_aes_key: &str, + encrypted: &str, + expected_corp_id: &str, +) -> anyhow::Result { + use aes::cipher::{BlockDecryptMut, KeyIvInit}; + use base64::Engine; + + let key = decode_aes_key(encoding_aes_key)?; + let iv = &key[..16]; + + let cipher_bytes = base64::engine::general_purpose::STANDARD + .decode(encrypted) + .map_err(|e| anyhow::anyhow!("base64 decode failed: {e}"))?; + + if cipher_bytes.is_empty() || cipher_bytes.len() % 16 != 0 { + anyhow::bail!("ciphertext length {} not a multiple of 16", cipher_bytes.len()); + } + + type Aes256CbcDec = cbc::Decryptor; + let decryptor = Aes256CbcDec::new_from_slices(&key, iv) + .map_err(|e| anyhow::anyhow!("aes init failed: {e}"))?; + + let mut buf = cipher_bytes.to_vec(); + // WeCom uses PKCS7 with block_size=32, not 16. Decrypt without padding validation + // and strip padding manually. + let plaintext = decryptor + .decrypt_padded_mut::(&mut buf) + .map_err(|e| anyhow::anyhow!("aes decrypt failed: {e}"))?; + + // Strip WeCom PKCS7 padding (block_size=32): last byte indicates pad length (1-32) + let pad_byte = *plaintext.last().ok_or_else(|| anyhow::anyhow!("empty plaintext"))? as usize; + if pad_byte == 0 || pad_byte > 32 || pad_byte > plaintext.len() { + anyhow::bail!("invalid wecom padding value: {pad_byte}"); + } + let pad_start = plaintext.len() - pad_byte; + if !plaintext[pad_start..].iter().all(|&b| b as usize == pad_byte) { + anyhow::bail!("invalid PKCS#7 padding: not all padding bytes match"); + } + let plaintext = &plaintext[..pad_start]; + + // Plaintext structure: random(16) + msg_len(4, big-endian) + msg + corp_id + if plaintext.len() < 20 { + anyhow::bail!("decrypted payload too short"); + } + let msg_len = + u32::from_be_bytes([plaintext[16], plaintext[17], plaintext[18], plaintext[19]]) as usize; + if plaintext.len() < 20 + msg_len { + anyhow::bail!("msg_len exceeds payload size"); + } + let msg = &plaintext[20..20 + msg_len]; + let corp_id = &plaintext[20 + msg_len..]; + + let corp_id_str = + std::str::from_utf8(corp_id).map_err(|e| anyhow::anyhow!("corp_id not utf8: {e}"))?; + if corp_id_str != expected_corp_id { + anyhow::bail!("corp_id mismatch: expected {expected_corp_id}, got {corp_id_str}"); + } + + String::from_utf8(msg.to_vec()).map_err(|e| anyhow::anyhow!("message not utf8: {e}")) +} + +// --- Deduplication --- + +const DEDUPE_TTL_SECS: u64 = 30; +const DEDUPE_MAX_SIZE: usize = 10_000; + +struct DedupeCache { + entries: std::sync::Mutex>, +} + +impl DedupeCache { + fn new() -> Self { + Self { + entries: std::sync::Mutex::new(std::collections::HashMap::new()), + } + } + + fn check_and_insert(&self, msg_id: &str) -> bool { + let mut entries = self.entries.lock().unwrap_or_else(|e| e.into_inner()); + let now = std::time::Instant::now(); + + if entries.len() >= DEDUPE_MAX_SIZE { + entries.retain(|_, t| now.duration_since(*t).as_secs() < DEDUPE_TTL_SECS); + } + + if let Some(t) = entries.get(msg_id) { + if now.duration_since(*t).as_secs() < DEDUPE_TTL_SECS { + return false; + } + } + + entries.insert(msg_id.to_string(), now); + true + } +} + +// --- Token cache --- + +pub const WECOM_API_BASE: &str = "https://qyapi.weixin.qq.com"; +const TOKEN_REFRESH_MARGIN_SECS: u64 = 300; + +pub struct WecomTokenCache { + inner: RwLock>, + base_url: String, +} + +impl WecomTokenCache { + fn new() -> Self { + Self { + inner: RwLock::new(None), + base_url: WECOM_API_BASE.into(), + } + } + + #[cfg(test)] + fn with_base_url(base_url: String) -> Self { + Self { + inner: RwLock::new(None), + base_url, + } + } + + pub async fn get_token( + &self, + client: &reqwest::Client, + corp_id: &str, + secret: &str, + ) -> Result { + // Fast path: read lock + { + let guard = self.inner.read().await; + if let Some((ref token, created_at, expires_in)) = *guard { + let elapsed = created_at.elapsed().as_secs(); + if elapsed + TOKEN_REFRESH_MARGIN_SECS < expires_in { + return Ok(token.clone()); + } + } + } + + // Slow path: write lock + refresh + let mut guard = self.inner.write().await; + // Double-check after acquiring write lock + if let Some((ref token, created_at, expires_in)) = *guard { + let elapsed = created_at.elapsed().as_secs(); + if elapsed + TOKEN_REFRESH_MARGIN_SECS < expires_in { + return Ok(token.clone()); + } + } + + // WeCom's gettoken API requires `corpsecret` as a query parameter — the + // protocol mandates this, we can't move it to a header. Operators must + // configure their reverse proxy / load balancer to redact query strings + // on `/cgi-bin/gettoken` paths before logging access logs. We do not log + // this URL anywhere from the gateway side. + let url = format!( + "{}/cgi-bin/gettoken?corpid={}&corpsecret={}", + self.base_url, corp_id, secret + ); + let resp: serde_json::Value = client.get(&url).send().await?.json().await?; + + let errcode = resp["errcode"].as_i64().unwrap_or(-1); + if errcode != 0 { + anyhow::bail!( + "wecom gettoken failed: errcode={}, errmsg={}", + errcode, + resp["errmsg"] + ); + } + + let token = resp["access_token"] + .as_str() + .ok_or_else(|| anyhow::anyhow!("missing access_token in response"))? + .to_string(); + let expires_in = resp["expires_in"].as_u64().unwrap_or(7200); + + *guard = Some((token.clone(), std::time::Instant::now(), expires_in)); + Ok(token) + } + + pub async fn force_refresh( + &self, + client: &reqwest::Client, + corp_id: &str, + secret: &str, + ) -> Result { + let mut guard = self.inner.write().await; + *guard = None; + drop(guard); + self.get_token(client, corp_id, secret).await + } +} + +// --- Adapter --- + +struct PendingStream { + text_watch: tokio::sync::watch::Sender, +} + +type PendingMap = Arc>>; + +pub struct WecomAdapter { + pub config: WecomConfig, + pub token_cache: Arc, + client: reqwest::Client, + dedupe: DedupeCache, + pending_streams: PendingMap, +} + +impl WecomAdapter { + pub fn new(config: WecomConfig) -> Self { + Self { + token_cache: Arc::new(WecomTokenCache::new()), + client: reqwest::Client::new(), + dedupe: DedupeCache::new(), + pending_streams: Arc::new(std::sync::Mutex::new(std::collections::HashMap::new())), + config, + } + } + + + pub async fn handle_reply( + &self, + reply: &crate::schema::GatewayReply, + event_tx: &tokio::sync::broadcast::Sender, + ) { + if let Some(cmd) = reply.command.as_deref() { + match cmd { + "add_reaction" | "remove_reaction" | "create_topic" => { + info!(command = cmd, "wecom: ignoring unsupported command"); + return; + } + "edit_message" => { + self.handle_edit_message(reply); + return; + } + _ => {} + } + } + + let text = &reply.content.text; + if text.is_empty() { + return; + } + + let to_user = reply + .channel + .id + .rsplit(':') + .next() + .unwrap_or(&reply.channel.id); + + let has_pending = { + let pending = self.pending_streams.lock().unwrap_or_else(|e| e.into_inner()); + pending.contains_key(&reply.channel.id) + }; + let is_streaming_placeholder = reply.request_id.is_some() && !has_pending; + if is_streaming_placeholder { + // Optionally send a thinking placeholder. With streaming disabled + // (default), buffer chunks silently and send the consolidated text + // when the debounce settles — no recall/flicker. + let placeholder_id = if self.config.streaming_enabled { + info!(to_user = to_user, "wecom: sending thinking placeholder"); + match self.send_text(to_user, "⏳...").await { + Ok(id) => Some(id), + Err(e) => { + warn!("wecom send thinking failed: {e}"); + return; + } + } + } else { + None + }; + + let (text_tx, text_rx) = tokio::sync::watch::channel(String::new()); + { + let mut pending = self.pending_streams.lock().unwrap_or_else(|e| e.into_inner()); + pending.insert(reply.channel.id.clone(), PendingStream { + text_watch: text_tx, + }); + } + let client = self.client.clone(); + let token_cache = self.token_cache.clone(); + let corp_id = self.config.corp_id.clone(); + let secret = self.config.secret.clone(); + let agent_id = self.config.agent_id.clone(); + let thinking_id = placeholder_id.clone(); + let flush_to_user = to_user.to_string(); + let channel_id_clone = reply.channel.id.clone(); + let pending_clone = self.pending_streams.clone(); + let debounce_secs = self.config.debounce_secs; + tokio::spawn(async move { + let mut rx = text_rx; + let debounce = std::time::Duration::from_secs(debounce_secs); + let mut last_text = String::new(); + let max_idle = std::time::Duration::from_secs(300); + let started = std::time::Instant::now(); + loop { + match tokio::time::timeout(debounce, rx.changed()).await { + Ok(Ok(())) => { + last_text = rx.borrow().clone(); + } + Ok(Err(_)) => break, + Err(_) => { + if !last_text.is_empty() { + break; + } + if started.elapsed() > max_idle { + warn!("wecom: debounce task timed out after 5 minutes"); + break; + } + } + } + } + // Acquire pending lock first, then capture any late writes + // that landed between the loop break and now. Holding the + // lock blocks handle_reply from sending more chunks for this + // channel, so this read is the last writeable moment. Then + // remove the entry, which drops text_tx and closes the channel. + { + let mut pending = pending_clone.lock().unwrap_or_else(|e| e.into_inner()); + let final_text = rx.borrow().clone(); + if !final_text.is_empty() { + last_text = final_text; + } + pending.remove(&channel_id_clone); + } + if last_text.is_empty() { + return; + } + flush_thinking( + &client, &token_cache, &corp_id, &secret, &agent_id, + thinking_id.as_deref(), &flush_to_user, &last_text, + ).await; + }); + + if let Some(ref req_id) = reply.request_id { + let resp = crate::schema::GatewayResponse { + schema: "openab.gateway.response.v1".into(), + request_id: req_id.clone(), + success: true, + thread_id: None, + message_id: placeholder_id, + error: None, + }; + if let Ok(json) = serde_json::to_string(&resp) { + let _ = event_tx.send(json); + } + } + return; + } + + if has_pending { + // Re-check under lock: the debounce task may have removed the entry + // between our earlier read of `has_pending` and now. If it did, + // fall through to the direct-send path so the chunk isn't lost. + let appended = { + let pending = self.pending_streams.lock().unwrap_or_else(|e| e.into_inner()); + if let Some(stream) = pending.get(&reply.channel.id) { + let current = stream.text_watch.borrow().clone(); + let combined = if current.is_empty() { + text.to_string() + } else { + format!("{}\n{}", current, text) + }; + let _ = stream.text_watch.send(combined); + true + } else { + false + } + }; + if appended { + if let Some(ref req_id) = reply.request_id { + let resp = crate::schema::GatewayResponse { + schema: "openab.gateway.response.v1".into(), + request_id: req_id.clone(), + success: true, + thread_id: None, + message_id: None, + error: None, + }; + if let Ok(json) = serde_json::to_string(&resp) { + let _ = event_tx.send(json); + } + } + return; + } + // Pending entry was already removed (debounce flushed) — fall + // through to direct-send below so this chunk still reaches the user. + } + + info!(to_user = to_user, "wecom: sending reply"); + let chunks = split_text_lines(text, 2048); + let mut msg_id = None; + + for chunk in &chunks { + match self.send_text(to_user, chunk).await { + Ok(id) => { + if msg_id.is_none() { + msg_id = Some(id); + } + } + Err(e) => warn!("wecom send failed: {e}"), + } + } + + if let Some(ref req_id) = reply.request_id { + let resp = crate::schema::GatewayResponse { + schema: "openab.gateway.response.v1".into(), + request_id: req_id.clone(), + success: msg_id.is_some(), + thread_id: None, + message_id: msg_id, + error: None, + }; + if let Ok(json) = serde_json::to_string(&resp) { + let _ = event_tx.send(json); + } + } + } + + fn handle_edit_message(&self, reply: &crate::schema::GatewayReply) { + let text = reply.content.text.trim(); + if text.is_empty() { + return; + } + let pending = self.pending_streams.lock().unwrap_or_else(|e| e.into_inner()); + if let Some(stream) = pending.get(&reply.channel.id) { + let _ = stream.text_watch.send(text.to_string()); + } + } + + + async fn send_text(&self, to_user: &str, text: &str) -> Result { + let agent_id: u64 = self.config.agent_id.parse().expect("agent_id validated at startup"); + let body = serde_json::json!({ + "touser": to_user, + "msgtype": "text", + "agentid": agent_id, + "text": { "content": text } + }); + + let resp = post_with_token_retry( + &self.client, + &self.token_cache, + &self.config.corp_id, + &self.config.secret, + "/cgi-bin/message/send", + &body, + ) + .await?; + Ok(resp["msgid"].as_str().unwrap_or("").to_string()) + } +} + +/// POST a JSON body to a WeCom API endpoint with automatic token refresh +/// on errcode 42001 (access_token expired). Used by both `send_text` and +/// the streaming flush path so a long-running stream can't lose its final +/// reply if the cached token expires mid-flight. +async fn post_with_token_retry( + client: &reqwest::Client, + token_cache: &WecomTokenCache, + corp_id: &str, + secret: &str, + api_path: &str, + body: &serde_json::Value, +) -> Result { + let token = token_cache.get_token(client, corp_id, secret).await?; + let url = format!("{}{}?access_token={}", token_cache.base_url, api_path, token); + let resp: serde_json::Value = client.post(&url).json(body).send().await?.json().await?; + let errcode = resp["errcode"].as_i64().unwrap_or(-1); + + if errcode == 42001 { + warn!(api_path, "wecom: access_token expired, refreshing and retrying"); + let new_token = token_cache.force_refresh(client, corp_id, secret).await?; + let retry_url = format!("{}{}?access_token={}", token_cache.base_url, api_path, new_token); + let retry_resp: serde_json::Value = + client.post(&retry_url).json(body).send().await?.json().await?; + let retry_code = retry_resp["errcode"].as_i64().unwrap_or(-1); + if retry_code != 0 { + anyhow::bail!( + "wecom {} retry failed: errcode={}, errmsg={}", + api_path, + retry_code, + retry_resp["errmsg"] + ); + } + Ok(retry_resp) + } else if errcode != 0 { + anyhow::bail!( + "wecom {} failed: errcode={}, errmsg={}", + api_path, + errcode, + resp["errmsg"] + ); + } else { + Ok(resp) + } +} + +// --- Handlers --- + +fn handle_verify_request( + token: &str, + encoding_aes_key: &str, + corp_id: &str, + msg_signature: &str, + timestamp: &str, + nonce: &str, + echostr: &str, +) -> anyhow::Result { + if !verify_signature(token, timestamp, nonce, echostr, msg_signature) { + anyhow::bail!("signature verification failed"); + } + decrypt_message(encoding_aes_key, echostr, corp_id) +} + +// --- XML parsing --- + +struct CallbackEnvelope { + to_user_name: String, + encrypt: String, +} + +struct WecomMessage { + from_user: String, + msg_type: String, + content: String, + msg_id: String, + pic_url: String, + media_id: String, + file_name: String, +} + +fn parse_envelope_xml(xml: &str) -> Result { + use quick_xml::events::Event; + use quick_xml::Reader; + + let mut reader = Reader::from_str(xml); + let mut to_user_name = String::new(); + let mut encrypt = String::new(); + let mut current_tag = String::new(); + + loop { + match reader.read_event() { + Ok(Event::Start(e)) => { + current_tag = String::from_utf8_lossy(e.name().as_ref()).to_string(); + } + Ok(Event::CData(e)) => { + let text = String::from_utf8_lossy(&e).to_string(); + match current_tag.as_str() { + "ToUserName" => to_user_name = text, + "Encrypt" => encrypt = text, + _ => {} + } + } + Ok(Event::Text(e)) => { + let text = e.unescape().unwrap_or_default().to_string(); + match current_tag.as_str() { + "ToUserName" if to_user_name.is_empty() => to_user_name = text, + "Encrypt" if encrypt.is_empty() => encrypt = text, + _ => {} + } + } + Ok(Event::End(_)) => { + current_tag.clear(); + } + Ok(Event::Eof) => break, + Err(e) => anyhow::bail!("xml parse error: {e}"), + _ => {} + } + } + + if encrypt.is_empty() { + anyhow::bail!("missing Encrypt field in callback XML"); + } + Ok(CallbackEnvelope { + to_user_name, + encrypt, + }) +} + +fn parse_message_xml(xml: &str) -> Result { + use quick_xml::events::Event; + use quick_xml::Reader; + + let mut reader = Reader::from_str(xml); + let mut from_user = String::new(); + let mut msg_type = String::new(); + let mut content = String::new(); + let mut msg_id = String::new(); + let mut pic_url = String::new(); + let mut media_id = String::new(); + let mut file_name = String::new(); + let mut current_tag = String::new(); + + loop { + match reader.read_event() { + Ok(Event::Start(e)) => { + current_tag = String::from_utf8_lossy(e.name().as_ref()).to_string(); + } + Ok(Event::CData(e)) => { + let text = String::from_utf8_lossy(&e).to_string(); + match current_tag.as_str() { + "FromUserName" => from_user = text, + "MsgType" => msg_type = text, + "Content" => content = text, + "MsgId" => msg_id = text, + "PicUrl" => pic_url = text, + "MediaId" => media_id = text, + "FileName" => file_name = text, + _ => {} + } + } + Ok(Event::Text(e)) => { + let text = e.unescape().unwrap_or_default().to_string(); + match current_tag.as_str() { + "FromUserName" if from_user.is_empty() => from_user = text, + "MsgType" if msg_type.is_empty() => msg_type = text, + "Content" if content.is_empty() => content = text, + "MsgId" if msg_id.is_empty() => msg_id = text, + "PicUrl" if pic_url.is_empty() => pic_url = text, + "MediaId" if media_id.is_empty() => media_id = text, + "FileName" if file_name.is_empty() => file_name = text, + _ => {} + } + } + Ok(Event::End(_)) => { + current_tag.clear(); + } + Ok(Event::Eof) => break, + Err(e) => anyhow::bail!("xml parse error: {e}"), + _ => {} + } + } + + Ok(WecomMessage { + from_user, + msg_type, + content, + msg_id, + pic_url, + media_id, + file_name, + }) +} + +#[allow(clippy::too_many_arguments)] +async fn flush_thinking( + client: &reqwest::Client, + token_cache: &WecomTokenCache, + corp_id: &str, + secret: &str, + agent_id: &str, + thinking_msg_id: Option<&str>, + to_user: &str, + text: &str, +) { + info!(?thinking_msg_id, text_len = text.len(), "wecom: flush_thinking starting"); + + // Recall thinking placeholder (only when streaming was enabled) + if let Some(id) = thinking_msg_id { + let body = serde_json::json!({ "msgid": id }); + match post_with_token_retry( + client, + token_cache, + corp_id, + secret, + "/cgi-bin/message/recall", + &body, + ) + .await + { + Ok(resp) => info!(body = %resp, "wecom: recall response"), + Err(e) => warn!(error = %e, "wecom: recall failed"), + } + } + + // Send final text. Each chunk goes through retry-on-token-expiry so a + // long stream that outlives the cached token still delivers its reply. + let aid = agent_id.parse::().unwrap_or(0); + let chunks = split_text_lines(text, 2048); + info!(chunk_count = chunks.len(), "wecom: sending final chunks"); + for (i, chunk) in chunks.iter().enumerate() { + let body = serde_json::json!({ + "touser": to_user, + "msgtype": "text", + "agentid": aid, + "text": { "content": chunk } + }); + match post_with_token_retry( + client, + token_cache, + corp_id, + secret, + "/cgi-bin/message/send", + &body, + ) + .await + { + Ok(val) => { + let msg_id = val["msgid"].as_str().unwrap_or(""); + info!(msg_id = %msg_id, chunk_idx = i, "wecom: sent final reply chunk"); + } + Err(e) => warn!(error = %e, chunk_idx = i, "wecom flush send failed"), + } + } +} + +/// Split `text` into chunks that each fit within `limit` bytes (WeCom's +/// `message/send` truncates server-side at 2048 bytes). Splits prefer +/// newline boundaries; lines that exceed the limit themselves are split at +/// UTF-8 char boundaries via `char_indices()` so multibyte characters are +/// never severed mid-codepoint. The `limit` and all `len()` comparisons in +/// this function are in **bytes**, matching WeCom's server-side check. +fn split_text_lines(text: &str, limit: usize) -> Vec { + if text.len() <= limit { + return vec![text.to_string()]; + } + let mut chunks = Vec::new(); + let mut current = String::new(); + for line in text.split('\n') { + if line.len() > limit { + if !current.is_empty() { + chunks.push(current); + current = String::new(); + } + // Split long line at char boundaries + let mut pos = 0; + for (i, ch) in line.char_indices() { + if i - pos + ch.len_utf8() > limit { + chunks.push(line[pos..i].to_string()); + pos = i; + } + } + if pos < line.len() { + current = line[pos..].to_string(); + } + continue; + } + let candidate_len = if current.is_empty() { + line.len() + } else { + current.len() + 1 + line.len() + }; + if candidate_len > limit && !current.is_empty() { + chunks.push(current); + current = String::new(); + } + if !current.is_empty() { + current.push('\n'); + } + current.push_str(line); + } + if !current.is_empty() { + chunks.push(current); + } + chunks +} + +pub async fn verify( + State(state): State>, + query: axum::extract::Query>, +) -> axum::response::Response { + use axum::response::IntoResponse; + + let wecom = match state.wecom.as_ref() { + Some(w) => w, + None => return axum::http::StatusCode::SERVICE_UNAVAILABLE.into_response(), + }; + + let msg_signature = query.get("msg_signature").map(|s| s.as_str()).unwrap_or(""); + let timestamp = query.get("timestamp").map(|s| s.as_str()).unwrap_or(""); + let nonce = query.get("nonce").map(|s| s.as_str()).unwrap_or(""); + let echostr = query.get("echostr").map(|s| s.as_str()).unwrap_or(""); + + info!( + msg_signature = %msg_signature, + timestamp = %timestamp, + nonce = %nonce, + echostr_len = echostr.len(), + "wecom verify request received" + ); + + match handle_verify_request( + &wecom.config.token, + &wecom.config.encoding_aes_key, + &wecom.config.corp_id, + msg_signature, + timestamp, + nonce, + echostr, + ) { + Ok(plaintext) => plaintext.into_response(), + Err(e) => { + warn!("wecom callback verification failed: {e}"); + axum::http::StatusCode::FORBIDDEN.into_response() + } + } +} + +pub async fn webhook( + State(state): State>, + query: axum::extract::Query>, + body: axum::body::Bytes, +) -> axum::response::Response { + use axum::response::IntoResponse; + + let wecom = match state.wecom.as_ref() { + Some(w) => w, + None => return axum::http::StatusCode::SERVICE_UNAVAILABLE.into_response(), + }; + + let msg_signature = query.get("msg_signature").map(|s| s.as_str()).unwrap_or(""); + let timestamp = query.get("timestamp").map(|s| s.as_str()).unwrap_or(""); + let nonce = query.get("nonce").map(|s| s.as_str()).unwrap_or(""); + + // Reject stale callbacks. WeCom retries within ~5s, our dedup window is + // 30s, so a 5-minute freshness check rejects replays without false- + // positives on legitimate retries. The signature itself doesn't bind a + // freshness expectation, so without this an attacker who captured a + // signed payload could replay it indefinitely. + if let Ok(ts) = timestamp.parse::() { + let now = chrono::Utc::now().timestamp(); + if (now - ts).abs() > 300 { + warn!(timestamp_age_secs = now - ts, "wecom webhook: rejecting stale callback"); + return axum::http::StatusCode::FORBIDDEN.into_response(); + } + } + + let body_str = match std::str::from_utf8(&body) { + Ok(s) => s, + Err(_) => return axum::http::StatusCode::BAD_REQUEST.into_response(), + }; + + let envelope = match parse_envelope_xml(body_str) { + Ok(e) => e, + Err(e) => { + warn!("wecom envelope parse error: {e}"); + return axum::http::StatusCode::BAD_REQUEST.into_response(); + } + }; + + // ToUserName in the outer envelope must match our configured Corp ID. + // The decrypt step also validates the inner Corp ID suffix; checking here + // first surfaces misrouted callbacks before we touch crypto. + if envelope.to_user_name != wecom.config.corp_id { + warn!( + envelope_to = %envelope.to_user_name, + expected = %wecom.config.corp_id, + "wecom webhook: envelope ToUserName mismatch" + ); + return axum::http::StatusCode::FORBIDDEN.into_response(); + } + + if !verify_signature( + &wecom.config.token, + timestamp, + nonce, + &envelope.encrypt, + msg_signature, + ) { + warn!("wecom webhook signature verification failed"); + return axum::http::StatusCode::FORBIDDEN.into_response(); + } + + info!(encrypt_len = envelope.encrypt.len(), "wecom: decrypting callback"); + let decrypted = match decrypt_message( + &wecom.config.encoding_aes_key, + &envelope.encrypt, + &wecom.config.corp_id, + ) { + Ok(d) => { + info!("wecom: decrypt ok"); + d + } + Err(e) => { + warn!(encrypt_len = envelope.encrypt.len(), "wecom decrypt failed: {e}"); + return "success".into_response(); + } + }; + + let msg = match parse_message_xml(&decrypted) { + Ok(m) => m, + Err(e) => { + warn!("wecom message parse error: {e}"); + return "success".into_response(); + } + }; + + info!( + msg_type = %msg.msg_type, + has_pic_url = !msg.pic_url.is_empty(), + msg_id = %msg.msg_id, + "wecom: parsed message" + ); + + if !matches!(msg.msg_type.as_str(), "text" | "image" | "file") { + return "success".into_response(); + } + + if !wecom.dedupe.check_and_insert(&msg.msg_id) { + return "success".into_response(); + } + + let text = match msg.msg_type.as_str() { + "text" => msg.content.clone(), + "image" => "Describe this image.".to_string(), + "file" => format!("User sent a file: {}", msg.file_name), + _ => String::new(), + }; + + let mut attachments = Vec::new(); + if msg.msg_type == "image" && !msg.pic_url.is_empty() { + match download_wecom_image(&wecom.client, &msg.pic_url).await { + Some(att) => attachments.push(att), + None => info!("wecom: image download failed, forwarding without attachment"), + } + } + if msg.msg_type == "file" && !msg.media_id.is_empty() { + match download_wecom_file( + &wecom.client, + &wecom.token_cache, + &wecom.config.corp_id, + &wecom.config.secret, + &msg.media_id, + &msg.file_name, + ) + .await + { + Some(att) => attachments.push(att), + None => info!("wecom: file download failed, forwarding without attachment"), + } + } + + if text.trim().is_empty() && attachments.is_empty() { + return "success".into_response(); + } + + let channel_id = format!("wecom:{}:{}", wecom.config.corp_id, msg.from_user); + let mut event = crate::schema::GatewayEvent::new( + "wecom", + crate::schema::ChannelInfo { + id: channel_id, + channel_type: "direct".into(), + thread_id: None, + }, + crate::schema::SenderInfo { + id: msg.from_user.clone(), + name: msg.from_user.clone(), + display_name: msg.from_user.clone(), + is_bot: false, + }, + &text, + &msg.msg_id, + vec![], + ); + event.content.attachments = attachments; + + let att_sizes: Vec = event.content.attachments.iter().map(|a| a.data.len()).collect(); + info!( + attachments = event.content.attachments.len(), + text_len = event.content.text.len(), + att_data_sizes = ?att_sizes, + att_mime = ?event.content.attachments.iter().map(|a| a.mime_type.as_str()).collect::>(), + "wecom: forwarding event to OAB" + ); + if let Ok(json) = serde_json::to_string(&event) { + info!( + json_len = json.len(), + has_attachments_in_json = json.contains("\"attachments\""), + "wecom: event JSON ready" + ); + let _ = state.event_tx.send(json); + } + + "success".into_response() +} + +const IMAGE_MAX_DOWNLOAD: u64 = 10 * 1024 * 1024; +const IMAGE_MAX_DIMENSION_PX: u32 = 1200; +const IMAGE_JPEG_QUALITY: u8 = 75; + +async fn download_wecom_image( + client: &reqwest::Client, + pic_url: &str, +) -> Option { + // Only fetch over HTTPS. WeCom's CDN serves images over HTTPS; rejecting + // non-HTTPS URLs prevents SSRF if the AES key is ever compromised and + // an attacker forges a callback with PicUrl pointing at an internal host. + if !pic_url.starts_with("https://") { + warn!(pic_url, "wecom: rejecting non-HTTPS pic_url"); + return None; + } + info!(pic_url, "wecom: downloading image"); + let resp = match client.get(pic_url).send().await { + Ok(r) => r, + Err(e) => { + warn!(error = %e, "wecom image download failed"); + return None; + } + }; + if !resp.status().is_success() { + warn!(status = %resp.status(), "wecom image download failed"); + return None; + } + if let Some(cl) = resp.headers().get(reqwest::header::CONTENT_LENGTH) { + if let Ok(size) = cl.to_str().unwrap_or("0").parse::() { + if size > IMAGE_MAX_DOWNLOAD { + warn!(size, "wecom image exceeds 10MB limit, skipping"); + return None; + } + } + } + let bytes = resp.bytes().await.ok()?; + if bytes.len() as u64 > IMAGE_MAX_DOWNLOAD { + warn!(size = bytes.len(), "wecom image exceeds 10MB limit"); + return None; + } + let (compressed, mime) = match resize_and_compress(&bytes) { + Ok(v) => v, + Err(e) => { + warn!(error = %e, "wecom: image resize/compress failed"); + return None; + } + }; + use base64::Engine; + let data = base64::engine::general_purpose::STANDARD.encode(&compressed); + let ext = if mime == "image/gif" { "gif" } else { "jpg" }; + Some(crate::schema::Attachment { + attachment_type: "image".into(), + filename: format!("wecom_{}.{}", chrono::Utc::now().timestamp(), ext), + mime_type: mime, + data, + size: compressed.len() as u64, + }) +} + +const FILE_MAX_DOWNLOAD: u64 = 20 * 1024 * 1024; + +const TEXT_EXTENSIONS: &[&str] = &[ + "txt", "csv", "log", "md", "json", "jsonl", "yaml", "yml", "toml", "xml", "rs", "py", "js", + "ts", "jsx", "tsx", "go", "java", "c", "cpp", "h", "hpp", "rb", "sh", "bash", "zsh", "fish", + "ps1", "bat", "sql", "html", "css", "scss", "less", "ini", "cfg", "conf", "env", + "swift", "kt", "scala", "r", "pl", "lua", "graphql", "tsv", +]; + +const TEXT_FILENAMES: &[&str] = &[ + "dockerfile", "makefile", "justfile", "rakefile", "gemfile", + "procfile", "vagrantfile", ".gitignore", ".dockerignore", ".editorconfig", +]; + +fn is_text_file(filename: &str) -> bool { + let lower = filename.to_lowercase(); + if lower.contains('.') { + if let Some(ext) = lower.rsplit('.').next() { + if TEXT_EXTENSIONS.contains(&ext) { + return true; + } + } + } + TEXT_FILENAMES.contains(&lower.as_str()) +} + +/// GET /cgi-bin/media/get with token-expiry retry. The media API returns +/// JSON `{"errcode":42001,...}` instead of binary when the token is stale, +/// so we sniff Content-Type and retry once with a force-refreshed token. +async fn fetch_media_with_retry( + client: &reqwest::Client, + token_cache: &WecomTokenCache, + corp_id: &str, + secret: &str, + media_id: &str, +) -> Result { + let token = token_cache.get_token(client, corp_id, secret).await?; + let url = format!( + "{}/cgi-bin/media/get?access_token={}&media_id={}", + token_cache.base_url, token, media_id + ); + let resp = client.get(&url).send().await?; + let content_type = resp + .headers() + .get(reqwest::header::CONTENT_TYPE) + .and_then(|v| v.to_str().ok()) + .unwrap_or("") + .to_string(); + if !content_type.contains("json") { + return Ok(resp); + } + // JSON body means error path. Inspect for 42001 and retry once. + let body = resp.text().await.unwrap_or_default(); + let val: serde_json::Value = serde_json::from_str(&body).unwrap_or_default(); + let errcode = val["errcode"].as_i64().unwrap_or(-1); + if errcode == 42001 { + warn!("wecom media: access_token expired, refreshing and retrying"); + let new_token = token_cache.force_refresh(client, corp_id, secret).await?; + let retry_url = format!( + "{}/cgi-bin/media/get?access_token={}&media_id={}", + token_cache.base_url, new_token, media_id + ); + return Ok(client.get(&retry_url).send().await?); + } + anyhow::bail!("wecom media error: {body}") +} + +async fn download_wecom_file( + client: &reqwest::Client, + token_cache: &WecomTokenCache, + corp_id: &str, + secret: &str, + media_id: &str, + filename: &str, +) -> Option { + info!(filename, media_id, "wecom: downloading file"); + let resp = match fetch_media_with_retry(client, token_cache, corp_id, secret, media_id).await { + Ok(r) => r, + Err(e) => { + warn!(error = %e, "wecom file download failed"); + return None; + } + }; + if !resp.status().is_success() { + warn!(status = %resp.status(), "wecom file download failed"); + return None; + } + if let Some(cl) = resp.headers().get(reqwest::header::CONTENT_LENGTH) { + if let Ok(size) = cl.to_str().unwrap_or("0").parse::() { + if size > FILE_MAX_DOWNLOAD { + warn!(size, "wecom file exceeds 20MB limit, skipping"); + return None; + } + } + } + let bytes = resp.bytes().await.ok()?; + if bytes.len() as u64 > FILE_MAX_DOWNLOAD { + warn!(size = bytes.len(), "wecom file exceeds 20MB limit"); + return None; + } + + if !is_text_file(filename) { + info!(filename, "wecom: skipping non-text file"); + return None; + } + + let text_content = match String::from_utf8(bytes.to_vec()) { + Ok(s) => s, + Err(_) => { + info!(filename, "wecom: file is not valid UTF-8, skipping"); + return None; + } + }; + + use base64::Engine; + let data = base64::engine::general_purpose::STANDARD.encode(text_content.as_bytes()); + let size = text_content.len() as u64; + + Some(crate::schema::Attachment { + attachment_type: "text_file".into(), + filename: filename.to_string(), + mime_type: "text/plain".into(), + data, + size, + }) +} + +fn resize_and_compress(raw: &[u8]) -> Result<(Vec, String), image::ImageError> { + use image::ImageReader; + use std::io::Cursor; + + let reader = ImageReader::new(Cursor::new(raw)).with_guessed_format()?; + let format = reader.format(); + if format == Some(image::ImageFormat::Gif) { + return Ok((raw.to_vec(), "image/gif".to_string())); + } + let img = reader.decode()?; + let (w, h) = (img.width(), img.height()); + let img = if w > IMAGE_MAX_DIMENSION_PX || h > IMAGE_MAX_DIMENSION_PX { + let max_side = std::cmp::max(w, h); + let ratio = f64::from(IMAGE_MAX_DIMENSION_PX) / f64::from(max_side); + let new_w = (f64::from(w) * ratio) as u32; + let new_h = (f64::from(h) * ratio) as u32; + img.resize(new_w, new_h, image::imageops::FilterType::Lanczos3) + } else { + img + }; + let mut buf = Cursor::new(Vec::new()); + let encoder = image::codecs::jpeg::JpegEncoder::new_with_quality(&mut buf, IMAGE_JPEG_QUALITY); + img.write_with_encoder(encoder)?; + Ok((buf.into_inner(), "image/jpeg".to_string())) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn make_env(pairs: &[(&str, &str)]) -> impl Fn(&str) -> Option { + let map: std::collections::HashMap = pairs + .iter() + .map(|(k, v)| ((*k).to_string(), (*v).to_string())) + .collect(); + move |k: &str| map.get(k).cloned() + } + + #[test] + fn config_from_env_all_present() { + let env = make_env(&[ + ("WECOM_CORP_ID", "ww_test_corp"), + ("WECOM_SECRET", "test_secret"), + ("WECOM_TOKEN", "test_token"), + ("WECOM_ENCODING_AES_KEY", "abcdefghijklmnopqrstuvwxyz0123456789ABCDEFG"), + ("WECOM_AGENT_ID", "1000002"), + ]); + let config = WecomConfig::from_reader(env).unwrap(); + assert_eq!(config.corp_id, "ww_test_corp"); + assert_eq!(config.agent_id, "1000002"); + assert_eq!(config.webhook_path, "/webhook/wecom"); + assert!(!config.streaming_enabled, "streaming defaults off"); + assert_eq!(config.debounce_secs, 3); + } + + #[test] + fn config_from_env_missing_required() { + let env = make_env(&[]); + assert!(WecomConfig::from_reader(env).is_none()); + } + + fn encrypt_for_test(encoding_aes_key: &str, msg: &str, corp_id: &str) -> String { + use aes::cipher::{BlockEncryptMut, KeyIvInit}; + use base64::Engine; + + let key = decode_aes_key(encoding_aes_key).unwrap(); + let iv = &key[..16]; + + let msg_bytes = msg.as_bytes(); + let corp_id_bytes = corp_id.as_bytes(); + let msg_len = (msg_bytes.len() as u32).to_be_bytes(); + + let mut plaintext = Vec::new(); + plaintext.extend_from_slice(&[0u8; 16]); // random bytes (zeros for test) + plaintext.extend_from_slice(&msg_len); + plaintext.extend_from_slice(msg_bytes); + plaintext.extend_from_slice(corp_id_bytes); + + // WeCom uses PKCS7 padding with block_size=32 + let block_size = 32; + let pad_len = block_size - (plaintext.len() % block_size); + for _ in 0..pad_len { + plaintext.push(pad_len as u8); + } + + // Encrypt with NoPadding since we already padded manually + let total_len = plaintext.len(); + let mut buf = vec![0u8; total_len + 16]; // extra space just in case + buf[..total_len].copy_from_slice(&plaintext); + + type Aes256CbcEnc = cbc::Encryptor; + let encryptor = Aes256CbcEnc::new_from_slices(&key, iv).unwrap(); + let encrypted = encryptor + .encrypt_padded_mut::(&mut buf, total_len) + .unwrap(); + + base64::engine::general_purpose::STANDARD.encode(encrypted) + } + + #[test] + fn aes_key_decode() { + let key_str = "QUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUE"; + let key_bytes = decode_aes_key(key_str).unwrap(); + assert_eq!(key_bytes.len(), 32); + } + + #[test] + fn signature_verify() { + let token = "testtoken"; + let timestamp = "1409659813"; + let nonce = "1372623149"; + let encrypt = "msg_encrypt_content"; + + let sig = compute_signature(token, timestamp, nonce, encrypt); + assert!(verify_signature(token, timestamp, nonce, encrypt, &sig)); + assert!(!verify_signature( + token, + timestamp, + nonce, + encrypt, + "wrong_signature_value_here" + )); + } + + #[test] + fn decrypt_wecom_payload() { + let key_str = "QUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUE"; + let corp_id = "ww_test_corp"; + let msg = "hello world"; + + let encrypted = encrypt_for_test(key_str, msg, corp_id); + let decrypted = decrypt_message(key_str, &encrypted, corp_id).unwrap(); + assert_eq!(decrypted, msg); + } + + #[test] + fn verify_callback_echostr() { + let token = "testtoken"; + let encoding_aes_key = "QUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUE"; + let corp_id = "ww_test_corp"; + let echostr_plain = "success_echo_string"; + + let echostr_encrypted = encrypt_for_test(encoding_aes_key, echostr_plain, corp_id); + let sig = compute_signature(token, "1409659813", "nonce123", &echostr_encrypted); + + let result = handle_verify_request( + token, + encoding_aes_key, + corp_id, + &sig, + "1409659813", + "nonce123", + &echostr_encrypted, + ); + assert_eq!(result.unwrap(), echostr_plain); + } + + #[test] + fn parse_text_message_xml() { + let xml = r#"134883186012345678901234561000002"#; + + let msg = parse_message_xml(xml).unwrap(); + assert_eq!(msg.from_user, "user001"); + assert_eq!(msg.msg_type, "text"); + assert_eq!(msg.content, "hello bot"); + assert_eq!(msg.msg_id, "1234567890123456"); + } + + #[test] + fn parse_callback_envelope() { + let xml = r#""#; + + let envelope = parse_envelope_xml(xml).unwrap(); + assert_eq!(envelope.to_user_name, "ww_test_corp"); + assert_eq!(envelope.encrypt, "some_encrypted_base64"); + } + + #[test] + fn dedupe_rejects_duplicates() { + let cache = DedupeCache::new(); + assert!(cache.check_and_insert("msg_001")); + assert!(!cache.check_and_insert("msg_001")); + assert!(cache.check_and_insert("msg_002")); + } + + #[tokio::test] + async fn token_refresh_success() { + use wiremock::matchers::{method, query_param}; + use wiremock::{Mock, MockServer, ResponseTemplate}; + + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(query_param("corpid", "ww_test_corp")) + .and(query_param("corpsecret", "test_secret")) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "errcode": 0, + "errmsg": "ok", + "access_token": "test_token_abc", + "expires_in": 7200 + }))) + .expect(1) + .mount(&server) + .await; + + let cache = WecomTokenCache::with_base_url(server.uri()); + let client = reqwest::Client::new(); + let token = cache.get_token(&client, "ww_test_corp", "test_secret").await.unwrap(); + assert_eq!(token, "test_token_abc"); + + // Second call uses cache (mock expects exactly 1 call) + let token2 = cache.get_token(&client, "ww_test_corp", "test_secret").await.unwrap(); + assert_eq!(token2, "test_token_abc"); + } + + #[test] + fn split_text_lines_multi() { + let text = "line1\nline2\nline3"; + let chunks = split_text_lines(text, 11); + assert_eq!(chunks.len(), 2); + assert_eq!(chunks[0], "line1\nline2"); + assert_eq!(chunks[1], "line3"); + } + + #[test] + fn split_text_lines_within_limit() { + let text = "short"; + let chunks = split_text_lines(text, 100); + assert_eq!(chunks, vec!["short"]); + } + + #[test] + fn split_text_lines_long_line() { + let text = "abcdefghij"; + let chunks = split_text_lines(text, 4); + assert_eq!(chunks, vec!["abcd", "efgh", "ij"]); + } + + #[test] + fn split_text_lines_long_line_utf8() { + let text = "你好世界測試"; // 18 bytes, 6 chars + let chunks = split_text_lines(text, 6); + assert_eq!(chunks, vec!["你好", "世界", "測試"]); + } + + #[test] + fn is_text_file_check() { + assert!(is_text_file("readme.md")); + assert!(is_text_file("config.json")); + assert!(is_text_file("data.csv")); + assert!(is_text_file("MAIN.PY")); + assert!(!is_text_file("photo.png")); + assert!(!is_text_file("archive.zip")); + assert!(!is_text_file("doc.pdf")); + } + + #[test] + fn parse_file_message() { + let xml = r#"134883186066661000002"#; + let msg = parse_message_xml(xml).unwrap(); + assert_eq!(msg.msg_type, "file"); + assert_eq!(msg.media_id, "media_abc123"); + assert_eq!(msg.file_name, "report.csv"); + } + + #[test] + fn full_webhook_decrypt_and_parse() { + let token = "testtoken"; + let encoding_aes_key = "QUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUE"; + let corp_id = "ww_test_corp"; + let timestamp = "1409659813"; + let nonce = "nonce123"; + + // Simulate the inner message + let inner_xml = "134883186099991000002"; + + // Encrypt it + let encrypted = encrypt_for_test(encoding_aes_key, inner_xml, corp_id); + + // Compute signature + let sig = compute_signature(token, timestamp, nonce, &encrypted); + + // Verify signature + assert!(verify_signature(token, timestamp, nonce, &encrypted, &sig)); + + // Decrypt + let decrypted = decrypt_message(encoding_aes_key, &encrypted, corp_id).unwrap(); + assert_eq!(decrypted, inner_xml); + + // Parse + let msg = parse_message_xml(&decrypted).unwrap(); + assert_eq!(msg.from_user, "user42"); + assert_eq!(msg.msg_type, "text"); + assert_eq!(msg.content, "ping"); + assert_eq!(msg.msg_id, "9999"); + } + + #[test] + fn parse_image_message() { + let encoding_aes_key = "QUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUE"; + let corp_id = "ww_test_corp"; + + let inner_xml = "134883186088881000002"; + + let encrypted = encrypt_for_test(encoding_aes_key, inner_xml, corp_id); + let decrypted = decrypt_message(encoding_aes_key, &encrypted, corp_id).unwrap(); + let msg = parse_message_xml(&decrypted).unwrap(); + assert_eq!(msg.msg_type, "image"); + assert_eq!(msg.pic_url, "http://example.com/pic.jpg"); + assert_eq!(msg.from_user, "user42"); + } + + #[test] + fn unsupported_msg_type_skipped() { + let xml = "134883186077771000002"; + let msg = parse_message_xml(xml).unwrap(); + assert_eq!(msg.msg_type, "voice"); + assert!(!matches!(msg.msg_type.as_str(), "text" | "image")); + } + + #[test] + fn verify_rejects_wrong_signature() { + let token = "testtoken"; + let encoding_aes_key = "QUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUE"; + let corp_id = "ww_test_corp"; + let echostr_plain = "test_echo"; + + let echostr_encrypted = encrypt_for_test(encoding_aes_key, echostr_plain, corp_id); + + let result = handle_verify_request( + token, + encoding_aes_key, + corp_id, + "completely_wrong_signature", + "1409659813", + "nonce123", + &echostr_encrypted, + ); + assert!(result.is_err()); + } + + #[test] + fn decrypt_with_large_padding_value() { + // Verifies decryption works when WeCom's 32-byte padding exceeds 16 + let encoding_aes_key = "QUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUE"; + let corp_id = "ww_test_corp"; + // Choose a message where (16 + 4 + msg_len + corp_id_len) % 32 < 16, + // producing a pad value > 16 which would fail with PKCS7/block_size=16. + // 16 + 4 + 1 + 12 = 33 → 33 % 32 = 1 → pad = 31 + let msg = "x"; + let encrypted = encrypt_for_test(encoding_aes_key, msg, corp_id); + let decrypted = decrypt_message(encoding_aes_key, &encrypted, corp_id).unwrap(); + assert_eq!(decrypted, msg); + } + + #[test] + fn decrypt_rejects_wrong_corp_id() { + let encoding_aes_key = "QUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUE"; + let corp_id = "ww_test_corp"; + let msg = "hello"; + + let encrypted = encrypt_for_test(encoding_aes_key, msg, corp_id); + let result = decrypt_message(encoding_aes_key, &encrypted, "ww_other_corp"); + assert!(result.is_err()); + assert!(result.unwrap_err().to_string().contains("corp_id mismatch")); + } +} diff --git a/gateway/src/main.rs b/gateway/src/main.rs index eaaf69997..ae685a957 100644 --- a/gateway/src/main.rs +++ b/gateway/src/main.rs @@ -50,6 +50,7 @@ pub struct AppState { pub feishu: Option, /// Google Chat adapter (None if Google Chat disabled) pub google_chat: Option, + pub wecom: Option, /// WebSocket authentication token pub ws_token: Option, /// Broadcast channel: gateway → OAB (events from all platforms) @@ -108,7 +109,7 @@ async fn handle_oab_connection(state: Arc, socket: axum::extract::ws:: let client = reqwest::Client::new(); while let Some(Ok(msg)) = ws_rx.next().await { if let Message::Text(text) = msg { - match serde_json::from_str::(&*text) { + match serde_json::from_str::(&text) { Ok(reply) => { info!( platform = %reply.platform, @@ -171,6 +172,13 @@ async fn handle_oab_connection(state: Arc, socket: axum::extract::ws:: warn!("reply for googlechat but adapter not configured"); } } + "wecom" => { + if let Some(ref wecom) = state_for_recv.wecom { + wecom.handle_reply(&reply, &state_for_recv.event_tx).await; + } else { + warn!("reply for wecom but adapter not configured"); + } + } other => warn!(platform = other, "unknown reply platform"), } } @@ -314,13 +322,26 @@ async fn main() -> Result<()> { None }; + // WeCom adapter + let wecom = adapters::wecom::WecomConfig::from_env().map(|config| { + let path = config.webhook_path.clone(); + info!(path = %path, "wecom adapter enabled"); + adapters::wecom::WecomAdapter::new(config) + }); + if let Some(ref w) = wecom { + app = app + .route(&w.config.webhook_path, axum::routing::get(adapters::wecom::verify)) + .route(&w.config.webhook_path, post(adapters::wecom::webhook)); + } + if telegram_bot_token.is_none() && line_access_token.is_none() && teams.is_none() && feishu.is_none() && google_chat.is_none() + && wecom.is_none() { - warn!("no adapters configured — set TELEGRAM_BOT_TOKEN, LINE_CHANNEL_ACCESS_TOKEN, TEAMS_APP_ID + TEAMS_APP_SECRET, FEISHU_APP_ID + FEISHU_APP_SECRET, and/or GOOGLE_CHAT_ENABLED=true"); + warn!("no adapters configured — set TELEGRAM_BOT_TOKEN, LINE_CHANNEL_ACCESS_TOKEN, TEAMS_APP_ID + TEAMS_APP_SECRET, FEISHU_APP_ID + FEISHU_APP_SECRET, GOOGLE_CHAT_ENABLED=true, and/or WECOM_CORP_ID + WECOM_SECRET + WECOM_TOKEN + WECOM_ENCODING_AES_KEY + WECOM_AGENT_ID"); } let state = Arc::new(AppState { @@ -332,6 +353,7 @@ async fn main() -> Result<()> { teams_service_urls: Mutex::new(HashMap::new()), feishu, google_chat, + wecom, ws_token, event_tx, reply_token_cache, diff --git a/gateway/src/schema.rs b/gateway/src/schema.rs index 560648a06..8fffa919b 100644 --- a/gateway/src/schema.rs +++ b/gateway/src/schema.rs @@ -32,7 +32,7 @@ pub struct SenderInfo { pub is_bot: bool, } -#[derive(Clone, Debug, Serialize, Deserialize)] +#[derive(Clone, Debug, Default, Serialize, Deserialize)] pub struct Content { #[serde(rename = "type")] pub content_type: String, @@ -41,7 +41,7 @@ pub struct Content { pub attachments: Vec, } -#[derive(Clone, Debug, Serialize, Deserialize)] +#[derive(Clone, Debug, Default, Serialize, Deserialize)] pub struct Attachment { #[serde(rename = "type")] pub attachment_type: String, // "image", "text_file" From e3f6b542ef58813e0b1cb47dc63b952dafc0f20f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E8=AA=9E=E5=AB=A3?= Date: Wed, 13 May 2026 07:52:31 +0800 Subject: [PATCH 025/100] feat(gateway): feishu voice message STT via gateway audio attachment (#761) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(gateway): feishu voice message STT via gateway audio attachment - Add msg_type=audio support to feishu adapter (parse, download, base64 encode) - Add MediaRef::Audio variant and download_feishu_audio() function - Add "audio" attachment type to core gateway handler (decode → stt::transcribe) - Pass SttConfig to gateway handler via GatewayParams - Update docs/feishu.md and docs/stt.md for multi-platform voice support Feishu voice messages (opus/ogg) are downloaded by the gateway, passed as base64-encoded audio attachments to core, and transcribed via the existing [stt] infrastructure (Groq Whisper by default). This is the first gateway platform to support audio — LINE/Telegram can reuse the core-side handler. Tested: 102 gateway tests + 197 core tests pass. E2E verified. * fix(gateway): read Content-Type from response, URL-encode path params in audio download - F1: Use actual Content-Type header from Feishu response instead of hardcoding "audio/ogg", with fallback to audio/ogg if header missing. - F2: URL-encode message_id and file_key in the download URL path to prevent potential path injection from untrusted JSON values. --------- Co-authored-by: wangyuyan-agent <265828726+wangyuyan-agent@users.noreply.github.com> Co-authored-by: chaodu-agent --- docs/feishu.md | 1 + docs/stt.md | 6 +-- gateway/Cargo.lock | 7 ++++ gateway/Cargo.toml | 1 + gateway/src/adapters/feishu.rs | 77 +++++++++++++++++++++++++++++++++- src/gateway.rs | 20 +++++++++ src/main.rs | 1 + 7 files changed, 109 insertions(+), 4 deletions(-) diff --git a/docs/feishu.md b/docs/feishu.md index 816696871..ef53f4a8a 100644 --- a/docs/feishu.md +++ b/docs/feishu.md @@ -167,6 +167,7 @@ The gateway downloads and forwards image and text file attachments to the AI age | `text` | Text extracted, forwarded as prompt | | `image` | Image downloaded, resized (max 1200px), JPEG compressed, base64 encoded → `ContentBlock::Image` | | `file` | Text files only (`.txt`, `.py`, `.rs`, `.md`, `.json`, etc., max 512KB). Non-text files (`.pdf`, `.zip`, etc.) are silently ignored. | +| `audio` | Voice message downloaded (opus/ogg, max 25MB), base64 encoded, forwarded to core. If `[stt]` is enabled, core transcribes via Whisper API and injects `[Voice message transcript]: ...` into the prompt. If STT is disabled or fails, the message is silently skipped. | | `post` | Rich text: text nodes extracted as prompt, `img` nodes downloaded as image attachments. This is the format Feishu uses when @mention + paste image in a group. | **Group chat limitation:** Feishu does not allow @mention and image upload in the same message. However, @mention + paste (Ctrl+V) an image works — Feishu sends this as a `post` message containing both the mention and the image. Direct image upload (via the attachment button) cannot include @mention, so the bot will not respond in groups. diff --git a/docs/stt.md b/docs/stt.md index 202f96789..5e76ff545 100644 --- a/docs/stt.md +++ b/docs/stt.md @@ -1,6 +1,6 @@ # Speech-to-Text (STT) for Voice Messages -openab can automatically transcribe Discord voice message attachments and forward the transcript to your ACP agent as text. +openab can automatically transcribe voice message attachments (Discord, Feishu, and other gateway platforms) and forward the transcript to your ACP agent as text. ## Quick Start @@ -24,7 +24,7 @@ api_key = "${GROQ_API_KEY}" ## How It Works ``` -Discord voice message (.ogg) +Voice message (Discord .ogg, Feishu opus/ogg, etc.) │ ▼ openab downloads the audio file @@ -170,6 +170,6 @@ When disabled, audio attachments are silently skipped with no impact on existing ## Technical Notes - openab sends `response_format=json` in the transcription request to ensure the response is always parseable JSON. Some local whisper servers default to plain text output without this parameter. -- The actual MIME type from the Discord attachment is passed through to the STT API (e.g. `audio/ogg`, `audio/mp4`, `audio/wav`). +- The actual MIME type from the platform attachment is passed through to the STT API (e.g. `audio/ogg` for Discord and Feishu voice messages, `audio/mp4`, `audio/wav`). - Environment variables in config values are expanded via `${VAR}` syntax (e.g. `api_key = "${GROQ_API_KEY}"`). - The `api_key` field is auto-detected from the `GROQ_API_KEY` environment variable when using the default Groq endpoint. If you set a custom `base_url` (e.g. local server), auto-detect is disabled to avoid leaking the Groq key to unrelated endpoints — you must set `api_key` explicitly. diff --git a/gateway/Cargo.lock b/gateway/Cargo.lock index b0e24b921..747c0395c 100644 --- a/gateway/Cargo.lock +++ b/gateway/Cargo.lock @@ -1136,6 +1136,7 @@ dependencies = [ "tokio-tungstenite 0.21.0", "tracing", "tracing-subscriber", + "urlencoding", "uuid", "wiremock", ] @@ -2178,6 +2179,12 @@ dependencies = [ "serde", ] +[[package]] +name = "urlencoding" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da" + [[package]] name = "utf-8" version = "0.7.6" diff --git a/gateway/Cargo.toml b/gateway/Cargo.toml index eed46efb7..6af027b4a 100644 --- a/gateway/Cargo.toml +++ b/gateway/Cargo.toml @@ -27,6 +27,7 @@ subtle = "2" sha1 = "0.10" quick-xml = "0.37" image = { version = "0.25", default-features = false, features = ["jpeg", "png", "gif", "webp"] } +urlencoding = "2" [dev-dependencies] wiremock = "0.6" diff --git a/gateway/src/adapters/feishu.rs b/gateway/src/adapters/feishu.rs index 2419d63cc..0ed92c93b 100644 --- a/gateway/src/adapters/feishu.rs +++ b/gateway/src/adapters/feishu.rs @@ -310,7 +310,7 @@ mod event_types { let sender = event.sender.as_ref()?; let msg_type = msg.message_type.as_deref().unwrap_or("text"); - if !matches!(msg_type, "text" | "image" | "file" | "post") { + if !matches!(msg_type, "text" | "image" | "file" | "post" | "audio") { return None; } // Skip bot messages with explicit sender_type @@ -398,6 +398,17 @@ mod event_types { }]; (String::new(), mentions.1, refs) } + "audio" => { + let file_key = content_json.get("file_key")?.as_str()?; + let mentions = extract_mentions( + "", msg.mentions.as_deref().unwrap_or(&[]), bot_open_id, + ); + let refs = vec![MediaRef::Audio { + message_id: message_id.to_string(), + file_key: file_key.to_string(), + }]; + (String::new(), mentions.1, refs) + } "post" => { // Rich text: content is {"title":"...","content":[[{tag,text,...},{tag,image_key,...}]]} let mut texts = Vec::new(); @@ -1055,6 +1066,9 @@ async fn handle_ws_message( MediaRef::File { message_id, file_key, file_name } => { download_feishu_file(client, &api_base, &token, message_id, file_key, file_name).await } + MediaRef::Audio { message_id, file_key } => { + download_feishu_audio(client, &api_base, &token, message_id, file_key).await + } }; if let Some(att) = attachment { gateway_event.content.attachments.push(att); @@ -1354,6 +1368,7 @@ fn try_parse_link(chars: &[char], start: usize) -> Option<(String, String, usize pub enum MediaRef { Image { message_id: String, image_key: String }, File { message_id: String, file_key: String, file_name: String }, + Audio { message_id: String, file_key: String }, } const IMAGE_MAX_DIMENSION_PX: u32 = 1200; @@ -1508,6 +1523,63 @@ pub async fn download_feishu_file( }) } +const AUDIO_MAX_DOWNLOAD: u64 = 25 * 1024 * 1024; // 25 MB (Whisper API limit) + +/// Download a Feishu audio message by message_id + file_key → base64 Attachment. +pub async fn download_feishu_audio( + client: &reqwest::Client, + api_base: &str, + token: &str, + message_id: &str, + file_key: &str, +) -> Option { + use urlencoding::encode; + let url = format!( + "{}/open-apis/im/v1/messages/{}/resources/{}?type=file", + api_base, encode(message_id), encode(file_key) + ); + let resp = match client.get(&url).bearer_auth(token).send().await { + Ok(r) => r, + Err(e) => { + tracing::warn!(file_key, error = %e, "feishu audio download failed"); + return None; + } + }; + if !resp.status().is_success() { + tracing::warn!(file_key, status = %resp.status(), "feishu audio download failed"); + return None; + } + let content_type = resp + .headers() + .get(reqwest::header::CONTENT_TYPE) + .and_then(|v| v.to_str().ok()) + .unwrap_or("audio/ogg") + .to_string(); + if let Some(cl) = resp.headers().get(reqwest::header::CONTENT_LENGTH) { + if let Ok(size) = cl.to_str().unwrap_or("0").parse::() { + if size > AUDIO_MAX_DOWNLOAD { + tracing::warn!(file_key, size, "feishu audio exceeds 25MB limit"); + return None; + } + } + } + let bytes = resp.bytes().await.ok()?; + if bytes.len() as u64 > AUDIO_MAX_DOWNLOAD { + tracing::warn!(file_key, size = bytes.len(), "feishu audio exceeds 25MB limit"); + return None; + } + tracing::debug!(file_key, size = bytes.len(), "feishu audio downloaded"); + use base64::Engine; + let data = base64::engine::general_purpose::STANDARD.encode(&bytes); + Some(crate::schema::Attachment { + attachment_type: "audio".into(), + filename: format!("{}.ogg", file_key), + mime_type: content_type, + data, + size: bytes.len() as u64, + }) +} + /// Send a post (rich text) message to a feishu chat_id. /// Returns the sent message_id on success, None on failure. /// When `reply_to` is Some(root_id), uses the reply API to stay in a thread. @@ -2297,6 +2369,9 @@ pub async fn webhook( MediaRef::File { message_id, file_key, file_name } => { download_feishu_file(&feishu.client, &api_base, &token, message_id, file_key, file_name).await } + MediaRef::Audio { message_id, file_key } => { + download_feishu_audio(&feishu.client, &api_base, &token, message_id, file_key).await + } }; if let Some(att) = attachment { gateway_event.content.attachments.push(att); diff --git a/src/gateway.rs b/src/gateway.rs index bd3254e85..0056c45b1 100644 --- a/src/gateway.rs +++ b/src/gateway.rs @@ -529,6 +529,7 @@ pub struct GatewayParams { pub allow_all_users: bool, pub allowed_users: Vec, pub streaming: bool, + pub stt: crate::config::SttConfig, } pub async fn run_gateway_adapter( @@ -547,6 +548,7 @@ pub async fn run_gateway_adapter( let allow_all_users = params.allow_all_users; let allowed_users = params.allowed_users; let streaming = params.streaming; + let stt_config = params.stt; let connect_url = match ¶ms.token { Some(token) => { @@ -719,6 +721,24 @@ pub async fn run_gateway_adapter( }); } } + "audio" if stt_config.enabled => { + use base64::Engine; + if let Ok(audio_bytes) = base64::engine::general_purpose::STANDARD.decode(&att.data) { + if let Some(transcript) = crate::stt::transcribe( + &crate::media::HTTP_CLIENT, + &stt_config, + audio_bytes, + att.filename.clone(), + &att.mime_type, + ).await { + extra_blocks.push(ContentBlock::Text { + text: format!("[Voice message transcript]: {transcript}"), + }); + } + } else { + warn!(filename = %att.filename, "audio attachment base64 decode failed"); + } + } _ => {} } } diff --git a/src/main.rs b/src/main.rs index 413eb1147..43a205899 100644 --- a/src/main.rs +++ b/src/main.rs @@ -299,6 +299,7 @@ async fn main() -> anyhow::Result<()> { ), allowed_users: gw_cfg.allowed_users, streaming: gw_cfg.streaming, + stt: cfg.stt.clone(), }; let gw_router = router.clone(); Some(tokio::spawn(async move { From cc5fe22311412fb77d227426591d3d36d36d5ea7 Mon Sep 17 00:00:00 2001 From: Cheng-Lung Sung Date: Wed, 13 May 2026 09:12:41 +0800 Subject: [PATCH 026/100] feat(discord): add thread export command (#794) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(discord): add thread export command # Conflicts: # docs/slash-commands.md # src/discord.rs * fix(discord): address review findings F1-F3 for export-thread F1: Add tracing::warn when attachment_size_limit < 2048 F2: Add code comment documenting newest-first fetch direction assumption F3: Use YYYYMMDD date fallback instead of generic 'thread' for non-ASCII names * feat(discord): add limit/since/days params to /export-thread - Add optional slash command options: limit (Integer), since (String), days (Integer) — mutually exclusive - Validate ranges: limit 1..=5000, days 1..=365, since YYYY-MM-DD UTC - Ephemeral error on mutual exclusion violation or invalid input - Refactor export_channel_messages to accept ExportFilter enum: All (before pagination), Limit (before, capped), After (after pagination) - Implement timestamp_ms_to_snowflake for since/days conversion - Update docs/slash-commands.md with parameter table and examples - Add unit tests for snowflake conversion edge cases * fix(discord): use before-pagination for since/days to keep newest messages on cap ExportFilter::After now fetches newest-first (before pagination) and stops when hitting messages at or before the filter boundary. This ensures that when the 5000-message cap is reached, the most recent messages in the time window are preserved — matching the existing disclosure text and user expectations. Addresses 擺渡法師 review finding: after-pagination would keep the oldest 5000 in the window, which is counterintuitive for days/since. * fix(discord): use result.fetched in hit_cap disclosure instead of hardcoded 5000 When limit:N is used and hits cap, the disclosure now correctly shows the actual number fetched rather than always saying 5000. * fix(discord): resolve clippy warnings (contains, if-let) - Use (1..=5000).contains(&n) instead of manual range comparison - Use (1..=365).contains(&d) instead of manual range comparison - Replace match Ok/Err with if-let for probe (single-arm match) * refactor(discord): change since param from UTC date to message ID Replace YYYY-MM-DD date parsing with direct message ID input. Users can right-click any message → Copy Message ID and use it directly. This avoids timezone ambiguity entirely. days param still uses synthetic snowflake for relative time filtering. * refactor(discord): default export to last 100, add all:true for full dump - No params → last 100 messages (more practical default) - all:true → full dump up to 5000 - all is mutually exclusive with limit/since/days - Docs updated with new default and examples * docs: clarify days param is rolling N×24h window * docs+tests: update for new DX (default 100, since=message_id, all:true) - PR description updated (done via gh pr edit) - docs/slash-commands.md: summary table, rolling 72h note, all examples - Add 4 unit tests verifying ExportFilter cap logic: default=100, all=5000, custom limit, after=global cap --------- Co-authored-by: 超渡法師 --- docs/slash-commands.md | 30 ++ src/discord.rs | 635 ++++++++++++++++++++++++++++++++++++++++- 2 files changed, 662 insertions(+), 3 deletions(-) diff --git a/docs/slash-commands.md b/docs/slash-commands.md index 040838e51..080c357a0 100644 --- a/docs/slash-commands.md +++ b/docs/slash-commands.md @@ -11,6 +11,7 @@ OpenAB registers Discord slash commands for session control. These work in both | `/cancel` | Cancel the current in-flight operation | Yes | | `/reset` | Reset the conversation session (clear history, start fresh) | Yes | | `/remind` | Set a one-shot delayed reminder to mention users/roles | No | +| `/export-thread` | Export thread/DM as `.txt` (default: last 100 messages) | No | All responses are **ephemeral** — only the user who invoked the command sees the reply. @@ -64,6 +65,35 @@ This is equivalent to the `sessions close` + `sessions new` pattern used by [Ope - Bot identity and system prompt (re-applied on next session creation) - Config settings in `config.toml` +### `/export-thread` + +Fetches the current Discord thread or DM history and returns a `.txt` file as an ephemeral follow-up. The transcript includes message timestamps, author names and IDs, message text, and attachment URLs. + +**Optional parameters** (mutually exclusive — use at most one): + +| Parameter | Type | Description | +|-----------|------|-------------| +| `limit` | Integer | Export only the most recent N messages (1–5000) | +| `since` | String | Export messages after this message ID (right-click → Copy Message ID) | +| `days` | Integer | Export messages from the last N days (1–365). Rolling N×24h window from now. | +| `all` | Boolean | Export all messages (up to 5000) | + +If no parameter is provided, the **last 100 messages** are exported. + +**Examples:** +``` +/export-thread → last 100 messages (default) +/export-thread limit:500 → most recent 500 messages +/export-thread since:1503744866100842698 → messages after this specific message +/export-thread days:3 → messages from the last 3 days (rolling 72h) +/export-thread all:true → export all (cap 5000) +``` + +**Constraints:** +- Only works in allowed Discord threads or enabled DMs. +- Specifying more than one filter returns an error. +- Very large exports may be truncated to fit Discord's attachment size limit. + ## Passing CLI Commands via @mention In addition to slash commands, you can pass built-in CLI commands directly after an @mention: diff --git a/src/discord.rs b/src/discord.rs index 5452e6a9e..6deb6c94e 100644 --- a/src/discord.rs +++ b/src/discord.rs @@ -8,9 +8,10 @@ use crate::media; use crate::remind::{self, ReminderStore}; use async_trait::async_trait; use serenity::builder::{ - CreateActionRow, CreateButton, CreateCommand, CreateCommandOption, CreateInteractionResponse, - CreateInteractionResponseMessage, CreateSelectMenu, CreateSelectMenuKind, - CreateSelectMenuOption, CreateThread, EditMessage, + CreateActionRow, CreateAttachment, CreateButton, CreateCommand, CreateCommandOption, + CreateInteractionResponse, CreateInteractionResponseFollowup, CreateInteractionResponseMessage, + CreateSelectMenu, CreateSelectMenuKind, CreateSelectMenuOption, CreateThread, EditMessage, + GetMessages, }; use serenity::http::Http; use serenity::model::application::ButtonStyle; @@ -34,6 +35,9 @@ const PARTICIPATION_CACHE_MAX: usize = 1000; /// Discord StringSelectMenu hard limit on options. const SELECT_MENU_PAGE_SIZE: usize = 25; +/// Avoid unbounded Discord history exports from very large threads. +const THREAD_EXPORT_MESSAGE_LIMIT: usize = 5000; + // --- DiscordAdapter: implements ChatAdapter for Discord via serenity --- pub struct DiscordAdapter { @@ -837,6 +841,28 @@ impl EventHandler for Handler { "delay", "Delay before firing (e.g. 30m, 2h, 1d)", ).required(true)), + CreateCommand::new("export-thread") + .description("Download this thread as a text file") + .add_option(CreateCommandOption::new( + CommandOptionType::Integer, + "limit", + "Export only the most recent N messages (1–5000)", + )) + .add_option(CreateCommandOption::new( + CommandOptionType::String, + "since", + "Export messages after this message ID", + )) + .add_option(CreateCommandOption::new( + CommandOptionType::Integer, + "days", + "Export messages from the last N days (1–365)", + )) + .add_option(CreateCommandOption::new( + CommandOptionType::Boolean, + "all", + "Export all messages (up to 5000). Default is last 100.", + )), ]; // Register global commands (works in DMs + all guilds after propagation). @@ -895,6 +921,9 @@ impl EventHandler for Handler { Interaction::Command(cmd) if cmd.data.name == "remind" => { self.handle_remind_command(&ctx, &cmd).await; } + Interaction::Command(cmd) if cmd.data.name == "export-thread" => { + self.handle_export_thread_command(&ctx, &cmd).await; + } Interaction::Component(comp) if comp.data.custom_id.starts_with("acp_config_") => { self.handle_config_select(&ctx, &comp).await; } @@ -1296,6 +1325,183 @@ impl Handler { } } + async fn handle_export_thread_command( + &self, + ctx: &Context, + cmd: &serenity::model::application::CommandInteraction, + ) { + if is_denied_user( + false, + self.allow_all_users, + &self.allowed_users, + cmd.user.id.get(), + ) { + let response = CreateInteractionResponse::Message( + CreateInteractionResponseMessage::new() + .content("🚫 You are not allowed to use this bot.") + .ephemeral(true), + ); + if let Err(e) = cmd.create_response(&ctx.http, response).await { + tracing::error!(error = %e, "failed to deny /export-thread command"); + } + return; + } + + let channel_id = cmd.channel_id; + let (export_allowed, export_name) = match channel_id.to_channel(&ctx.http).await { + Ok(serenity::model::channel::Channel::Guild(gc)) => { + let in_allowed_channel = + self.allow_all_channels || self.allowed_channels.contains(&channel_id.get()); + let (in_thread, _) = detect_thread( + gc.thread_metadata.is_some(), + gc.parent_id.map(|id| id.get()), + gc.owner_id.map(|id| id.get()), + ctx.cache.current_user().id.get(), + &self.allowed_channels, + self.allow_all_channels, + in_allowed_channel, + ); + (in_thread, gc.name.clone()) + } + Ok(serenity::model::channel::Channel::Private(_)) => { + (self.allow_dm, "dm".to_string()) + } + Ok(_) => (false, "channel".to_string()), + Err(e) => { + tracing::warn!(channel_id = %channel_id, error = %e, "failed to inspect channel for export"); + (false, "channel".to_string()) + } + }; + + if !export_allowed { + let response = CreateInteractionResponse::Message( + CreateInteractionResponseMessage::new() + .content("⚠️ Run this command inside an allowed Discord thread or DM.") + .ephemeral(true), + ); + if let Err(e) = cmd.create_response(&ctx.http, response).await { + tracing::error!(error = %e, "failed to respond to /export-thread rejection"); + } + return; + } + + // --- Parse and validate filter params (mutual exclusion) --- + let opts = &cmd.data.options; + let limit_opt = opts.iter().find(|o| o.name == "limit").and_then(|o| o.value.as_i64()); + let since_opt = opts.iter().find(|o| o.name == "since").and_then(|o| o.value.as_str()); + let days_opt = opts.iter().find(|o| o.name == "days").and_then(|o| o.value.as_i64()); + let all_opt = opts.iter().find(|o| o.name == "all").and_then(|o| o.value.as_bool()).unwrap_or(false); + + let filter_count = limit_opt.is_some() as u8 + since_opt.is_some() as u8 + days_opt.is_some() as u8 + all_opt as u8; + if filter_count > 1 { + let response = CreateInteractionResponse::Message( + CreateInteractionResponseMessage::new() + .content("⚠️ Please specify only one filter: `limit`, `since`, `days`, or `all`.") + .ephemeral(true), + ); + let _ = cmd.create_response(&ctx.http, response).await; + return; + } + + let filter = if all_opt { + ExportFilter::All + } else if let Some(n) = limit_opt { + if !(1..=5000).contains(&n) { + let response = CreateInteractionResponse::Message( + CreateInteractionResponseMessage::new() + .content("⚠️ `limit` must be between 1 and 5000.") + .ephemeral(true), + ); + let _ = cmd.create_response(&ctx.http, response).await; + return; + } + ExportFilter::Limit(n as usize) + } else if let Some(id_str) = since_opt { + match id_str.parse::() { + Ok(id) if id > 0 => ExportFilter::After(MessageId::new(id)), + _ => { + let response = CreateInteractionResponse::Message( + CreateInteractionResponseMessage::new() + .content("⚠️ `since` must be a valid message ID (right-click a message → Copy Message ID).") + .ephemeral(true), + ); + let _ = cmd.create_response(&ctx.http, response).await; + return; + } + } + } else if let Some(d) = days_opt { + if !(1..=365).contains(&d) { + let response = CreateInteractionResponse::Message( + CreateInteractionResponseMessage::new() + .content("⚠️ `days` must be between 1 and 365.") + .ephemeral(true), + ); + let _ = cmd.create_response(&ctx.http, response).await; + return; + } + let since_ts = chrono::Utc::now() - chrono::Duration::days(d); + let ts_ms = since_ts.timestamp_millis() as u64; + ExportFilter::After(timestamp_ms_to_snowflake(ts_ms)) + } else { + // Default: export last 100 messages (use limit:N or all:true for more) + ExportFilter::Limit(100) + }; + + let response = CreateInteractionResponse::Message( + CreateInteractionResponseMessage::new() + .content("Preparing thread export...") + .ephemeral(true), + ); + if let Err(e) = cmd.create_response(&ctx.http, response).await { + tracing::error!(error = %e, "failed to acknowledge /export-thread command"); + return; + } + + match export_channel_messages( + &ctx.http, + channel_id, + &export_name, + cmd.attachment_size_limit, + filter, + ) + .await + { + Ok(result) => { + let mut content = format!("Exported {} messages.", result.written); + if result.hit_cap { + content.push_str(&format!( + " Only the most recent {} messages were fetched — older messages were not included.", + result.fetched + )); + } + if result.byte_truncated { + content.push_str(&format!( + " Transcript truncated to fit Discord's attachment size limit ({} of {} fetched messages included).", + result.written, result.fetched + )); + } + let attachment = + CreateAttachment::bytes(result.transcript.into_bytes(), result.filename); + let followup = CreateInteractionResponseFollowup::new() + .content(content) + .add_file(attachment) + .ephemeral(true); + if let Err(e) = cmd.create_followup(&ctx.http, followup).await { + tracing::error!(error = %e, "failed to send /export-thread attachment"); + } + } + Err(e) => { + tracing::warn!(channel_id = %channel_id, error = %e, "failed to export thread"); + let followup = CreateInteractionResponseFollowup::new() + .content(format!("⚠️ Failed to export thread: {e}")) + .ephemeral(true); + if let Err(e) = cmd.create_followup(&ctx.http, followup).await { + tracing::error!(error = %e, "failed to send /export-thread error"); + } + } + } + } + async fn handle_config_select( &self, ctx: &Context, @@ -1412,6 +1618,283 @@ fn discord_msg_ref(msg: &Message) -> MessageRef { } } +struct ExportResult { + filename: String, + transcript: String, + /// Messages successfully pulled from Discord. + fetched: usize, + /// Messages that fit in the transcript (≤ `fetched`; differs when the + /// attachment-size limit truncates). + written: usize, + /// We stopped fetching because we hit the message cap and the thread still + /// has more messages we did not include. + hit_cap: bool, + /// Transcript was cut to keep the attachment under Discord's size limit. + byte_truncated: bool, +} + +/// Filter mode for export_channel_messages. +enum ExportFilter { + /// Fetch all messages (newest-first via `before`), capped at THREAD_EXPORT_MESSAGE_LIMIT. + All, + /// Fetch the most recent N messages (newest-first via `before`). + Limit(usize), + /// Fetch messages after a synthetic snowflake (newest-first via `before`, with boundary filtering). + After(MessageId), +} + +/// Discord epoch: 2015-01-01T00:00:00Z in milliseconds. +const DISCORD_EPOCH_MS: u64 = 1_420_070_400_000; + +/// Convert a UTC timestamp (in milliseconds since Unix epoch) to a synthetic +/// Discord snowflake suitable for use as an `after` cursor. +fn timestamp_ms_to_snowflake(timestamp_ms: u64) -> MessageId { + let discord_ms = timestamp_ms.saturating_sub(DISCORD_EPOCH_MS); + // Snowflake IDs use NonZeroU64 in serenity; ensure at least 1. + MessageId::new((discord_ms << 22).max(1)) +} + +async fn export_channel_messages( + http: &Http, + channel_id: ChannelId, + channel_name: &str, + attachment_size_limit: u32, + filter: ExportFilter, +) -> anyhow::Result { + let cap = match &filter { + ExportFilter::Limit(n) => *n, + _ => THREAD_EXPORT_MESSAGE_LIMIT, + }; + + let mut messages = Vec::new(); + let mut hit_cap = false; + + match &filter { + ExportFilter::All | ExportFilter::Limit(_) => { + // Fetch newest-first using `before` pagination, then reverse. + let mut before = None; + loop { + if messages.len() >= cap { + hit_cap = true; + break; + } + let remaining = cap - messages.len(); + let limit = remaining.min(100) as u8; + let mut request = GetMessages::new().limit(limit); + if let Some(before_id) = before { + request = request.before(before_id); + } + let batch = channel_id.messages(http, request).await?; + if batch.is_empty() { + break; + } + before = batch.last().map(|m| m.id); + let batch_len = batch.len(); + messages.extend(batch); + if batch_len < limit as usize { + break; + } + } + // Probe to confirm we actually left messages behind. + if hit_cap { + let probe = GetMessages::new().limit(1); + let probe = if let Some(before_id) = before { + probe.before(before_id) + } else { + probe + }; + if matches!(channel_id.messages(http, probe).await, Ok(b) if b.is_empty()) { + hit_cap = false; + } + } + messages.reverse(); + } + ExportFilter::After(after_id) => { + // Fetch newest-first using `before` pagination, stop when we hit + // messages at or before the filter boundary. This ensures that when + // the cap is reached, we keep the *newest* messages in the window. + let mut before = None; + loop { + if messages.len() >= cap { + hit_cap = true; + break; + } + let remaining = cap - messages.len(); + let limit = remaining.min(100) as u8; + let mut request = GetMessages::new().limit(limit); + if let Some(before_id) = before { + request = request.before(before_id); + } + let batch = channel_id.messages(http, request).await?; + if batch.is_empty() { + break; + } + before = batch.last().map(|m| m.id); + let batch_len = batch.len(); + // Filter out messages at or before the boundary. + let filtered: Vec<_> = batch.into_iter().filter(|m| m.id > *after_id).collect(); + let hit_boundary = filtered.len() < batch_len; + messages.extend(filtered); + if hit_boundary { + // We've reached the time boundary; no need to fetch older. + break; + } + if batch_len < limit as usize { + break; + } + } + // Probe only if we stopped due to cap (not boundary). + if hit_cap { + let probe = GetMessages::new().limit(1); + let probe = if let Some(before_id) = before { + probe.before(before_id) + } else { + probe + }; + if let Ok(batch) = channel_id.messages(http, probe).await { + // If the next message is beyond our filter boundary, + // we didn't actually leave relevant messages behind. + let has_more_in_window = batch.iter().any(|m| m.id > *after_id); + if !has_more_in_window { + hit_cap = false; + } + } + } + messages.reverse(); + } + } + + let filename = export_filename(channel_id, channel_name); + if attachment_size_limit < 2048 { + tracing::warn!(attachment_size_limit, "attachment_size_limit is very small; export will likely be truncated"); + } + let max_bytes = usize::try_from(attachment_size_limit) + .unwrap_or(8 * 1024 * 1024) + .saturating_sub(1024) + .max(1024); + let (transcript, written, byte_truncated) = + format_thread_export(channel_id, channel_name, &messages, max_bytes); + let fetched = messages.len(); + + Ok(ExportResult { + filename, + transcript, + fetched, + written, + hit_cap, + byte_truncated, + }) +} + +fn format_thread_export( + channel_id: ChannelId, + channel_name: &str, + messages: &[Message], + max_bytes: usize, +) -> (String, usize, bool) { + let header = format!( + "Discord thread export\nChannel: {channel_name} ({channel_id})\nMessages: {}\n\n", + messages.len() + ); + let entries: Vec = messages.iter().map(format_export_message).collect(); + assemble_export(&header, &entries, max_bytes) +} + +/// Build the transcript body from a pre-rendered header and a list of +/// already-formatted message entries, honouring `max_bytes`. +/// +/// Returns `(transcript, written, truncated)` where `written` is the number of +/// entries actually included. Split out from `format_thread_export` so the +/// truncation boundary logic can be unit-tested without constructing real +/// `serenity::model::channel::Message` values. +fn assemble_export(header: &str, entries: &[String], max_bytes: usize) -> (String, usize, bool) { + let mut out = String::from(header); + let mut written = 0; + let mut truncated = false; + + for entry in entries { + if out.len() + entry.len() > max_bytes { + truncated = true; + break; + } + out.push_str(entry); + written += 1; + } + + if truncated { + let note = "\n[Export truncated to fit Discord attachment size limit]\n"; + let room = max_bytes.saturating_sub(out.len()); + if room >= note.len() { + out.push_str(note); + } + } + + (out, written, truncated) +} + +fn format_export_message(msg: &Message) -> String { + let bot_marker = if msg.author.bot { " [bot]" } else { "" }; + let mut out = format!( + "[{}] {}{} ({})\n", + msg.timestamp, + msg.author.name, + bot_marker, + msg.author.id + ); + + if msg.content.is_empty() { + out.push_str("(no text)\n"); + } else { + out.push_str(&msg.content); + out.push('\n'); + } + + for attachment in &msg.attachments { + let mime = attachment.content_type.as_deref().unwrap_or("unknown"); + out.push_str(&format!( + "[attachment] {} ({} bytes, {}): {}\n", + attachment.filename, attachment.size, mime, attachment.url + )); + } + + out.push('\n'); + out +} + +fn export_filename(channel_id: ChannelId, channel_name: &str) -> String { + let safe_name = sanitize_filename_component(channel_name); + format!("discord-thread-{safe_name}-{channel_id}.txt") +} + +/// Reduce a free-form Discord channel/thread name to a safe ASCII filename +/// fragment. +/// +/// Non-ASCII characters are dropped silently — a purely-Chinese thread name +/// like "扈三娘的房間" yields a date-based fallback (e.g. `"20260512"`). +/// The caller appends the channel ID, which already guarantees uniqueness, +/// and an ASCII fragment plays nicer with downstream tools (mail attachments, +/// S3 keys, browser save-as dialogs). The 64-byte cap leaves room for the +/// `discord-thread-` prefix and the channel-ID suffix within typical +/// filesystem limits. +fn sanitize_filename_component(input: &str) -> String { + let mut safe = String::with_capacity(input.len()); + for ch in input.chars() { + if ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_') { + safe.push(ch); + } else if ch.is_whitespace() || matches!(ch, '.' | '/') { + safe.push('-'); + } + } + let safe = safe.trim_matches('-'); + if safe.is_empty() { + // Use current date as a human-friendly fallback when the thread name + // is entirely non-ASCII. + chrono::Utc::now().format("%Y%m%d").to_string() + } else { + safe.chars().take(64).collect() + } +} + async fn get_or_create_thread( ctx: &Context, adapter: &Arc, @@ -1777,6 +2260,152 @@ mod tests { assert!(!is_thread_already_exists_error(&err)); } + // --- thread export helpers --- + + #[test] + fn sanitize_filename_component_keeps_safe_ascii() { + assert_eq!( + sanitize_filename_component("release notes_v2"), + "release-notes_v2" + ); + } + + #[test] + fn sanitize_filename_component_falls_back_for_empty_result() { + let result = sanitize_filename_component("///..."); + // Fallback is a YYYYMMDD date string + assert_eq!(result.len(), 8); + assert!(result.chars().all(|c| c.is_ascii_digit())); + } + + // --- assemble_export --- + // Split out from format_thread_export so we can test the truncation + // boundary without constructing serenity::model::channel::Message values. + + #[test] + fn assemble_export_empty_entries_returns_header_only() { + let (out, written, truncated) = assemble_export("HDR\n", &[], 1024); + assert_eq!(out, "HDR\n"); + assert_eq!(written, 0); + assert!(!truncated); + } + + #[test] + fn assemble_export_single_oversized_entry_writes_zero_and_marks_truncated() { + let entries = vec!["x".repeat(200)]; + let (out, written, truncated) = assemble_export("h\n", &entries, 50); + assert_eq!(written, 0); + assert!(truncated); + // Footer needs ~56 bytes; max_bytes 50 leaves ≤48 of room, so it is + // intentionally omitted (it can't be appended without exceeding the + // limit). The header is still present. + assert!(out.starts_with("h\n")); + assert!(!out.contains("xx")); + } + + #[test] + fn assemble_export_entry_at_exact_boundary_is_included() { + // header(2) + entry(3) == max_bytes(5); the strict-greater check + // keeps the entry in. + let (out, written, truncated) = assemble_export("h\n", &["abc".to_string()], 5); + assert_eq!(written, 1); + assert!(!truncated); + assert_eq!(out, "h\nabc"); + } + + #[test] + fn assemble_export_entry_one_byte_over_boundary_is_excluded() { + // header(2) + entry(4) == 6 > max_bytes(5); entry is dropped. + let (out, written, truncated) = assemble_export("h\n", &["abcd".to_string()], 5); + assert_eq!(written, 0); + assert!(truncated); + assert!(out.starts_with("h\n")); + assert!(!out.contains("abcd")); + } + + #[test] + fn assemble_export_appends_footer_when_room_remains() { + // First two short entries fit; the long third entry would overflow, + // and the remaining headroom is enough for the truncation footer. + let entries = vec!["a\n".to_string(), "b\n".to_string(), "c".repeat(500)]; + let (out, written, truncated) = assemble_export("h\n", &entries, 200); + assert_eq!(written, 2); + assert!(truncated); + assert!(out.contains("[Export truncated")); + } + + // --- snowflake conversion --- + + #[test] + fn timestamp_ms_to_snowflake_known_value() { + // 2026-05-10 00:00:00 UTC = 1778572800000 ms since Unix epoch + // Discord ms = 1778572800000 - 1420070400000 = 358502400000 + // Snowflake = 358502400000 << 22 = 1503238553600000000 (approx) + let ts_ms: u64 = 1_778_572_800_000; + let snowflake = timestamp_ms_to_snowflake(ts_ms); + // Verify round-trip: extract timestamp back from snowflake + let extracted_ms = (snowflake.get() >> 22) + DISCORD_EPOCH_MS; + assert_eq!(extracted_ms, ts_ms); + } + + #[test] + fn timestamp_ms_to_snowflake_at_discord_epoch_is_one() { + // At exactly the Discord epoch, discord_ms=0, shifted=0, clamped to 1 + let snowflake = timestamp_ms_to_snowflake(DISCORD_EPOCH_MS); + assert_eq!(snowflake.get(), 1); + } + + #[test] + fn timestamp_ms_to_snowflake_before_epoch_saturates() { + // Timestamp before Discord epoch should saturate to 1 + let snowflake = timestamp_ms_to_snowflake(1_000_000_000_000); + assert_eq!(snowflake.get(), 1); + } + + // --- ExportFilter cap logic --- + + #[test] + fn export_filter_default_cap_is_100() { + // Default (no params) uses Limit(100) + let filter = ExportFilter::Limit(100); + let cap = match &filter { + ExportFilter::Limit(n) => *n, + _ => THREAD_EXPORT_MESSAGE_LIMIT, + }; + assert_eq!(cap, 100); + } + + #[test] + fn export_filter_all_cap_is_5000() { + let filter = ExportFilter::All; + let cap = match &filter { + ExportFilter::Limit(n) => *n, + _ => THREAD_EXPORT_MESSAGE_LIMIT, + }; + assert_eq!(cap, THREAD_EXPORT_MESSAGE_LIMIT); + assert_eq!(cap, 5000); + } + + #[test] + fn export_filter_limit_uses_custom_cap() { + let filter = ExportFilter::Limit(250); + let cap = match &filter { + ExportFilter::Limit(n) => *n, + _ => THREAD_EXPORT_MESSAGE_LIMIT, + }; + assert_eq!(cap, 250); + } + + #[test] + fn export_filter_after_uses_global_cap() { + let filter = ExportFilter::After(MessageId::new(123456789)); + let cap = match &filter { + ExportFilter::Limit(n) => *n, + _ => THREAD_EXPORT_MESSAGE_LIMIT, + }; + assert_eq!(cap, THREAD_EXPORT_MESSAGE_LIMIT); + } + // --- should_process_user_message tests (GIVEN/WHEN/THEN) --- // Tests the multibot-mentions gating logic extracted from EventHandler::message. // The bug in #481 was that other bots' messages were filtered by bot gating From db9059136ed8915122d3b4b05d5070a4025ae2b1 Mon Sep 17 00:00:00 2001 From: CHC-Agent Date: Wed, 13 May 2026 23:51:47 +0800 Subject: [PATCH 027/100] docs(cronjob): add version annotation to breaking change note (#809) The usercron_path base directory change was introduced in v0.8.2. Annotate the CAUTION block so users know which version requires migration. Co-authored-by: openab-triage[bot] --- docs/cronjob.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/cronjob.md b/docs/cronjob.md index 6c73ffbad..e754177ed 100644 --- a/docs/cronjob.md +++ b/docs/cronjob.md @@ -179,7 +179,7 @@ The path is relative to `$HOME/.openab/` (e.g. `"cronjob.toml"` resolves to `$HO > **New installations**: If `~/.openab/` does not exist yet, the scheduler silently skips the file and continues running. Once you create the directory and place `cronjob.toml` inside, it will be picked up automatically on the next tick — no restart required. > [!CAUTION] -> **Breaking Change** — `usercron_path` relative path base changed from `$HOME` to `$HOME/.openab/`. +> **Breaking Change (v0.8.2)** — `usercron_path` relative path base changed from `$HOME` to `$HOME/.openab/`. > If you are upgrading from a previous version, move your existing file: > ```bash > mkdir -p ~/.openab From 104bf7b028815465f8b905b821fef483e74c0016 Mon Sep 17 00:00:00 2001 From: "openab-app[bot]" <274185012+openab-app[bot]@users.noreply.github.com> Date: Wed, 13 May 2026 16:28:33 -0400 Subject: [PATCH 028/100] release: v0.8.3-beta.9 (#812) Co-authored-by: openab-app[bot] <274185012+openab-app[bot]@users.noreply.github.com> --- charts/openab/Chart.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/charts/openab/Chart.yaml b/charts/openab/Chart.yaml index 81e8d1e7c..fdfd69516 100644 --- a/charts/openab/Chart.yaml +++ b/charts/openab/Chart.yaml @@ -2,5 +2,5 @@ apiVersion: v2 name: openab description: A lightweight, secure, cloud-native ACP harness that bridges Discord and any ACP-compatible coding CLI. type: application -version: 0.8.3-beta.8 -appVersion: "0.8.3-beta.8" +version: 0.8.3-beta.9 +appVersion: "0.8.3-beta.9" From bb5b3285e6d00caf3f2d15aa467a39d07c1174f0 Mon Sep 17 00:00:00 2001 From: chaodufashi Date: Wed, 13 May 2026 17:29:28 -0400 Subject: [PATCH 029/100] fix(ci): add gateway/Cargo.lock to CI path triggers (#813) Explicitly list gateway/Cargo.lock in the pull_request paths filter so dependency-only updates (e.g. Dependabot PRs) are clearly covered by the gateway CI job. While gateway/** already matches this path, listing it explicitly makes the intent clear and prevents accidental removal if the glob is later narrowed. Closes #805 Co-authored-by: chaodu-agent --- .github/workflows/ci.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b2c13804a..59d15ae2a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -5,6 +5,7 @@ on: paths: - "src/**" - "gateway/**" + - "gateway/Cargo.lock" - "Cargo.toml" - "Cargo.lock" - "Dockerfile*" From d06bf26441f7f14af5a1f1bd13b84e5733a5fd47 Mon Sep 17 00:00:00 2001 From: chaodufashi Date: Thu, 14 May 2026 13:59:48 -0400 Subject: [PATCH 030/100] fix(slack): accept raw bot IDs in trustedBotIds (#814) Co-authored-by: chaodufashi --- docs/config-reference.md | 2 +- docs/messaging.md | 2 +- src/slack.rs | 86 +++++++++++++++++++++++++++++++++++----- 3 files changed, 79 insertions(+), 11 deletions(-) diff --git a/docs/config-reference.md b/docs/config-reference.md index 9ddaf40f7..a0eef687f 100644 --- a/docs/config-reference.md +++ b/docs/config-reference.md @@ -57,7 +57,7 @@ Slack adapter using Socket Mode. Requires both a Bot User OAuth Token and an App | `allow_all_users` | bool \| omit | auto-detect | Same behavior as Discord. | | `allowed_users` | string[] | `[]` | Slack user IDs (e.g. `U0123456789`). | | `allow_bot_messages` | string | `"off"` | Same as Discord. | -| `trusted_bot_ids` | string[] | `[]` | Slack Bot User IDs (`U...`). Find via: click bot profile → Copy member ID. | +| `trusted_bot_ids` | string[] | `[]` | Slack Bot User IDs (`U...`) or Bot IDs (`B...`). `U...` matching resolves event Bot IDs via Slack `bots.info`, so the bot token needs `users:read`. | | `allow_user_messages` | string | `"involved"` | Same as Discord. | | `max_bot_turns` | u32 | `100` | Same as Discord. | diff --git a/docs/messaging.md b/docs/messaging.md index 9172bca0c..40b3273d0 100644 --- a/docs/messaging.md +++ b/docs/messaging.md @@ -187,7 +187,7 @@ BotA in thread: here's my analysis | Key | Type | Default | Description | |-----|------|---------|-------------| | `allow_bot_messages` | string | `"off"` | `"off"` — ignore bot messages. `"mentions"` — only process bot messages that @mention this bot. `"all"` — process all bot messages (capped by `max_bot_turns`). | -| `trusted_bot_ids` | string[] | `[]` | Whitelist of bot IDs. When non-empty, only these bots pass the bot gate. Empty = any bot (mode permitting). Ignored when `allow_bot_messages = "off"`. | +| `trusted_bot_ids` | string[] | `[]` | Whitelist of bot IDs. For Slack, entries may be Bot User IDs (`U...`) or Bot IDs (`B...`); `U...` matching requires `users:read` so OpenAB can call `bots.info`. Empty = any bot (mode permitting). Ignored when `allow_bot_messages = "off"`. | | `max_bot_turns` | u32 | `20` | Max consecutive bot turns per thread before throttling. A human message resets the counter. | > **Safety:** When `allow_bot_messages = "all"`, a separate hardcoded cap of 10 consecutive bot turns applies regardless of `max_bot_turns`. diff --git a/src/slack.rs b/src/slack.rs index cbe101f2e..b1e0b534a 100644 --- a/src/slack.rs +++ b/src/slack.rs @@ -200,6 +200,10 @@ impl SlackAdapter { /// Resolve a Bot ID (B...) to Bot User ID (U...) via bots.info API. /// Cached permanently (bot IDs don't change). async fn resolve_bot_user_id(&self, bot_id: &str) -> Option { + if bot_id.is_empty() { + return None; + } + { let cache = self.bot_id_cache.lock().await; if let Some(user_id) = cache.get(bot_id) { @@ -210,6 +214,13 @@ impl SlackAdapter { let resp = self .api_post("bots.info", serde_json::json!({ "bot": bot_id })) .await + .inspect_err(|e| { + warn!( + bot_id, + error = %e, + "failed to resolve Slack bot ID via bots.info" + ) + }) .ok()?; let user_id = resp.get("bot")?.get("user_id")?.as_str()?.to_string(); @@ -221,6 +232,21 @@ impl SlackAdapter { Some(user_id) } + async fn trusted_bot_ids_contains( + &self, + trusted_bot_ids: &HashSet, + event_bot_id: &str, + ) -> bool { + if trusted_bot_ids.is_empty() { + return true; + } + if bot_id_matches_trusted(trusted_bot_ids, event_bot_id, None) { + return true; + } + let resolved = self.resolve_bot_user_id(event_bot_id).await; + bot_id_matches_trusted(trusted_bot_ids, event_bot_id, resolved.as_deref()) + } + /// Check whether the bot has participated in a Slack thread and whether /// other bots have also posted in it. /// Returns `(involved, other_bot_present)`. @@ -538,11 +564,11 @@ pub async fn run_slack_adapter( AllowBots::Mentions | AllowBots::All => { if !trusted_bot_ids.is_empty() { let event_bot_id = event["bot_id"].as_str().unwrap_or(""); - let resolved = adapter.resolve_bot_user_id(event_bot_id).await; - let is_trusted = resolved.as_ref() - .is_some_and(|uid| trusted_bot_ids.contains(uid.as_str())); + let is_trusted = adapter + .trusted_bot_ids_contains(&trusted_bot_ids, event_bot_id) + .await; if !is_trusted { - debug!(event_bot_id, resolved = ?resolved, "bot not in trusted_bot_ids, ignoring app_mention"); + debug!(event_bot_id, "bot not in trusted_bot_ids, ignoring app_mention"); continue; } } @@ -713,12 +739,11 @@ pub async fn run_slack_adapter( } // Check trusted_bot_ids if !trusted_bot_ids.is_empty() { - let resolved = adapter.resolve_bot_user_id(event_bot_id).await; - let is_trusted = resolved - .as_ref() - .is_some_and(|uid| trusted_bot_ids.contains(uid.as_str())); + let is_trusted = adapter + .trusted_bot_ids_contains(&trusted_bot_ids, event_bot_id) + .await; if !is_trusted { - debug!(event_bot_id, resolved = ?resolved, "bot not in trusted_bot_ids, ignoring"); + debug!(event_bot_id, "bot not in trusted_bot_ids, ignoring"); continue; } } @@ -1171,6 +1196,19 @@ fn strip_mime_params(mimetype: &str) -> &str { mimetype.split(';').next().unwrap_or(mimetype).trim() } +fn bot_id_matches_trusted( + trusted_bot_ids: &HashSet, + event_bot_id: &str, + resolved_user_id: Option<&str>, +) -> bool { + if event_bot_id.is_empty() { + return false; + } + + trusted_bot_ids.contains(event_bot_id) + || resolved_user_id.is_some_and(|uid| trusted_bot_ids.contains(uid)) +} + /// True only when a Slack non-bot event represents a real user message /// that should reset the bot-turn counter. /// @@ -1364,6 +1402,36 @@ mod tests { assert_eq!(strip_mime_params(" text/plain "), "text/plain"); } + // --- bot_id_matches_trusted tests --- + + #[test] + fn trusted_bot_ids_accepts_raw_slack_bot_id() { + let trusted = HashSet::from(["B123BOT".to_string()]); + assert!(bot_id_matches_trusted(&trusted, "B123BOT", None)); + } + + #[test] + fn trusted_bot_ids_accepts_resolved_bot_user_id() { + let trusted = HashSet::from(["U123BOT".to_string()]); + assert!(bot_id_matches_trusted( + &trusted, + "B123BOT", + Some("U123BOT") + )); + } + + #[test] + fn trusted_bot_ids_rejects_unknown_bot_when_resolution_fails() { + let trusted = HashSet::from(["U123BOT".to_string()]); + assert!(!bot_id_matches_trusted(&trusted, "B999BOT", None)); + } + + #[test] + fn trusted_bot_ids_rejects_empty_event_bot_id() { + let trusted = HashSet::from(["".to_string()]); + assert!(!bot_id_matches_trusted(&trusted, "", None)); + } + /// Per-thread streaming: ON by default, OFF when another bot is present (#534). #[test] fn streaming_per_thread() { From 1f8864c2a620eb5aeed46da33bbc1c6c53e04cbd Mon Sep 17 00:00:00 2001 From: howie <2318485+howie@users.noreply.github.com> Date: Fri, 15 May 2026 02:25:11 +0800 Subject: [PATCH 031/100] fix(slack): match both bare and labelled mention forms in mentions_bot (#808) - Add text_mentions_uid() helper accepting both <@UID> and <@UID|handle> - Rewrite resolve_slack_mentions() to strip both forms symmetrically - Add .inspect_err() warning on get_bot_user_id failure - Add doc comment to pool.rs with_connection Fixes #800 Co-authored-by: chaodufashi --- src/acp/pool.rs | 4 ++ src/slack.rs | 134 +++++++++++++++++++++++++++++++++++++++++++++--- 2 files changed, 132 insertions(+), 6 deletions(-) diff --git a/src/acp/pool.rs b/src/acp/pool.rs index 6ccd3631d..42fc1113b 100644 --- a/src/acp/pool.rs +++ b/src/acp/pool.rs @@ -257,6 +257,10 @@ impl SessionPool { } /// Get mutable access to a connection. Caller must have called get_or_create first. + /// + /// Only the per-connection `Mutex` is held during `f`; the pool-level + /// `RwLock` is acquired briefly (read-only) to look up the `Arc` and then + /// released, so other connections can be used concurrently. pub async fn with_connection(&self, thread_id: &str, f: F) -> Result where F: for<'a> FnOnce( diff --git a/src/slack.rs b/src/slack.rs index b1e0b534a..47d4c42d1 100644 --- a/src/slack.rs +++ b/src/slack.rs @@ -117,6 +117,7 @@ impl SlackAdapter { .ok_or_else(|| anyhow!("no user_id in auth.test response")) }) .await + .inspect_err(|e| warn!(error = %e, "bot user ID unavailable; mention detection may suppress bot messages under Mentions mode")) .ok() .map(|s| s.as_str()) } @@ -307,7 +308,7 @@ impl SlackAdapter { let parent_mentions_bot = messages .first() .and_then(|m| m["text"].as_str()) - .is_some_and(|text| text.contains(&format!("<@{bot_id}>"))); + .is_some_and(|text| text_mentions_uid(text, bot_id)); let bot_posted = messages.iter().any(|m| m["user"].as_str() == Some(bot_id)); @@ -607,7 +608,7 @@ pub async fn run_slack_adapter( let bot_uid_opt = adapter.get_bot_user_id().await.map(|s| s.to_string()); let mentions_bot = bot_uid_opt .as_ref() - .is_some_and(|bot_uid| msg_text.contains(&format!("<@{bot_uid}>"))); + .is_some_and(|bot_uid| text_mentions_uid(msg_text, bot_uid)); let is_dm = channel_id.starts_with('D'); let event_user_id = event["user"].as_str(); let is_own_bot_msg = is_bot @@ -1168,15 +1169,41 @@ async fn handle_message( } } -/// Strip only the bot's own `<@BOT_UID>` trigger mention. +/// Strip all occurrences of the bot's own `<@BOT_UID>` or `<@BOT_UID|handle>` mention. /// Other users' mentions stay intact so the LLM can @-mention them back. /// If the bot UID isn't known, fall back to returning the text trimmed — /// safer than stripping all mentions and losing user addressability. fn resolve_slack_mentions(text: &str, bot_id: Option<&str>) -> String { - match bot_id { - Some(id) => text.replace(&format!("<@{id}>"), "").trim().to_string(), - None => text.trim().to_string(), + let Some(id) = bot_id else { + return text.trim().to_string(); + }; + let prefix = format!("<@{id}"); + let mut out = String::with_capacity(text.len()); + let mut s = text; + while let Some(pos) = s.find(&prefix) { + let after = &s[pos + prefix.len()..]; + match after.as_bytes().first() { + Some(b'>') => { + out.push_str(&s[..pos]); + s = &after[1..]; + } + Some(b'|') => { + if let Some(close) = after.find('>') { + out.push_str(&s[..pos]); + s = &after[close + 1..]; + } else { + out.push_str(&s[..pos + prefix.len()]); + s = after; + } + } + _ => { + out.push_str(&s[..pos + prefix.len()]); + s = after; + } + } } + out.push_str(s); + out.trim().to_string() } /// Pick the best download URL for a Slack file object. `url_private_download` @@ -1196,6 +1223,18 @@ fn strip_mime_params(mimetype: &str) -> &str { mimetype.split(';').next().unwrap_or(mimetype).trim() } +/// Returns `true` if `text` contains a Slack user mention for `uid`. +/// +/// Accepts both `<@U...>` (bare) and `<@U...|handle>` (labelled) wire forms. +/// Slack (and bots addressing peers) can emit the labelled form; `<@UID>` is +/// not a substring of `<@UID|handle>`, so a bare `contains("<@UID>")` silently +/// misses it. +fn text_mentions_uid(text: &str, uid: &str) -> bool { + let prefix = format!("<@{uid}"); + text.match_indices(&prefix) + .any(|(i, _)| matches!(text.as_bytes().get(i + prefix.len()), Some(b'>') | Some(b'|'))) +} + fn bot_id_matches_trusted( trusted_bot_ids: &HashSet, event_bot_id: &str, @@ -1291,6 +1330,89 @@ mod tests { assert_eq!(out, "<@U1BOT> hi <@U2ALICE>"); } + /// Labelled form of another user's mention (`<@UID|handle>`) is preserved. + #[test] + fn resolve_mentions_preserves_labelled_other_user_mention() { + let out = resolve_slack_mentions("<@U1BOT> say hi to <@U2ALICE|alice>", Some("U1BOT")); + assert_eq!(out, "say hi to <@U2ALICE|alice>"); + } + + /// Labelled form `<@UID|handle>` is stripped the same as bare form. + #[test] + fn resolve_mentions_strips_labelled_bot_mention() { + let out = resolve_slack_mentions("<@U1BOT|my-bot> hello", Some("U1BOT")); + assert_eq!(out, "hello"); + } + + /// Labelled form mid-sentence is stripped and surrounding text preserved. + #[test] + fn resolve_mentions_strips_labelled_mid_sentence() { + let out = resolve_slack_mentions("please ask <@U1BOT|handle> to run", Some("U1BOT")); + assert_eq!(out, "please ask to run"); + } + + /// Mixed bare and labelled forms of the same UID in one string are both stripped. + #[test] + fn resolve_mentions_strips_mixed_bare_and_labelled() { + let out = resolve_slack_mentions("<@U1BOT> and <@U1BOT|handle> run", Some("U1BOT")); + assert_eq!(out, "and run"); + } + + /// Malformed unclosed `<@UID|label` (no closing `>`) is preserved verbatim. + #[test] + fn resolve_mentions_malformed_unclosed_label_preserved() { + let out = resolve_slack_mentions("ask <@U1BOT|nolabel to run", Some("U1BOT")); + assert!(out.contains("<@U1BOT")); + } + + #[test] + fn resolve_mentions_preserves_longer_uid_prefix() { + let out = resolve_slack_mentions("<@U1BOTX> hello", Some("U1BOT")); + assert_eq!(out, "<@U1BOTX> hello"); + } + + // --- text_mentions_uid tests --- + + #[test] + fn mentions_uid_bare_form() { + assert!(text_mentions_uid("<@U123BOT> hello", "U123BOT")); + } + + #[test] + fn mentions_uid_labelled_form() { + assert!(text_mentions_uid("<@U123BOT|my-bot> hello", "U123BOT")); + } + + #[test] + fn mentions_uid_labelled_form_mid_sentence() { + assert!(text_mentions_uid("please ask <@U123BOT|handle> to run", "U123BOT")); + } + + #[test] + fn mentions_uid_no_match() { + assert!(!text_mentions_uid("hello world", "U123BOT")); + } + + #[test] + fn mentions_uid_no_false_positive_on_uid_prefix() { + assert!(!text_mentions_uid("<@U123BOT> hello", "U123")); + } + + #[test] + fn mentions_uid_second_mention_matches() { + assert!(text_mentions_uid("<@U999OTHER> and <@U123BOT>", "U123BOT")); + } + + #[test] + fn mentions_uid_empty_label_form() { + assert!(text_mentions_uid("<@U123BOT|> hello", "U123BOT")); + } + + #[test] + fn mentions_uid_truncated_no_closing_delimiter() { + assert!(!text_mentions_uid("<@U123BOT", "U123BOT")); + } + // --- is_plain_user_message tests (regression for openabdev/openab#497 parity) --- /// Empty message text never counts as a user message (regardless of subtype). From 0079352b8d37566c765b8fed68164fe72f988055 Mon Sep 17 00:00:00 2001 From: Can Date: Fri, 15 May 2026 08:30:03 +0800 Subject: [PATCH 032/100] feat(gateway): Google Chat attachment support (image / file / audio + STT) (#762) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(gateway): inbound attachment support for Google Chat Implements image / text file / audio download from Google Chat via Media API + service account token, following the PR #731 base64 pattern. Changes: - GoogleChatMessage: parse attachment[] array (Attachment / AttachmentDataRef / DriveDataRef structs) - GoogleChatMediaRef enum: Image / File / Audio variants for typed dispatch - parse_attachments(): branches on contentType prefix, skips DRIVE_FILE source - download_googlechat_image(): resize → 1200px JPEG q75, max 10MB, GIF preserved - download_googlechat_file(): text extension whitelist (.txt/.md/.py/...), max 512KB - download_googlechat_audio(): forwarded as-is for core STT pipeline, max 25MB - media_url(): percent-encode resource_name as path segment - webhook handler: parses attachments, async-downloads via adapter token, populates Content.attachments - Empty-text events with attachments are now forwarded (previously dropped) - Tests: 11 new (parse, download success/skip/oversized, URL encoding) Refs: openabdev/openab#731 (Feishu pattern) Co-Authored-By: Claude Opus 4.6 * feat(core): STT for Custom Gateway audio attachments Extends src/gateway.rs attachment handling to transcribe audio attachments via the existing STT pipeline (previously only Discord/Slack adapters went through download_and_transcribe; Custom Gateway adapters got no audio path even though stt::transcribe was available). When a gateway adapter (Feishu, Google Chat, etc.) sends an Attachment with attachment_type = "audio", core now: 1. Decodes base64 → audio bytes 2. Calls stt::transcribe with the configured SttConfig 3. Wraps the transcript as a ContentBlock::Text: "[Voice message transcript]: ..." The audio branch is gated on stt_config.enabled — if STT is disabled in config, audio attachments fall through unchanged (same as before). Threads stt_config through GatewayParams and run_gateway_adapter. This closes the audio attachment gap left by the (now-closed) PR #726 without re-introducing the HTTP MediaStore proxy approach. Pairs with the Google Chat adapter audio download (separate PR) — once both land, Google Chat voice/audio attachments work end-to-end. Co-Authored-By: Claude Opus 4.6 * fix(gateway): address googlechat attachment review feedback Addresses canyugs/openab#4 must-fix items: #1+#2 Webhook timeout safety: - Spawn background tokio task for attachment downloads so the webhook returns 200 within Google Chat's 30s deadline regardless of how long downloads take. - Add 30s per-request timeout to all Media API GET calls — a single hung connection can no longer stall the download task indefinitely. - Refactor event emission into send_googlechat_event helper to share between sync (no-attachment) and async (background download) paths. #4 Text file caps (matches Discord/Slack): - TEXT_FILE_COUNT_CAP = 5: skip text files past the 5th with a warning. - TEXT_TOTAL_CAP = 1 MB: skip text files that would push the running aggregate past 1 MB with a warning. - Image and audio attachments are not capped (same as Discord/Slack). #6 STT silent failure: - When stt::transcribe returns None, push a fallback ContentBlock::Text ("[Voice message — transcription failed for ...]") so the agent knows a voice message was attempted and can ask the user to re-send. Previously the failure was silent and confusing. Skipped from issue #4: #3 (streaming download), #5 (cross-adapter refactor — adapters stay independent per design), #7-#10 (cosmetic). Co-Authored-By: Claude Opus 4.6 * fix(gateway): correct media_url encoding, remove lossy UTF-8 round-trip, add spawn panic logging - media_url: preserve `/` as literal path separators per Google Chat Media API's RFC 6570 reserved expansion (`{+resourceName}`). Previously all `/` were encoded as `%2F` which is fragile. - download_googlechat_file: base64-encode raw bytes directly instead of round-tripping through String::from_utf8_lossy which silently replaces invalid bytes with U+FFFD. - Spawned attachment download task: log an error if the task panics so silent message drops are diagnosable. Co-Authored-By: Claude Opus 4.6 * fix(gateway): address review — remove .env from whitelist, add audio decode fallback - Remove `"env"` from TEXT_EXTS whitelist to prevent credential exposure if a user accidentally uploads a .env file. - Audio base64 decode failure now produces a fallback text block ("[Voice message — decode failed for X]") instead of silently dropping. - Audio attachments when STT is disabled now log at debug level instead of being silently discarded. Co-Authored-By: Claude Opus 4.6 * refactor(gateway): simplify text file cap logic, defer text allocation to spawn path - Flatten nested if/else in File download cap check using early continue, improving readability. - Defer text .to_string() allocation to the tokio::spawn path so the no-attachment fast path avoids a heap allocation. Co-Authored-By: Claude Opus 4.6 * refactor(gateway): address remaining review nits - Replace double-spawn panic logging with single spawn + catch_unwind — more idiomatic, same observability. - Remove unused content_type from Image/File variants of GoogleChatMediaRef; only Audio needs it. Drops #[allow(dead_code)] on the enum. - Pass remaining aggregate budget to download_googlechat_file so Content-Length is checked against the budget before downloading, avoiding wasted bandwidth on files that would exceed the cap. Co-Authored-By: Claude Opus 4.6 * fix(gateway): enforce aggregate budget on post-download check, log skipped video attachments - download_googlechat_file: post-download size check now uses max_size (min of FILE_MAX_DOWNLOAD and remaining_budget) instead of only FILE_MAX_DOWNLOAD, ensuring TEXT_TOTAL_CAP is respected even when Content-Length header is absent. - parse_attachments: video/ MIME type now gets an explicit info! log and is skipped early, instead of silently failing the text extension whitelist downstream. --------- Co-authored-by: Claude Opus 4.6 Co-authored-by: chaodu-agent --- docs/google-chat.md | 8 +- gateway/src/adapters/googlechat.rs | 747 ++++++++++++++++++++++++++++- src/gateway.rs | 45 +- 3 files changed, 770 insertions(+), 30 deletions(-) diff --git a/docs/google-chat.md b/docs/google-chat.md index d51253c93..bcdc68c35 100644 --- a/docs/google-chat.md +++ b/docs/google-chat.md @@ -143,11 +143,17 @@ working_dir = "/home/agent" - Inline code, fenced code blocks: pass through unchanged - Tables and other unsupported syntax pass through as-is - **Streaming (edit_message)** — when OAB streaming is enabled, the bot edits its initial reply in-place as tokens arrive (typewriter effect) +- **Inbound attachments** — image, text file, and audio attachments are downloaded via Google Chat Media API and forwarded to the agent as base64 (PR #731 pattern): + - Images: resized to ≤1200px JPEG (q75); GIFs preserved. Max 10 MB. + - Text files: only known text extensions (`.txt`, `.md`, `.json`, `.py`, `.rs`, etc.). Max 512 KB. + - Audio: forwarded as-is for STT processing by core. Max 25 MB. + - Drive-sourced attachments are skipped (require separate Drive API integration). ### Not Supported - **Reactions** — Google Chat API does not support message reactions on behalf of bots -- **File/image attachments** — not yet implemented +- **Outbound attachments** — bot cannot send image/file attachments back to the user yet +- **Drive-linked attachments** — only `UPLOADED_CONTENT` source is handled; `DRIVE_FILE` source skipped ## Environment Variables (Gateway) diff --git a/gateway/src/adapters/googlechat.rs b/gateway/src/adapters/googlechat.rs index 90442f975..25ddd0f39 100644 --- a/gateway/src/adapters/googlechat.rs +++ b/gateway/src/adapters/googlechat.rs @@ -12,6 +12,19 @@ use tracing::{error, info, warn}; pub const GOOGLE_CHAT_API_BASE: &str = "https://chat.googleapis.com/v1"; const GOOGLE_CHAT_MESSAGE_LIMIT: usize = 4096; +const IMAGE_MAX_DIMENSION_PX: u32 = 1200; +const IMAGE_JPEG_QUALITY: u8 = 75; +const IMAGE_MAX_DOWNLOAD: u64 = 10 * 1024 * 1024; // 10 MB +const FILE_MAX_DOWNLOAD: u64 = 512 * 1024; // 512 KB +const AUDIO_MAX_DOWNLOAD: u64 = 25 * 1024 * 1024; // 25 MB +/// Per-request timeout for Google Chat Media API downloads. Prevents a hung +/// connection from blocking the spawned download task indefinitely. +const MEDIA_REQUEST_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(30); +/// Cap on text file attachments per message (matches Discord/Slack). +const TEXT_FILE_COUNT_CAP: usize = 5; +/// Cap on aggregate text file bytes per message (matches Discord/Slack 1 MB). +const TEXT_TOTAL_CAP: u64 = 1024 * 1024; + // --- Google Chat types (v2 envelope format) --- #[derive(Debug, Deserialize)] @@ -42,6 +55,52 @@ pub struct GoogleChatMessage { pub sender: Option, pub thread: Option, pub space: Option, + #[serde(default)] + pub attachment: Vec, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct GoogleChatAttachment { + #[allow(dead_code)] + pub name: Option, + pub content_name: Option, + pub content_type: Option, + pub source: Option, + pub attachment_data_ref: Option, + #[allow(dead_code)] + pub drive_data_ref: Option, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AttachmentDataRef { + pub resource_name: Option, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +#[allow(dead_code)] +pub struct DriveDataRef { + pub drive_file_id: Option, +} + +/// Reference to media that needs async download after webhook parse. +#[derive(Debug, Clone)] +pub enum GoogleChatMediaRef { + Image { + resource_name: String, + content_name: String, + }, + File { + resource_name: String, + content_name: String, + }, + Audio { + resource_name: String, + content_name: String, + content_type: String, + }, } #[derive(Debug, Deserialize)] @@ -440,7 +499,11 @@ pub async fn webhook( .as_deref() .or(msg.text.as_deref()) .unwrap_or(""); - if text.trim().is_empty() { + + let media_refs = parse_attachments(&msg.attachment); + + // Drop event only if BOTH text and attachments are empty + if text.trim().is_empty() && media_refs.is_empty() { return empty_json_response(); } @@ -475,28 +538,178 @@ pub async fn webhook( .unwrap_or(&msg.name) .to_string(); - let gw_event = GatewayEvent::new( + // No attachments → emit event synchronously and respond 200 + if media_refs.is_empty() { + send_googlechat_event( + &state, + &space_name, + space_type, + thread_id, + &sender_id, + &sender_name, + &display_name, + text, + &message_id, + Vec::new(), + ); + return empty_json_response(); + } + + // Has attachments — spawn background task so the webhook returns 200 within + // Google Chat's 30 s deadline regardless of how long downloads take. + let text = text.to_string(); + let state = state.clone(); + let spawn_space = space_name.clone(); + tokio::spawn(async move { + use futures_util::FutureExt; + let result = std::panic::AssertUnwindSafe(async { + let mut downloaded: Vec = Vec::new(); + let mut text_file_count: usize = 0; + let mut text_file_bytes: u64 = 0; + if let Some(ref adapter) = state.google_chat { + if let Some(token) = adapter.get_token().await { + for media_ref in &media_refs { + let attachment = match media_ref { + GoogleChatMediaRef::Image { + resource_name, + content_name, + .. + } => { + download_googlechat_image( + &adapter.client, + &token, + &adapter.api_base, + resource_name, + content_name, + ) + .await + } + GoogleChatMediaRef::File { + resource_name, + content_name, + .. + } => { + if text_file_count >= TEXT_FILE_COUNT_CAP { + warn!(content_name = %content_name, cap = TEXT_FILE_COUNT_CAP, "googlechat text file count cap reached, skipping"); + continue; + } + let remaining = TEXT_TOTAL_CAP.saturating_sub(text_file_bytes); + let att = download_googlechat_file( + &adapter.client, + &token, + &adapter.api_base, + resource_name, + content_name, + remaining, + ) + .await; + let Some(att) = att else { continue }; + text_file_count += 1; + text_file_bytes += att.size; + Some(att) + } + GoogleChatMediaRef::Audio { + resource_name, + content_name, + content_type, + } => { + download_googlechat_audio( + &adapter.client, + &token, + &adapter.api_base, + resource_name, + content_name, + content_type, + ) + .await + } + }; + if let Some(att) = attachment { + downloaded.push(att); + } + } + } else { + warn!("googlechat: no token available for attachment download"); + } + } + + // If text is empty AND every attachment failed to download, drop the event. + if text.trim().is_empty() && downloaded.is_empty() { + warn!( + space = %space_name, + "googlechat: empty text + all attachments failed, dropping event" + ); + return; + } + + send_googlechat_event( + &state, + &space_name, + space_type, + thread_id, + &sender_id, + &sender_name, + &display_name, + &text, + &message_id, + downloaded, + ); + }).catch_unwind().await; + if let Err(e) = result { + error!(space = %spawn_space, "googlechat attachment download task panicked: {e:?}"); + } + }); + + empty_json_response() +} + +#[allow(clippy::too_many_arguments)] +fn send_googlechat_event( + state: &Arc, + space_name: &str, + space_type: String, + thread_id: Option, + sender_id: &str, + sender_name: &str, + display_name: &str, + text: &str, + message_id: &str, + attachments: Vec, +) { + let mut gw_event = GatewayEvent::new( "googlechat", ChannelInfo { - id: space_name.clone(), + id: space_name.to_string(), channel_type: space_type, thread_id, }, SenderInfo { - id: sender_id, - name: sender_name.clone(), - display_name, + id: sender_id.to_string(), + name: sender_name.to_string(), + display_name: display_name.to_string(), is_bot: false, }, text, - &message_id, + message_id, vec![], ); + gw_event.content.attachments = attachments; - let json = serde_json::to_string(&gw_event).unwrap(); - info!(space = %space_name, sender = %sender_name, "googlechat → gateway"); + let attachment_count = gw_event.content.attachments.len(); + let json = match serde_json::to_string(&gw_event) { + Ok(j) => j, + Err(e) => { + error!(error = %e, "googlechat: failed to serialize GatewayEvent"); + return; + } + }; + info!( + space = %space_name, + sender = %sender_name, + attachment_count, + "googlechat → gateway" + ); let _ = state.event_tx.send(json); - empty_json_response() } fn empty_json_response() -> axum::response::Response { @@ -905,6 +1118,254 @@ fn split_text(text: &str, limit: usize) -> Vec<&str> { chunks } +// --- Attachment parsing & download --- + +/// Whitelist of text-like file extensions for `download_googlechat_file`. +const TEXT_EXTS: &[&str] = &[ + "txt", "csv", "log", "md", "json", "jsonl", "yaml", "yml", "toml", "xml", + "rs", "py", "js", "ts", "jsx", "tsx", "go", "java", "c", "cpp", "h", "hpp", + "rb", "sh", "bash", "sql", "html", "css", "ini", "cfg", "conf", +]; + +/// Parse Google Chat attachment array into media references for async download. +/// +/// Skips Drive-sourced attachments (different download API), and unknown +/// content types. Branches on `contentType` prefix to bucket into image / +/// audio / file. +fn parse_attachments(attachments: &[GoogleChatAttachment]) -> Vec { + let mut refs = Vec::new(); + for att in attachments { + // Only handle UPLOADED_CONTENT (Drive needs separate Drive API call) + if att.source.as_deref() != Some("UPLOADED_CONTENT") { + continue; + } + let resource_name = match att + .attachment_data_ref + .as_ref() + .and_then(|d| d.resource_name.clone()) + { + Some(rn) => rn, + None => continue, + }; + let content_type = att.content_type.clone().unwrap_or_default(); + let content_name = att.content_name.clone().unwrap_or_else(|| "file".into()); + + if content_type.starts_with("image/") { + refs.push(GoogleChatMediaRef::Image { + resource_name, + content_name, + }); + } else if content_type.starts_with("audio/") { + refs.push(GoogleChatMediaRef::Audio { + resource_name, + content_name, + content_type, + }); + } else if content_type.starts_with("video/") { + info!(content_name = %content_name, content_type = %content_type, "googlechat: video attachment skipped (not yet supported)"); + } else { + refs.push(GoogleChatMediaRef::File { + resource_name, + content_name, + }); + } + } + refs +} + +/// Resize image so longest side ≤ 1200px, then encode as JPEG. +/// GIFs are passed through unchanged to preserve animation. +fn resize_and_compress(raw: &[u8]) -> Result<(Vec, String), image::ImageError> { + use image::ImageReader; + use std::io::Cursor; + + let reader = ImageReader::new(Cursor::new(raw)).with_guessed_format()?; + let format = reader.format(); + if format == Some(image::ImageFormat::Gif) { + return Ok((raw.to_vec(), "image/gif".to_string())); + } + let img = reader.decode()?; + let (w, h) = (img.width(), img.height()); + let img = if w > IMAGE_MAX_DIMENSION_PX || h > IMAGE_MAX_DIMENSION_PX { + let max_side = std::cmp::max(w, h); + let ratio = f64::from(IMAGE_MAX_DIMENSION_PX) / f64::from(max_side); + let new_w = (f64::from(w) * ratio) as u32; + let new_h = (f64::from(h) * ratio) as u32; + img.resize(new_w, new_h, image::imageops::FilterType::Lanczos3) + } else { + img + }; + let mut buf = Cursor::new(Vec::new()); + let encoder = image::codecs::jpeg::JpegEncoder::new_with_quality(&mut buf, IMAGE_JPEG_QUALITY); + img.write_with_encoder(encoder)?; + Ok((buf.into_inner(), "image/jpeg".to_string())) +} + +/// Build the Media API URL for a given resource_name. +/// Google Chat Media API uses `{+resourceName}` (RFC 6570 reserved expansion), +/// so `/` must stay literal while other special chars are percent-encoded. +fn media_url(api_base: &str, resource_name: &str) -> String { + let encoded: String = resource_name + .bytes() + .map(|b| match b { + b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' | b'/' => { + (b as char).to_string() + } + _ => format!("%{:02X}", b), + }) + .collect(); + format!("{}/media/{}?alt=media", api_base, encoded) +} + +/// Download an image attachment via Google Chat Media API → resize/compress → base64. +pub async fn download_googlechat_image( + client: &reqwest::Client, + token: &str, + api_base: &str, + resource_name: &str, + content_name: &str, +) -> Option { + let url = media_url(api_base, resource_name); + let resp = match client.get(&url).bearer_auth(token).timeout(MEDIA_REQUEST_TIMEOUT).send().await { + Ok(r) => r, + Err(e) => { + warn!(content_name, error = %e, "googlechat image download failed"); + return None; + } + }; + if !resp.status().is_success() { + warn!(content_name, status = %resp.status(), "googlechat image download failed"); + return None; + } + if let Some(cl) = resp.headers().get(reqwest::header::CONTENT_LENGTH) { + if let Ok(size) = cl.to_str().unwrap_or("0").parse::() { + if size > IMAGE_MAX_DOWNLOAD { + warn!(content_name, size, "googlechat image Content-Length exceeds 10MB limit"); + return None; + } + } + } + let bytes = resp.bytes().await.ok()?; + if bytes.len() as u64 > IMAGE_MAX_DOWNLOAD { + warn!(content_name, size = bytes.len(), "googlechat image exceeds 10MB limit"); + return None; + } + let (compressed, mime) = match resize_and_compress(&bytes) { + Ok(v) => v, + Err(e) => { + warn!(content_name, error = %e, "googlechat image resize failed"); + return None; + } + }; + use base64::Engine; + let data = base64::engine::general_purpose::STANDARD.encode(&compressed); + Some(crate::schema::Attachment { + attachment_type: "image".into(), + filename: content_name.to_string(), + mime_type: mime, + data, + size: compressed.len() as u64, + }) +} + +/// Download a text-like file via Google Chat Media API → base64. +/// Non-text extensions are skipped to avoid sending binary garbage to the model. +pub async fn download_googlechat_file( + client: &reqwest::Client, + token: &str, + api_base: &str, + resource_name: &str, + content_name: &str, + remaining_budget: u64, +) -> Option { + let ext = content_name.rsplit('.').next().unwrap_or("").to_lowercase(); + if !TEXT_EXTS.contains(&ext.as_str()) { + tracing::debug!(content_name, "skipping non-text googlechat file attachment"); + return None; + } + let max_size = FILE_MAX_DOWNLOAD.min(remaining_budget); + let url = media_url(api_base, resource_name); + let resp = match client.get(&url).bearer_auth(token).timeout(MEDIA_REQUEST_TIMEOUT).send().await { + Ok(r) => r, + Err(e) => { + warn!(content_name, error = %e, "googlechat file download failed"); + return None; + } + }; + if !resp.status().is_success() { + warn!(content_name, status = %resp.status(), "googlechat file download failed"); + return None; + } + if let Some(cl) = resp.headers().get(reqwest::header::CONTENT_LENGTH) { + if let Ok(size) = cl.to_str().unwrap_or("0").parse::() { + if size > max_size { + warn!(content_name, size, limit = max_size, "googlechat file Content-Length exceeds limit"); + return None; + } + } + } + let bytes = resp.bytes().await.ok()?; + if bytes.len() as u64 > max_size { + warn!(content_name, size = bytes.len(), limit = max_size, "googlechat file exceeds size limit"); + return None; + } + use base64::Engine; + let data = base64::engine::general_purpose::STANDARD.encode(&bytes); + Some(crate::schema::Attachment { + attachment_type: "text_file".into(), + filename: content_name.to_string(), + mime_type: "text/plain".into(), + data, + size: bytes.len() as u64, + }) +} + +/// Download an audio attachment as-is (no resize/transcode) → base64. +/// Core's STT pipeline (when available) consumes this as `audio` attachment_type. +pub async fn download_googlechat_audio( + client: &reqwest::Client, + token: &str, + api_base: &str, + resource_name: &str, + content_name: &str, + content_type: &str, +) -> Option { + let url = media_url(api_base, resource_name); + let resp = match client.get(&url).bearer_auth(token).timeout(MEDIA_REQUEST_TIMEOUT).send().await { + Ok(r) => r, + Err(e) => { + warn!(content_name, error = %e, "googlechat audio download failed"); + return None; + } + }; + if !resp.status().is_success() { + warn!(content_name, status = %resp.status(), "googlechat audio download failed"); + return None; + } + if let Some(cl) = resp.headers().get(reqwest::header::CONTENT_LENGTH) { + if let Ok(size) = cl.to_str().unwrap_or("0").parse::() { + if size > AUDIO_MAX_DOWNLOAD { + warn!(content_name, size, "googlechat audio Content-Length exceeds 25MB limit"); + return None; + } + } + } + let bytes = resp.bytes().await.ok()?; + if bytes.len() as u64 > AUDIO_MAX_DOWNLOAD { + warn!(content_name, size = bytes.len(), "googlechat audio exceeds 25MB limit"); + return None; + } + use base64::Engine; + let data = base64::engine::general_purpose::STANDARD.encode(&bytes); + Some(crate::schema::Attachment { + attachment_type: "audio".into(), + filename: content_name.to_string(), + mime_type: content_type.to_string(), + data, + size: bytes.len() as u64, + }) +} + #[cfg(test)] mod tests { use super::*; @@ -1372,8 +1833,8 @@ mod tests { }, content: Content { content_type: "text".into(), + attachments: Vec::new(), text: "hello".into(), - attachments: vec![], }, command: None, request_id: Some("req_123".into()), @@ -1416,8 +1877,8 @@ mod tests { }, content: Content { content_type: "text".into(), + attachments: Vec::new(), text: "hello".into(), - attachments: vec![], }, command: None, request_id: Some("req_fail".into()), @@ -1464,8 +1925,8 @@ mod tests { }, content: Content { content_type: "text".into(), + attachments: Vec::new(), text: "".into(), - attachments: vec![], }, command: None, request_id: Some("req_empty".into()), @@ -1509,8 +1970,8 @@ mod tests { }, content: Content { content_type: "text".into(), + attachments: Vec::new(), text: long_text, - attachments: vec![], }, command: None, request_id: Some("req_multi_fail".into()), @@ -1544,8 +2005,8 @@ mod tests { }, content: Content { content_type: "text".into(), + attachments: Vec::new(), text: "hello".into(), - attachments: vec![], }, command: None, request_id: Some("req_notoken".into()), @@ -1590,8 +2051,8 @@ mod tests { }, content: Content { content_type: "text".into(), + attachments: Vec::new(), text: "updated text".into(), - attachments: vec![], }, command: Some("edit_message".into()), request_id: None, @@ -1633,8 +2094,8 @@ mod tests { }, content: Content { content_type: "text".into(), + attachments: Vec::new(), text: long_text, - attachments: vec![], }, command: None, request_id: Some("req_multi".into()), @@ -1691,8 +2152,8 @@ mod tests { }, content: Content { content_type: "text".into(), + attachments: Vec::new(), text: long_text, - attachments: vec![], }, command: None, request_id: Some("req_partial".into()), @@ -1710,4 +2171,254 @@ mod tests { let err = resp.error.expect("partial failure should set error"); assert!(err.contains("500")); } + + // --- Attachment parsing tests --- + + fn make_attachment( + source: &str, + content_type: &str, + content_name: &str, + resource_name: Option<&str>, + ) -> GoogleChatAttachment { + GoogleChatAttachment { + name: Some("spaces/SP/messages/MSG/attachments/ATT".into()), + content_name: Some(content_name.into()), + content_type: Some(content_type.into()), + source: Some(source.into()), + attachment_data_ref: resource_name.map(|rn| AttachmentDataRef { + resource_name: Some(rn.into()), + }), + drive_data_ref: None, + } + } + + #[test] + fn parse_attachments_image() { + let atts = vec![make_attachment( + "UPLOADED_CONTENT", + "image/png", + "photo.png", + Some("AATT_resource"), + )]; + let refs = parse_attachments(&atts); + assert_eq!(refs.len(), 1); + match &refs[0] { + GoogleChatMediaRef::Image { + resource_name, + content_name, + } => { + assert_eq!(resource_name, "AATT_resource"); + assert_eq!(content_name, "photo.png"); + } + other => panic!("expected Image, got {:?}", other), + } + } + + #[test] + fn parse_attachments_audio() { + let atts = vec![make_attachment( + "UPLOADED_CONTENT", + "audio/mp4", + "voice.m4a", + Some("AATT"), + )]; + let refs = parse_attachments(&atts); + assert!(matches!(refs[0], GoogleChatMediaRef::Audio { .. })); + } + + #[test] + fn parse_attachments_file() { + let atts = vec![make_attachment( + "UPLOADED_CONTENT", + "text/plain", + "notes.txt", + Some("AATT"), + )]; + let refs = parse_attachments(&atts); + assert!(matches!(refs[0], GoogleChatMediaRef::File { .. })); + } + + #[test] + fn parse_attachments_skips_drive() { + let atts = vec![GoogleChatAttachment { + name: Some("spaces/SP/messages/MSG/attachments/ATT".into()), + content_name: Some("doc".into()), + content_type: Some("application/vnd.google-apps.document".into()), + source: Some("DRIVE_FILE".into()), + attachment_data_ref: None, + drive_data_ref: Some(DriveDataRef { + drive_file_id: Some("drive_id_123".into()), + }), + }]; + assert_eq!(parse_attachments(&atts).len(), 0); + } + + #[test] + fn parse_attachments_skips_missing_resource_name() { + let atts = vec![make_attachment( + "UPLOADED_CONTENT", + "image/png", + "photo.png", + None, + )]; + assert_eq!(parse_attachments(&atts).len(), 0); + } + + #[test] + fn media_url_preserves_slashes_and_encodes_specials() { + let url = media_url("https://chat.googleapis.com/v1", "spaces/SP/messages/MSG/attachments/ATT"); + assert_eq!( + url, + "https://chat.googleapis.com/v1/media/spaces/SP/messages/MSG/attachments/ATT?alt=media" + ); + let url2 = media_url("https://chat.googleapis.com/v1", "AATT/some+resource=name"); + assert_eq!( + url2, + "https://chat.googleapis.com/v1/media/AATT/some%2Bresource%3Dname?alt=media" + ); + } + + #[tokio::test] + async fn download_googlechat_image_resizes_and_returns_attachment() { + use wiremock::{Mock, MockServer, ResponseTemplate}; + use wiremock::matchers::{method, path_regex}; + + // Generate a small valid PNG + let img = image::RgbImage::from_pixel(10, 10, image::Rgb([255, 0, 0])); + let mut buf = std::io::Cursor::new(Vec::new()); + image::DynamicImage::ImageRgb8(img) + .write_to(&mut buf, image::ImageFormat::Png) + .unwrap(); + let png_bytes = buf.into_inner(); + + let mock_server = MockServer::start().await; + Mock::given(method("GET")) + .and(path_regex("/media/.*")) + .respond_with( + ResponseTemplate::new(200) + .set_body_bytes(png_bytes) + .insert_header("content-type", "image/png"), + ) + .mount(&mock_server) + .await; + + let client = reqwest::Client::new(); + let result = download_googlechat_image( + &client, + "fake-token", + &mock_server.uri(), + "AATT_resource", + "photo.png", + ) + .await; + let att = result.expect("expected successful download"); + assert_eq!(att.attachment_type, "image"); + assert_eq!(att.filename, "photo.png"); + assert_eq!(att.mime_type, "image/jpeg"); // resized PNG → JPEG + assert!(!att.data.is_empty()); + assert!(att.size > 0); + } + + #[tokio::test] + async fn download_googlechat_file_rejects_non_text_extension() { + let client = reqwest::Client::new(); + let result = download_googlechat_file( + &client, + "fake-token", + "https://unused", // not called for non-text + "AATT", + "binary.exe", + TEXT_TOTAL_CAP, + ) + .await; + assert!(result.is_none(), "non-text extensions must be skipped"); + } + + #[tokio::test] + async fn download_googlechat_file_text_extension_succeeds() { + use wiremock::{Mock, MockServer, ResponseTemplate}; + use wiremock::matchers::{method, path_regex}; + + let mock_server = MockServer::start().await; + Mock::given(method("GET")) + .and(path_regex("/media/.*")) + .respond_with( + ResponseTemplate::new(200).set_body_bytes(b"hello world".to_vec()), + ) + .mount(&mock_server) + .await; + + let client = reqwest::Client::new(); + let result = download_googlechat_file( + &client, + "fake-token", + &mock_server.uri(), + "AATT", + "notes.txt", + TEXT_TOTAL_CAP, + ) + .await; + let att = result.expect("expected successful download"); + assert_eq!(att.attachment_type, "text_file"); + assert_eq!(att.filename, "notes.txt"); + assert_eq!(att.mime_type, "text/plain"); + } + + #[tokio::test] + async fn download_googlechat_audio_returns_attachment() { + use wiremock::{Mock, MockServer, ResponseTemplate}; + use wiremock::matchers::{method, path_regex}; + + let mock_server = MockServer::start().await; + let audio_bytes = vec![0u8; 1024]; + Mock::given(method("GET")) + .and(path_regex("/media/.*")) + .respond_with(ResponseTemplate::new(200).set_body_bytes(audio_bytes.clone())) + .mount(&mock_server) + .await; + + let client = reqwest::Client::new(); + let result = download_googlechat_audio( + &client, + "fake-token", + &mock_server.uri(), + "AATT", + "voice.m4a", + "audio/mp4", + ) + .await; + let att = result.expect("expected successful download"); + assert_eq!(att.attachment_type, "audio"); + assert_eq!(att.filename, "voice.m4a"); + assert_eq!(att.mime_type, "audio/mp4"); + assert_eq!(att.size, 1024); + } + + #[tokio::test] + async fn download_googlechat_image_rejects_oversized_content_length() { + use wiremock::{Mock, MockServer, ResponseTemplate}; + use wiremock::matchers::{method, path_regex}; + + let mock_server = MockServer::start().await; + Mock::given(method("GET")) + .and(path_regex("/media/.*")) + .respond_with( + ResponseTemplate::new(200) + .insert_header("content-length", "20000000") // 20 MB > 10 MB limit + .set_body_bytes(vec![0u8; 100]), + ) + .mount(&mock_server) + .await; + + let client = reqwest::Client::new(); + let result = download_googlechat_image( + &client, + "fake-token", + &mock_server.uri(), + "AATT", + "huge.png", + ) + .await; + assert!(result.is_none(), "oversized image must be rejected"); + } } diff --git a/src/gateway.rs b/src/gateway.rs index 0056c45b1..fb035d735 100644 --- a/src/gateway.rs +++ b/src/gateway.rs @@ -723,22 +723,45 @@ pub async fn run_gateway_adapter( } "audio" if stt_config.enabled => { use base64::Engine; - if let Ok(audio_bytes) = base64::engine::general_purpose::STANDARD.decode(&att.data) { - if let Some(transcript) = crate::stt::transcribe( - &crate::media::HTTP_CLIENT, - &stt_config, - audio_bytes, - att.filename.clone(), - &att.mime_type, - ).await { + match base64::engine::general_purpose::STANDARD.decode(&att.data) { + Ok(bytes) => { + match crate::stt::transcribe( + &crate::media::HTTP_CLIENT, + &stt_config, + bytes, + att.filename.clone(), + &att.mime_type, + ).await { + Some(transcript) => { + extra_blocks.push(ContentBlock::Text { + text: format!("[Voice message transcript]: {transcript}"), + }); + } + None => { + tracing::warn!(filename = %att.filename, "gateway audio STT failed"); + extra_blocks.push(ContentBlock::Text { + text: format!( + "[Voice message — transcription failed for {}]", + att.filename + ), + }); + } + } + } + Err(e) => { + tracing::warn!(filename = %att.filename, error = %e, "gateway audio base64 decode failed"); extra_blocks.push(ContentBlock::Text { - text: format!("[Voice message transcript]: {transcript}"), + text: format!( + "[Voice message — decode failed for {}]", + att.filename + ), }); } - } else { - warn!(filename = %att.filename, "audio attachment base64 decode failed"); } } + "audio" => { + tracing::debug!(filename = %att.filename, "audio attachment skipped — STT not enabled"); + } _ => {} } } From 5f9489def3e369b406818dc67b8ecc7b3243a534 Mon Sep 17 00:00:00 2001 From: Jack Lau <89240147+jacklau1993@users.noreply.github.com> Date: Fri, 15 May 2026 01:30:58 +0100 Subject: [PATCH 033/100] fix(discord): Register slash commands globally only (#804) Register Discord slash commands only at global scope so the same commands are not also created per guild. This keeps slash commands available in DMs and prevents duplicate entries in guild command pickers. Co-authored-by: Rapi-agent --- src/discord.rs | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/src/discord.rs b/src/discord.rs index 6deb6c94e..837e8048e 100644 --- a/src/discord.rs +++ b/src/discord.rs @@ -865,20 +865,25 @@ impl EventHandler for Handler { )), ]; - // Register global commands (works in DMs + all guilds after propagation). + // Register global commands only. Registering the same commands per-guild + // makes Discord show duplicate slash commands in guild command pickers. if let Err(e) = Command::set_global_commands(&ctx.http, commands.clone()).await { tracing::warn!(error = %e, "failed to register global slash commands"); } else { info!("registered global slash commands"); } - // Also register per-guild for instant availability (global can take up to 1h). + // One-time migration cleanup: older versions registered the same + // slash commands per-guild, and Discord persists those server-side. + // Keep guild command sets empty so only global commands are shown. for guild in &ready.guilds { let guild_id = guild.id; - if let Err(e) = guild_id.set_commands(&ctx.http, commands.clone()).await { - tracing::warn!(%guild_id, error = %e, "failed to register guild slash commands"); - } else { - info!(%guild_id, "registered guild slash commands"); + if let Err(e) = guild_id.set_commands(&ctx.http, Vec::new()).await { + tracing::warn!( + %guild_id, + error = %e, + "failed to clear stale guild slash commands" + ); } } From 645b56bf5925d742b9dd1282f6f2b2505f9c1639 Mon Sep 17 00:00:00 2001 From: chaodufashi Date: Sat, 16 May 2026 02:37:35 -0400 Subject: [PATCH 034/100] feat: add Hermes Agent as agent provider (#824) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: add Hermes Agent as agent provider Add Hermes Agent (NousResearch) as a supported agent provider, enabling OAB to leverage Hermes's multi-provider OAuth infrastructure via ACP. Key benefits: - xAI Grok OAuth (SuperGrok $30/mo flat rate vs pay-per-token) - 30+ providers accessible through one agent - OAuth token lifecycle managed by Hermes (zero auth complexity for OAB) - Multi-modal support (TTS, image gen, video gen) via same OAuth token - Built-in fallback chains for provider resilience Files added: - Dockerfile.hermes: runtime image with Hermes Agent installed - docs/hermes.md: setup guide with auth and provider switching docs - config.toml.example: added Hermes agent config example Closes #823 * fix: pin install script, correct ACP invocation, add credential persistence docs - Dockerfile.hermes: pin install script to commit cc07e30f with SHA256 checksum verification instead of curl-pipe-bash from main - docs/hermes.md & config.toml.example: correct command from 'hermes --acp --stdio' to 'hermes-acp' (verified upstream) - docs/hermes.md: add PVC/volume mount guidance for credential persistence Addresses review findings from PR #824. * ci: add hermes variant to build & release matrix Adds Dockerfile.hermes to the build-image, merge-manifests, and promote-stable matrices so the ghcr.io/openabdev/openab-hermes image is published by CI alongside other agent variants. * fix: Dockerfile user/permission ordering and missing deps - Create agent user before WORKDIR so /home/agent has correct ownership - Set HERMES_HOME=/home/agent/.hermes during install so OAuth tokens are stored in agent user's home (not /root/.hermes) - Add ffmpeg for Hermes multi-modal support - chown /home/agent after all root operations complete Addresses review findings from 覺渡法師. * ci: add hermes to smoke-test matrix; clarify persistence docs - docker-smoke-test.yml: add Dockerfile.hermes variant - docs/hermes.md: clarify that Helm chart persistence covers .hermes by default; manual PVC only needed for non-Helm deploys * ci: add hermes to pr-preview variant choices * docs(chart): add hermes agent example to values.yaml The chart is generic over agents., so hermes already works, but adding a commented example makes discoverability easier and aligns with the docs/hermes.md Helm install instructions. * fix: add xz-utils for Node.js tar.xz extraction in hermes install * fix: symlink hermes-acp to /usr/local/bin for PATH visibility FHS root install only links 'hermes' to /usr/local/bin, but 'hermes-acp' stays in the venv. Add explicit symlink. * fix: recreate venv with system Python to avoid uv-managed path issues uv installs its own Python 3.11 at /root/.local/share/uv/python/ which is inaccessible to the agent user. Recreate venv with the image's system Python 3.12 after install script completes. * fix(docker): chmod uv-managed Python for non-root agent user The hermes install script uses uv which places Python 3.11 under /root/.local/share/uv/. The container runs as non-root user 'agent', causing 'Permission denied' when the venv tries to resolve its Python interpreter. Fix: chmod the uv directory and parent paths to be world-readable/executable. Also symlink hermes-acp to /usr/local/bin for PATH accessibility. Tested and verified working on orbstack with xai-oauth + grok-4.3. * docs(hermes): add xAI OAuth auth guide with port-forward for K8s pods * docs(hermes): emphasize SuperGrok paid subscription requirement * docs: add Hermes Agent to README table and config-reference * docs(readme): add Hermes to architecture diagram and feature list * docs(readme): fix diagram alignment --------- Co-authored-by: 張飛 Co-authored-by: chaodufashi Co-authored-by: thepagent --- .github/workflows/build.yml | 3 + .github/workflows/docker-smoke-test.yml | 1 + .github/workflows/pr-preview.yml | 1 + Dockerfile.hermes | 52 +++++++++ README.md | 29 ++--- charts/openab/values.yaml | 21 ++++ config.toml.example | 7 ++ docs/config-reference.md | 5 + docs/hermes.md | 137 ++++++++++++++++++++++++ 9 files changed, 242 insertions(+), 14 deletions(-) create mode 100644 Dockerfile.hermes create mode 100644 docs/hermes.md diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 7b3604177..17b4505a0 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -72,6 +72,7 @@ jobs: - { suffix: "-copilot", dockerfile: "Dockerfile.copilot", artifact: "copilot" } - { suffix: "-opencode", dockerfile: "Dockerfile.opencode", artifact: "opencode" } - { suffix: "-cursor", dockerfile: "Dockerfile.cursor", artifact: "cursor" } + - { suffix: "-hermes", dockerfile: "Dockerfile.hermes", artifact: "hermes" } platform: - { os: linux/amd64, runner: ubuntu-latest } - { os: linux/arm64, runner: ubuntu-24.04-arm } @@ -135,6 +136,7 @@ jobs: - { suffix: "-copilot", artifact: "copilot" } - { suffix: "-opencode", artifact: "opencode" } - { suffix: "-cursor", artifact: "cursor" } + - { suffix: "-hermes", artifact: "hermes" } runs-on: ubuntu-latest permissions: contents: read @@ -185,6 +187,7 @@ jobs: - { suffix: "-copilot" } - { suffix: "-opencode" } - { suffix: "-cursor" } + - { suffix: "-hermes" } runs-on: ubuntu-latest permissions: contents: read diff --git a/.github/workflows/docker-smoke-test.yml b/.github/workflows/docker-smoke-test.yml index 64b4653a3..0fd0c2385 100644 --- a/.github/workflows/docker-smoke-test.yml +++ b/.github/workflows/docker-smoke-test.yml @@ -20,6 +20,7 @@ jobs: - { dockerfile: Dockerfile.copilot, suffix: "-copilot", agent: "copilot", agent_args: "--acp" } - { dockerfile: Dockerfile.opencode, suffix: "-opencode", agent: "opencode", agent_args: "acp" } - { dockerfile: Dockerfile.cursor, suffix: "-cursor", agent: "cursor-agent", agent_args: "acp" } + - { dockerfile: Dockerfile.hermes, suffix: "-hermes", agent: "hermes-acp", agent_args: "" } runs-on: ubuntu-latest steps: - uses: actions/checkout@v6 diff --git a/.github/workflows/pr-preview.yml b/.github/workflows/pr-preview.yml index 69ba89077..a5cac4419 100644 --- a/.github/workflows/pr-preview.yml +++ b/.github/workflows/pr-preview.yml @@ -18,6 +18,7 @@ on: - copilot - cursor - gemini + - hermes - opencode default: 'default' diff --git a/Dockerfile.hermes b/Dockerfile.hermes new file mode 100644 index 000000000..7de02b411 --- /dev/null +++ b/Dockerfile.hermes @@ -0,0 +1,52 @@ +# --- Build stage --- +FROM rust:1-bookworm AS builder +WORKDIR /build +COPY Cargo.toml Cargo.lock ./ +RUN mkdir src && echo 'fn main() {}' > src/main.rs && cargo build --release && rm -rf src +COPY src/ src/ +RUN touch src/main.rs && cargo build --release + +# --- Runtime stage --- +FROM python:3.12-slim-bookworm + +# Create agent user first so WORKDIR gets correct ownership +RUN useradd -m -u 1000 agent + +RUN apt-get update && apt-get install -y --no-install-recommends \ + ca-certificates curl procps ripgrep tini git ffmpeg xz-utils && \ + rm -rf /var/lib/apt/lists/* + +# Install Hermes Agent — pinned to known commit with checksum verification +# Root install uses FHS layout: binary at /usr/local/bin/hermes, code at /usr/local/lib/hermes-agent +# HERMES_HOME points to agent user's data dir for OAuth tokens and config +ARG HERMES_INSTALL_COMMIT=cc07e30f45267c00fac97ea5569c606aca5a1ffb +ARG HERMES_INSTALL_SHA256=cb94b83b96cc924716bd1651411955da7495912ef68affe6788840e6cf147d41 +RUN curl -fsSL "https://raw.githubusercontent.com/NousResearch/hermes-agent/${HERMES_INSTALL_COMMIT}/scripts/install.sh" \ + -o /tmp/install-hermes.sh && \ + echo "${HERMES_INSTALL_SHA256} /tmp/install-hermes.sh" | sha256sum -c - && \ + HERMES_HOME=/home/agent/.hermes bash /tmp/install-hermes.sh && \ + rm /tmp/install-hermes.sh && \ + chmod -R a+rX /root/.local/share/uv && \ + chmod a+rx /root /root/.local /root/.local/share && \ + ln -sf /usr/local/lib/hermes-agent/venv/bin/hermes-acp /usr/local/bin/hermes-acp + +# Install gh CLI +RUN curl -fsSL https://cli.github.com/packages/githubcli-archive-keyring.gpg \ + -o /usr/share/keyrings/githubcli-archive-keyring.gpg && \ + echo "deb [arch=$(dpkg --print-architecture) signed-by=/usr/share/keyrings/githubcli-archive-keyring.gpg] https://cli.github.com/packages stable main" \ + > /etc/apt/sources.list.d/github-cli.list && \ + apt-get update && apt-get install -y --no-install-recommends gh && \ + rm -rf /var/lib/apt/lists/* + +ENV HOME=/home/agent +WORKDIR /home/agent + +COPY --from=builder --chown=1000:1000 /build/target/release/openab /usr/local/bin/openab + +RUN chown -R agent:agent /home/agent + +USER agent +HEALTHCHECK --interval=30s --timeout=5s --retries=3 \ + CMD pgrep -x openab || exit 1 +ENTRYPOINT ["tini", "--"] +CMD ["openab", "run", "-c", "/etc/openab/config.toml"] diff --git a/README.md b/README.md index b7110cde6..ae2079b38 100644 --- a/README.md +++ b/README.md @@ -4,23 +4,23 @@ ![OpenAB banner](images/banner.jpg) -A lightweight, secure, cloud-native ACP harness that bridges **Discord, Slack**, and any [Agent Client Protocol](https://github.com/anthropics/agent-protocol)-compatible coding CLI (Kiro CLI, Claude Code, Codex, Gemini, OpenCode, Copilot CLI, etc.) over stdio JSON-RPC — delivering the next-generation development experience. **Telegram, LINE, Feishu/Lark, Google Chat**, and other webhook-based platforms are supported via the standalone [Custom Gateway](gateway/). +A lightweight, secure, cloud-native ACP harness that bridges **Discord, Slack**, and any [Agent Client Protocol](https://github.com/anthropics/agent-protocol)-compatible coding CLI (Kiro CLI, Claude Code, Codex, Gemini, OpenCode, Copilot CLI, Hermes, etc.) over stdio JSON-RPC — delivering the next-generation development experience. **Telegram, LINE, Feishu/Lark, Google Chat**, and other webhook-based platforms are supported via the standalone [Custom Gateway](gateway/). 🪼 **Join our community!** Come say hi on Discord — we'd love to have you: **[🪼 OpenAB — Official](https://discord.gg/DmbhfDZjQS)** 🎉 ``` -┌──────────────┐ Gateway WS ┌──────────────┐ ACP stdio ┌──────────────┐ -│ Discord │◄─────────────►│ │──────────────►│ coding CLI │ -│ User │ │ openab │◄── JSON-RPC ──│ (acp mode) │ -├──────────────┤ Socket Mode │ (Rust) │ └──────────────┘ -│ Slack │◄─────────────►│ │ -│ User │ └──────┬───────┘ -├──────────────┤ │ WebSocket (outbound) -│ Telegram │◄──webhook──┐ │ -│ User │ │ │ -├──────────────┤ ▼ ▼ -│ LINE │◄──webhook──┌──────────────────┐ -│ User │ │ Custom Gateway │ +┌──────────────┐ Gateway WS ┌──────────────┐ ACP stdio ┌──────────────────┐ +│ Discord │◄─────────────►│ │──────────────►│ coding CLI │ +│ User │ │ openab │◄── JSON-RPC ──│ (acp mode) │ +├──────────────┤ Socket Mode │ (Rust) │ ├──────────────────┤ +│ Slack │◄─────────────►│ │ │ kiro-cli acp │ +│ User │ └──────┬───────┘ │ claude-agent-acp │ +├──────────────┤ │ WebSocket │ codex-acp │ +│ Telegram │◄──webhook──┐ │ (outbound) │ gemini --acp │ +│ User │ │ │ │ copilot --acp │ +├──────────────┤ ▼ ▼ │ hermes-acp │ +│ LINE │◄──webhook──┌──────────────────┐ │ opencode acp │ +│ User │ │ Custom Gateway │ └──────────────────┘ ├──────────────┤ │ (standalone) │ │ Feishu/Lark │◄───WS──────│ │ │ User │ │ │ @@ -38,7 +38,7 @@ A lightweight, secure, cloud-native ACP harness that bridges **Discord, Slack**, - **Multi-platform** — supports Discord and Slack, run one or both simultaneously - **Custom Gateway** — extend to Telegram, LINE, Feishu/Lark, Google Chat, MS Teams via standalone [gateway](gateway/) -- **Pluggable agent backend** — swap between Kiro CLI, Claude Code, Codex, Gemini, OpenCode, Copilot CLI via config +- **Pluggable agent backend** — swap between Kiro CLI, Claude Code, Codex, Gemini, OpenCode, Copilot CLI, Hermes via config - **@mention trigger** — mention the bot in an allowed channel to start a conversation - **Thread-based multi-turn** — auto-creates threads; no @mention needed for follow-ups - **Multi-agent collaboration** — bot-to-bot messaging for coordinated workflows ([docs/multi-agent.md](docs/multi-agent.md)) @@ -168,6 +168,7 @@ The bot creates a thread. After that, just type in the thread — no @mention ne | OpenCode | `opencode acp` | Native | [docs/opencode.md](docs/opencode.md) | | Copilot CLI ⚠️ | `copilot --acp --stdio` | Native | [docs/copilot.md](docs/copilot.md) | | Cursor | `cursor-agent acp` | Native | [docs/cursor.md](docs/cursor.md) | +| Hermes Agent | `hermes-acp` | Native | [docs/hermes.md](docs/hermes.md) | > 🔧 Running multiple agents? See [docs/multi-agent.md](docs/multi-agent.md) diff --git a/charts/openab/values.yaml b/charts/openab/values.yaml index 50b659159..0e4b59520 100644 --- a/charts/openab/values.yaml +++ b/charts/openab/values.yaml @@ -140,6 +140,27 @@ agents: # storageClass: "" # size: 1Gi # image: "ghcr.io/openabdev/openab-cursor:latest" + # hermes: + # command: hermes-acp + # discord: + # enabled: true + # allowedChannels: + # - "YOUR_CHANNEL_ID" + # allowedUsers: [] + # allowBotMessages: "off" + # trustedBotIds: [] + # workingDir: /home/agent + # env: {} + # envFrom: [] + # secretEnv: [] + # pool: + # maxSessions: 10 + # sessionTtlHours: 24 + # persistence: + # enabled: true + # storageClass: "" + # size: 1Gi + # image: "ghcr.io/openabdev/openab-hermes" image: "" command: kiro-cli args: diff --git a/config.toml.example b/config.toml.example index d33a0902b..20ccff34f 100644 --- a/config.toml.example +++ b/config.toml.example @@ -103,6 +103,13 @@ working_dir = "/home/agent" # working_dir = "/home/agent" # env = {} # Auth via: kubectl exec -it -- cursor-agent login +# [agent] +# command = "hermes-acp" +# working_dir = "/home/agent" +# # Auth via: kubectl exec -it -- hermes auth add xai-oauth +# # Supports 30+ providers (xAI Grok OAuth, Anthropic, OpenAI Codex, Gemini, etc.) +# # Provider switching: kubectl exec -it -- hermes model + [pool] max_sessions = 10 session_ttl_hours = 24 diff --git a/docs/config-reference.md b/docs/config-reference.md index a0eef687f..907c122cb 100644 --- a/docs/config-reference.md +++ b/docs/config-reference.md @@ -141,6 +141,11 @@ working_dir = "/home/node" command = "cursor-agent" args = ["acp", "--model", "auto", "--workspace", "/home/agent"] working_dir = "/home/agent" + +# Hermes Agent +[agent] +command = "hermes-acp" +working_dir = "/home/agent" ``` --- diff --git a/docs/hermes.md b/docs/hermes.md new file mode 100644 index 000000000..2ab9ef2f5 --- /dev/null +++ b/docs/hermes.md @@ -0,0 +1,137 @@ +# Hermes Agent + +[Hermes Agent](https://github.com/NousResearch/hermes-agent) by Nous Research supports ACP natively via the `hermes acp` subcommand (or the `hermes-acp` binary). + +Hermes acts as a multi-provider inference gateway — it handles OAuth token lifecycle, credential storage, and provider routing so OAB agents don't need to manage auth directly. + +## Docker Image + +```bash +docker build -f Dockerfile.hermes -t openab-hermes:latest . +``` + +The image installs Hermes Agent via the official install script. + +## Helm Install + +```bash +helm install openab openab/openab \ + --set agents.kiro.enabled=false \ + --set agents.hermes.discord.enabled=true \ + --set agents.hermes.discord.botToken="$DISCORD_BOT_TOKEN" \ + --set-string 'agents.hermes.discord.allowedChannels[0]=YOUR_CHANNEL_ID' \ + --set agents.hermes.image=ghcr.io/openabdev/openab-hermes:latest \ + --set agents.hermes.command=hermes-acp \ + --set agents.hermes.workingDir=/home/agent +``` + +> Set `agents.kiro.enabled=false` to disable the default Kiro agent. + +## Manual config.toml + +```toml +[agent] +command = "hermes-acp" +working_dir = "/home/agent" +``` + +## Authentication + +Hermes supports 30+ providers. Authenticate inside the pod: + +```bash +kubectl exec -it -- hermes auth add xai-oauth # xAI Grok (SuperGrok $30/mo) +kubectl exec -it -- hermes auth add nous # Nous Portal +kubectl exec -it -- hermes model # Interactive provider picker +``` + +### xAI Grok OAuth (Recommended) + +> ⚠️ **Requires an active [SuperGrok paid subscription](https://x.ai/grok) ($30/mo).** Auth will succeed without one, but the API silently returns empty responses — the bot appears to work but never replies. + +xAI Grok OAuth uses a loopback redirect flow — the callback listener binds `127.0.0.1:56121` inside the pod. You need a port-forward so your browser's redirect reaches the pod: + +```bash +# Terminal 1: port-forward +kubectl port-forward deployment/ 56121:56121 + +# Terminal 2: run auth +kubectl exec -it deployment/ -- hermes auth add xai-oauth --no-browser +``` + +1. Copy the printed authorize URL → open in your local browser +2. Approve access on accounts.x.ai +3. Browser redirects to `127.0.0.1:56121/callback` → port-forward delivers it to the pod +4. Terminal shows "Login successful!" + +After auth, set the default model: + +```bash +kubectl exec -- hermes config set model.provider xai-oauth +kubectl exec -- hermes config set model.default grok-4.3 +``` + +> **Note:** Tokens are stored in `~/.hermes/auth.json` and auto-refresh in the background. + +### Providers That Don't Need Port-Forward + +| Provider | Auth Method | +|----------|-------------| +| Anthropic (Claude Pro/Max) | Paste-the-code flow | +| OpenAI Codex (ChatGPT Plus/Pro) | Device code flow | +| MiniMax, Nous Portal | Device code flow | +| xAI Grok, Spotify | Loopback OAuth (port-forward required) | + +### Supported Providers (via OAuth) + +| Provider | Auth Command | Cost Model | +|----------|-------------|------------| +| xAI Grok | `hermes auth add xai-oauth` | SuperGrok subscription ($30/mo) | +| OpenAI Codex | `hermes model` → OpenAI Codex | ChatGPT subscription | +| GitHub Copilot | `hermes model` → GitHub Copilot | Copilot subscription | +| Google Gemini | `hermes model` → Google Gemini (OAuth) | Free tier available | +| Anthropic | `hermes model` → Anthropic | Claude Max + extra credits | +| Nous Portal | `hermes auth add nous` | Nous subscription | + +### Supported Providers (via API Key) + +Any provider can also be configured with an API key via environment variables: + +```toml +[agent] +command = "hermes-acp" +working_dir = "/home/agent" +env = { XAI_API_KEY = "${XAI_API_KEY}" } +``` + +## Provider Switching + +Switch providers without restarting the pod: + +```bash +kubectl exec -it -- hermes model +``` + +## Credential Persistence + +Hermes stores OAuth tokens in `~/.hermes/`. The OpenAB Helm chart's default persistence covers this automatically (PVC mounted at `workingDir`). + +If deploying manually (without the Helm chart), mount persistent storage at `/home/agent` or `/home/agent/.hermes`: + +```yaml +volumes: + - name: hermes-credentials + persistentVolumeClaim: + claimName: hermes-credentials-pvc +volumeMounts: + - name: hermes-credentials + mountPath: /home/agent/.hermes +``` + +## Advantages + +- **Cost**: SuperGrok $30/mo flat rate vs pay-per-token API pricing +- **Multi-provider**: 30+ providers accessible through one agent +- **Zero auth complexity**: Hermes handles OAuth + token refresh +- **Multi-modal**: TTS, image gen, video gen via the same OAuth token +- **Fallback chains**: Auto-switch providers on failure From 60b56fb1d58bb9982c588f7f2a998583d8f81ae9 Mon Sep 17 00:00:00 2001 From: "openab-app[bot]" <274185012+openab-app[bot]@users.noreply.github.com> Date: Sat, 16 May 2026 14:39:40 +0800 Subject: [PATCH 035/100] release: v0.8.3-beta.10 (#825) Co-authored-by: openab-app[bot] <274185012+openab-app[bot]@users.noreply.github.com> --- charts/openab/Chart.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/charts/openab/Chart.yaml b/charts/openab/Chart.yaml index fdfd69516..c25d44ae5 100644 --- a/charts/openab/Chart.yaml +++ b/charts/openab/Chart.yaml @@ -2,5 +2,5 @@ apiVersion: v2 name: openab description: A lightweight, secure, cloud-native ACP harness that bridges Discord and any ACP-compatible coding CLI. type: application -version: 0.8.3-beta.9 -appVersion: "0.8.3-beta.9" +version: 0.8.3-beta.10 +appVersion: "0.8.3-beta.10" From dfd99608d3ea4ddde0baf9965a7c5b67e4875eb8 Mon Sep 17 00:00:00 2001 From: chaodufashi Date: Sat, 16 May 2026 07:46:45 -0400 Subject: [PATCH 036/100] docs(hermes): add detailed xAI OAuth guide for ECS + file ownership fix (#828) - Option A: K8s port-forward flow - Option B: ECS curl-the-callback flow (step-by-step) - File ownership fix after exec-based auth (chown) - SuperGrok subscription requirement warning Co-authored-by: thepagent --- docs/hermes.md | 51 ++++++++++++++++++++++++++++++++++++++++++++------ 1 file changed, 45 insertions(+), 6 deletions(-) diff --git a/docs/hermes.md b/docs/hermes.md index 2ab9ef2f5..d5cccc7ba 100644 --- a/docs/hermes.md +++ b/docs/hermes.md @@ -49,7 +49,9 @@ kubectl exec -it -- hermes model # Interactive provider pi > ⚠️ **Requires an active [SuperGrok paid subscription](https://x.ai/grok) ($30/mo).** Auth will succeed without one, but the API silently returns empty responses — the bot appears to work but never replies. -xAI Grok OAuth uses a loopback redirect flow — the callback listener binds `127.0.0.1:56121` inside the pod. You need a port-forward so your browser's redirect reaches the pod: +xAI Grok OAuth uses a loopback redirect flow — the callback listener binds `127.0.0.1:56121` inside the pod/container. + +#### Option A: Kubernetes (port-forward) ```bash # Terminal 1: port-forward @@ -62,16 +64,53 @@ kubectl exec -it deployment/ -- hermes auth add xai-oauth --no- 1. Copy the printed authorize URL → open in your local browser 2. Approve access on accounts.x.ai 3. Browser redirects to `127.0.0.1:56121/callback` → port-forward delivers it to the pod -4. Terminal shows "Login successful!" +4. Terminal shows `Added xai-oauth OAuth credential #1: "xai-oauth-oauth-1"` + +#### Option B: ECS / Remote (curl-the-callback) + +ECS Fargate doesn't support port-forward. Use two exec sessions instead: + +```bash +# Terminal 1: start the auth listener +aws ecs execute-command --cluster openab --task --container openab --interactive --command bash +hermes auth add xai-oauth --no-browser +# → prints authorize URL with &state=XXXXX in it +# → "Waiting for callback on http://127.0.0.1:56121/callback" +``` + +Open the authorize URL in your browser and approve. The browser will redirect to +`http://127.0.0.1:56121/callback?code=...` and fail ("Could not establish connection"). +**Copy the `code` value** from the page or URL bar. The `state` value comes from the +authorize URL printed in Terminal 1. + +```bash +# Terminal 2: exec into the SAME container +aws ecs execute-command --cluster openab --task --container openab --interactive --command bash +curl "http://127.0.0.1:56121/callback?code=&state=" +``` + +Terminal 1 should print: +``` +Added xai-oauth OAuth credential #1: "xai-oauth-oauth-1" +``` + +> ⚠️ The code expires in seconds — be fast. If you get `invalid_grant`, re-run `hermes auth add` and try again. -After auth, set the default model: +#### After auth: set the default model ```bash -kubectl exec -- hermes config set model.provider xai-oauth -kubectl exec -- hermes config set model.default grok-4.3 +hermes config set model.provider xai-oauth +hermes config set model.default grok-4.3 ``` -> **Note:** Tokens are stored in `~/.hermes/auth.json` and auto-refresh in the background. +#### Fix file ownership (important for exec-based auth) + +When running auth/config commands via `kubectl exec` or ECS exec (which runs as root), +fix ownership so the `agent` user can read the files: + +```bash +chown -R agent:agent /home/agent/.hermes/ +``` ### Providers That Don't Need Port-Forward From 925b4843ea9bbf8c6e319ab4d7917670b450b5dd Mon Sep 17 00:00:00 2001 From: Can Date: Sun, 17 May 2026 11:15:41 +0800 Subject: [PATCH 037/100] feat: add Grok Build CLI as supported agent (#831) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: add Grok Build (xAI) as agent provider Add xAI's official Grok Build CLI as a supported agent provider via its native ACP entry (`grok agent stdio`) — no wrapper required. Files added: - Dockerfile.grok: runtime image with pinned grok 0.1.211 binary (SHA256-verified, sourced from xAI's public artifacts bucket) - docs/grok.md: setup guide covering Docker build, Helm install, three auth options (API key / device-code / deployment key), credential persistence, and a comparison with Dockerfile.hermes - config.toml.example: agent example placed next to the hermes one - .github/workflows/build.yml: grok variant added to build-image, merge-manifests, and promote-stable matrices so the ghcr.io/openabdev/openab-grok image is published alongside other agent variants Co-Authored-By: Claude Opus 4.7 (1M context) * ci: add grok variant to docker-smoke-test matrix Mirrors the other agent variants so CI builds the Dockerfile.grok image and verifies that `grok agent stdio` answers the ACP initialize handshake (falling back to `grok --help` when auth is missing, same as other agents). Co-Authored-By: Claude Opus 4.7 (1M context) * feat(helm): add grok agent example to values.yaml and NOTES.txt * docs(helm): recommend API key for grok bot deployments in NOTES.txt Add guidance for bot/CI users to prefer GROK_CODE_XAI_API_KEY via secretEnv instead of interactive device-auth login. * fix(docker): remove unused /usr/local/bin/agent symlink in Dockerfile.grok The symlink is not referenced by OpenAB, Helm, or CI and may cause confusion in multi-agent environments. * docs(readme): add Grok Build to supported agents list --------- Co-authored-by: Can Co-authored-by: Claude Opus 4.7 (1M context) Co-authored-by: chaodu-agent --- .github/workflows/build.yml | 3 + .github/workflows/docker-smoke-test.yml | 1 + Dockerfile.grok | 58 ++++++++++ README.md | 9 +- charts/openab/templates/NOTES.txt | 4 + charts/openab/values.yaml | 28 +++++ config.toml.example | 10 ++ docs/grok.md | 134 ++++++++++++++++++++++++ 8 files changed, 243 insertions(+), 4 deletions(-) create mode 100644 Dockerfile.grok create mode 100644 docs/grok.md diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 17b4505a0..8eeb11738 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -73,6 +73,7 @@ jobs: - { suffix: "-opencode", dockerfile: "Dockerfile.opencode", artifact: "opencode" } - { suffix: "-cursor", dockerfile: "Dockerfile.cursor", artifact: "cursor" } - { suffix: "-hermes", dockerfile: "Dockerfile.hermes", artifact: "hermes" } + - { suffix: "-grok", dockerfile: "Dockerfile.grok", artifact: "grok" } platform: - { os: linux/amd64, runner: ubuntu-latest } - { os: linux/arm64, runner: ubuntu-24.04-arm } @@ -137,6 +138,7 @@ jobs: - { suffix: "-opencode", artifact: "opencode" } - { suffix: "-cursor", artifact: "cursor" } - { suffix: "-hermes", artifact: "hermes" } + - { suffix: "-grok", artifact: "grok" } runs-on: ubuntu-latest permissions: contents: read @@ -188,6 +190,7 @@ jobs: - { suffix: "-opencode" } - { suffix: "-cursor" } - { suffix: "-hermes" } + - { suffix: "-grok" } runs-on: ubuntu-latest permissions: contents: read diff --git a/.github/workflows/docker-smoke-test.yml b/.github/workflows/docker-smoke-test.yml index 0fd0c2385..2b8f7131e 100644 --- a/.github/workflows/docker-smoke-test.yml +++ b/.github/workflows/docker-smoke-test.yml @@ -21,6 +21,7 @@ jobs: - { dockerfile: Dockerfile.opencode, suffix: "-opencode", agent: "opencode", agent_args: "acp" } - { dockerfile: Dockerfile.cursor, suffix: "-cursor", agent: "cursor-agent", agent_args: "acp" } - { dockerfile: Dockerfile.hermes, suffix: "-hermes", agent: "hermes-acp", agent_args: "" } + - { dockerfile: Dockerfile.grok, suffix: "-grok", agent: "grok", agent_args: "agent stdio" } runs-on: ubuntu-latest steps: - uses: actions/checkout@v6 diff --git a/Dockerfile.grok b/Dockerfile.grok new file mode 100644 index 000000000..27b997ffe --- /dev/null +++ b/Dockerfile.grok @@ -0,0 +1,58 @@ +# --- Build stage --- +FROM rust:1-bookworm AS builder +WORKDIR /build +COPY Cargo.toml Cargo.lock ./ +RUN mkdir src && echo 'fn main() {}' > src/main.rs && cargo build --release && rm -rf src +COPY src/ src/ +RUN touch src/main.rs && cargo build --release + +# --- Runtime stage --- +FROM debian:bookworm-slim + +# Create agent user first so WORKDIR gets correct ownership +RUN useradd -m -u 1000 agent + +RUN apt-get update && apt-get install -y --no-install-recommends \ + ca-certificates curl procps ripgrep tini git && \ + rm -rf /var/lib/apt/lists/* + +# Install Grok Build CLI — pinned version with SHA256 checksum verification. +# Binary sourced from xAI's public artifacts bucket (same source the official +# `https://x.ai/cli/install.sh` resolves to) so the build is reproducible. +ARG GROK_VERSION=0.1.211 +ARG GROK_SHA256_AMD64=9245f9c921b1f91bfb34ee2ee27715000b65e947723541ff1a612eaece468bd0 +ARG GROK_SHA256_ARM64=b283cb72fdc3143365e044fd7f8630e14845640d4d81404bb36905cc7209abc6 +ARG TARGETPLATFORM +RUN set -eux; \ + case "${TARGETPLATFORM:-linux/amd64}" in \ + "linux/amd64") arch=x86_64; sha="${GROK_SHA256_AMD64}" ;; \ + "linux/arm64") arch=aarch64; sha="${GROK_SHA256_ARM64}" ;; \ + *) echo "Unsupported platform: ${TARGETPLATFORM}" >&2; exit 1 ;; \ + esac; \ + curl -fsSL "https://storage.googleapis.com/grok-build-public-artifacts/cli/grok-${GROK_VERSION}-linux-${arch}" \ + -o /tmp/grok && \ + echo "${sha} /tmp/grok" | sha256sum -c - && \ + install -m 0755 /tmp/grok /usr/local/bin/grok && \ + rm /tmp/grok + +# Install gh CLI +RUN curl -fsSL https://cli.github.com/packages/githubcli-archive-keyring.gpg \ + -o /usr/share/keyrings/githubcli-archive-keyring.gpg && \ + echo "deb [arch=$(dpkg --print-architecture) signed-by=/usr/share/keyrings/githubcli-archive-keyring.gpg] https://cli.github.com/packages stable main" \ + > /etc/apt/sources.list.d/github-cli.list && \ + apt-get update && apt-get install -y --no-install-recommends gh && \ + rm -rf /var/lib/apt/lists/* + +ENV HOME=/home/agent +WORKDIR /home/agent + +COPY --from=builder --chown=1000:1000 /build/target/release/openab /usr/local/bin/openab + +# Pre-create credential dir so a PVC mounted at ~/.grok inherits correct ownership +RUN mkdir -p /home/agent/.grok && chown -R agent:agent /home/agent + +USER agent +HEALTHCHECK --interval=30s --timeout=5s --retries=3 \ + CMD pgrep -x openab || exit 1 +ENTRYPOINT ["tini", "--"] +CMD ["openab", "run", "-c", "/etc/openab/config.toml"] diff --git a/README.md b/README.md index ae2079b38..c5f81ddc1 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ ![OpenAB banner](images/banner.jpg) -A lightweight, secure, cloud-native ACP harness that bridges **Discord, Slack**, and any [Agent Client Protocol](https://github.com/anthropics/agent-protocol)-compatible coding CLI (Kiro CLI, Claude Code, Codex, Gemini, OpenCode, Copilot CLI, Hermes, etc.) over stdio JSON-RPC — delivering the next-generation development experience. **Telegram, LINE, Feishu/Lark, Google Chat**, and other webhook-based platforms are supported via the standalone [Custom Gateway](gateway/). +A lightweight, secure, cloud-native ACP harness that bridges **Discord, Slack**, and any [Agent Client Protocol](https://github.com/anthropics/agent-protocol)-compatible coding CLI (Kiro CLI, Claude Code, Codex, Gemini, OpenCode, Copilot CLI, Hermes, Grok Build, etc.) over stdio JSON-RPC — delivering the next-generation development experience. **Telegram, LINE, Feishu/Lark, Google Chat**, and other webhook-based platforms are supported via the standalone [Custom Gateway](gateway/). 🪼 **Join our community!** Come say hi on Discord — we'd love to have you: **[🪼 OpenAB — Official](https://discord.gg/DmbhfDZjQS)** 🎉 @@ -20,8 +20,8 @@ A lightweight, secure, cloud-native ACP harness that bridges **Discord, Slack**, │ User │ │ │ │ copilot --acp │ ├──────────────┤ ▼ ▼ │ hermes-acp │ │ LINE │◄──webhook──┌──────────────────┐ │ opencode acp │ -│ User │ │ Custom Gateway │ └──────────────────┘ -├──────────────┤ │ (standalone) │ +│ User │ │ Custom Gateway │ │ grok agent stdio │ +├──────────────┤ │ (standalone) │ └──────────────────┘ │ Feishu/Lark │◄───WS──────│ │ │ User │ │ │ ├──────────────┤ │ │ @@ -38,7 +38,7 @@ A lightweight, secure, cloud-native ACP harness that bridges **Discord, Slack**, - **Multi-platform** — supports Discord and Slack, run one or both simultaneously - **Custom Gateway** — extend to Telegram, LINE, Feishu/Lark, Google Chat, MS Teams via standalone [gateway](gateway/) -- **Pluggable agent backend** — swap between Kiro CLI, Claude Code, Codex, Gemini, OpenCode, Copilot CLI, Hermes via config +- **Pluggable agent backend** — swap between Kiro CLI, Claude Code, Codex, Gemini, OpenCode, Copilot CLI, Hermes, Grok Build via config - **@mention trigger** — mention the bot in an allowed channel to start a conversation - **Thread-based multi-turn** — auto-creates threads; no @mention needed for follow-ups - **Multi-agent collaboration** — bot-to-bot messaging for coordinated workflows ([docs/multi-agent.md](docs/multi-agent.md)) @@ -169,6 +169,7 @@ The bot creates a thread. After that, just type in the thread — no @mention ne | Copilot CLI ⚠️ | `copilot --acp --stdio` | Native | [docs/copilot.md](docs/copilot.md) | | Cursor | `cursor-agent acp` | Native | [docs/cursor.md](docs/cursor.md) | | Hermes Agent | `hermes-acp` | Native | [docs/hermes.md](docs/hermes.md) | +| Grok Build | `grok agent stdio` | Native | [docs/grok.md](docs/grok.md) | > 🔧 Running multiple agents? See [docs/multi-agent.md](docs/multi-agent.md) diff --git a/charts/openab/templates/NOTES.txt b/charts/openab/templates/NOTES.txt index 2030ed6fe..89b01ef65 100644 --- a/charts/openab/templates/NOTES.txt +++ b/charts/openab/templates/NOTES.txt @@ -42,6 +42,10 @@ Agents deployed: {{- else if eq (toString $cfg.command) "cursor-agent" }} Authenticate: kubectl exec -it deployment/{{ include "openab.agentFullname" (dict "ctx" $ "agent" $name) }} -- cursor-agent login +{{- else if eq (toString $cfg.command) "grok" }} + Authenticate: + kubectl exec -it deployment/{{ include "openab.agentFullname" (dict "ctx" $ "agent" $name) }} -- grok login --device-auth + For bot/CI deployments, prefer setting `GROK_CODE_XAI_API_KEY` via `secretEnv` instead of interactive device-auth login. See docs/grok.md for all authentication options. {{- end }} Restart after auth: diff --git a/charts/openab/values.yaml b/charts/openab/values.yaml index 0e4b59520..8e4f2fa9e 100644 --- a/charts/openab/values.yaml +++ b/charts/openab/values.yaml @@ -161,6 +161,34 @@ agents: # storageClass: "" # size: 1Gi # image: "ghcr.io/openabdev/openab-hermes" + # grok: + # command: grok + # args: + # - agent + # - stdio + # # See docs/grok.md for the three authentication methods: + # # 1. API key (recommended for bots): set GROK_CODE_XAI_API_KEY via env or secretEnv + # # 2. Device-code OAuth: kubectl exec + `grok login --device-auth` (requires persistence) + # # 3. Enterprise deployment key: GROK_DEPLOYMENT_KEY + # discord: + # enabled: true + # allowedChannels: + # - "YOUR_CHANNEL_ID" + # allowedUsers: [] + # allowBotMessages: "off" + # trustedBotIds: [] + # workingDir: /home/agent + # env: {} + # envFrom: [] + # secretEnv: [] + # pool: + # maxSessions: 10 + # sessionTtlHours: 24 + # persistence: + # enabled: true + # storageClass: "" + # size: 1Gi + # image: "ghcr.io/openabdev/openab-grok" image: "" command: kiro-cli args: diff --git a/config.toml.example b/config.toml.example index 20ccff34f..3e3c39d48 100644 --- a/config.toml.example +++ b/config.toml.example @@ -110,6 +110,16 @@ working_dir = "/home/agent" # # Supports 30+ providers (xAI Grok OAuth, Anthropic, OpenAI Codex, Gemini, etc.) # # Provider switching: kubectl exec -it -- hermes model +# [agent] +# command = "grok" +# args = ["agent", "stdio"] +# working_dir = "/home/agent" +# # Auth options: +# # 1. API key: env = { GROK_CODE_XAI_API_KEY = "${XAI_API_KEY}" } +# # 2. Device-code: kubectl exec -it -- grok login --device-auth +# # 3. Deployment key: env = { GROK_DEPLOYMENT_KEY = "${GROK_DEPLOYMENT_KEY}" } +# # See docs/grok.md for details. + [pool] max_sessions = 10 session_ttl_hours = 24 diff --git a/docs/grok.md b/docs/grok.md new file mode 100644 index 000000000..b6d4d09c0 --- /dev/null +++ b/docs/grok.md @@ -0,0 +1,134 @@ +# Grok Build (xAI) + +[Grok Build](https://x.ai/news/grok-build-cli) is xAI's official coding agent CLI. It speaks ACP natively via `grok agent stdio` — no wrapper required. + +## Docker Image + +```bash +docker build -f Dockerfile.grok -t openab-grok:latest . +``` + +The image pulls a pinned `grok` binary from xAI's public artifacts bucket and verifies its SHA256 checksum. Bump `GROK_VERSION`, `GROK_SHA256_AMD64`, and `GROK_SHA256_ARM64` in `Dockerfile.grok` to upgrade. + +## Helm Install + +```bash +helm install openab openab/openab \ + --set agents.kiro.enabled=false \ + --set agents.grok.discord.enabled=true \ + --set agents.grok.discord.botToken="$DISCORD_BOT_TOKEN" \ + --set-string 'agents.grok.discord.allowedChannels[0]=YOUR_CHANNEL_ID' \ + --set agents.grok.image=ghcr.io/openabdev/openab-grok:latest \ + --set agents.grok.command=grok \ + --set-string 'agents.grok.args[0]=agent' \ + --set-string 'agents.grok.args[1]=stdio' \ + --set agents.grok.workingDir=/home/agent +``` + +> Set `agents.kiro.enabled=false` to disable the default Kiro agent. + +## Manual config.toml + +```toml +[agent] +command = "grok" +args = ["agent", "stdio"] +working_dir = "/home/agent" +``` + +## Authentication + +Grok Build supports three credential sources. Pick whichever fits your deployment. + +### Option A: API key (simplest, recommended for CI / bot deployments) + +Set the environment variable in the pod / task definition: + +```bash +export GROK_CODE_XAI_API_KEY="xai-..." +``` + +Get a key from . No interactive login needed. + +> ⚠️ **Security**: env vars listed under `[agent].env` are visible to the agent and can be leaked via prompt injection. Prefer mounting them via the platform's secret manager. + +### Option B: Device-code OAuth (for SuperGrok subscriptions) + +If you want to use a SuperGrok subscription instead of pay-per-token API billing: + +```bash +kubectl exec -it -- grok login --device-auth +``` + +The CLI prints a short code and URL — open the URL on any device, enter the code, approve. The token is stored at `~/.grok/auth.json` inside the container. + +This works in any headless environment (K8s exec, ECS exec, plain SSH) **without port-forwarding** — unlike loopback OAuth flows. + +### Option C: Enterprise deployment key + +```bash +export GROK_DEPLOYMENT_KEY="..." +``` + +A deployment key takes precedence over `auth.json`. The CLI fetches managed config from `cli-chat-proxy.grok.com/v1/deployment/config` on startup. Available to xAI enterprise customers; contact xAI sales for details. + +## Credential Persistence + +`grok login` stores OAuth credentials at `~/.grok/auth.json` and runtime config at `~/.grok/config.toml`. The OpenAB Helm chart's default persistence covers `workingDir` automatically (PVC mounted at `/home/agent`). + +If deploying manually, mount persistent storage at `/home/agent/.grok`: + +```yaml +volumes: + - name: grok-credentials + persistentVolumeClaim: + claimName: grok-credentials-pvc +volumeMounts: + - name: grok-credentials + mountPath: /home/agent/.grok +``` + +API-key-only deployments don't need persistence. + +## Model Selection + +The default model is whichever Grok Build CLI selects (currently `grok-code-fast-1` for the free tier; `grok-4.3` family for SuperGrok). To override: + +```toml +[agent] +command = "grok" +args = ["agent", "stdio", "--model", "grok-4.3"] +working_dir = "/home/agent" +``` + +List available models inside the pod: + +```bash +kubectl exec -it -- grok models +``` + +## Updating + +```bash +# Inside the container (one-shot upgrade): +kubectl exec -it -- grok update + +# Or rebuild the image with a new pinned version: +docker build -f Dockerfile.grok \ + --build-arg GROK_VERSION=0.1.220 \ + --build-arg GROK_SHA256_AMD64=... \ + --build-arg GROK_SHA256_ARM64=... \ + -t openab-grok:latest . +``` + +## Comparison with Hermes + +| Property | `Dockerfile.grok` | `Dockerfile.hermes` | +|----------|-------------------|---------------------| +| Provider | xAI Grok only | xAI + 30 others via Nous gateway | +| ACP | Native (`grok agent stdio`) | Via `hermes-acp` wrapper | +| Headless auth | API key env or device-code | Loopback OAuth (needs port-forward / ECS curl trick) | +| Supply chain | xAI only | xAI + Nous Research install script | +| Image size | Smaller (single static binary, no Python venv) | Larger (Python + uv + ffmpeg) | + +Pick `Dockerfile.grok` if Grok is the only model you need. Pick `Dockerfile.hermes` if you want multi-provider switching or fallback chains. From 1da46e809a0c13d1adf1ac8754d39752f7a454c5 Mon Sep 17 00:00:00 2001 From: "openab-app[bot]" <274185012+openab-app[bot]@users.noreply.github.com> Date: Sat, 16 May 2026 23:26:37 -0400 Subject: [PATCH 038/100] release: v0.8.3-beta.11 (#833) Co-authored-by: openab-app[bot] <274185012+openab-app[bot]@users.noreply.github.com> --- charts/openab/Chart.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/charts/openab/Chart.yaml b/charts/openab/Chart.yaml index c25d44ae5..08a548833 100644 --- a/charts/openab/Chart.yaml +++ b/charts/openab/Chart.yaml @@ -2,5 +2,5 @@ apiVersion: v2 name: openab description: A lightweight, secure, cloud-native ACP harness that bridges Discord and any ACP-compatible coding CLI. type: application -version: 0.8.3-beta.10 -appVersion: "0.8.3-beta.10" +version: 0.8.3-beta.11 +appVersion: "0.8.3-beta.11" From c1592ec4aa7e81c7e2695872d11fd5fdf0011dcd Mon Sep 17 00:00:00 2001 From: chaodufashi Date: Sun, 17 May 2026 08:36:48 -0400 Subject: [PATCH 039/100] docs: add message_processing_mode, max_buffered_messages, max_batch_tokens to config-reference (#835) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit These fields are supported in [discord], [slack], and [gateway] sections but were missing from the configuration reference. Also adds the corresponding Helm value mappings. Co-authored-by: 超渡法師 --- docs/config-reference.md | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/docs/config-reference.md b/docs/config-reference.md index 907c122cb..a9be58708 100644 --- a/docs/config-reference.md +++ b/docs/config-reference.md @@ -41,6 +41,9 @@ Discord adapter. Requires a Discord bot token. | `allow_user_messages` | string | `"involved"` | `"involved"` — reply in threads bot has participated in without @mention; channel messages require @mention; DMs always process. `"mentions"` — always require @mention. `"multibot-mentions"` — like `"involved"`, but require @mention once another bot has posted in the thread. | | `allow_dm` | bool | `false` | `true` = respond to Discord DMs; `false` = ignore DMs. `allowed_users` still applies in DMs. Each DM user consumes one session slot. | | `max_bot_turns` | u32 | `100` | Max consecutive bot turns per thread before throttling (soft limit). Human message resets the counter. A compiled-in hard cap of 1000 consecutive bot messages is always enforced. | +| `message_processing_mode` | string | `"per-message"` | Message dispatch mode: `"per-message"` (each message = own turn), `"per-thread"` (all messages in thread share one buffer), or `"per-lane"` (each sender gets own buffer). See [Message Dispatch Modes](message-dispatch-modes.md). | +| `max_buffered_messages` | u32 | `10` | Per-thread/lane mpsc channel capacity. Only applies to `per-thread` / `per-lane` modes. | +| `max_batch_tokens` | u32 | `24000` | Soft token cap per ACP turn. Only applies to `per-thread` / `per-lane` modes. | --- @@ -60,6 +63,9 @@ Slack adapter using Socket Mode. Requires both a Bot User OAuth Token and an App | `trusted_bot_ids` | string[] | `[]` | Slack Bot User IDs (`U...`) or Bot IDs (`B...`). `U...` matching resolves event Bot IDs via Slack `bots.info`, so the bot token needs `users:read`. | | `allow_user_messages` | string | `"involved"` | Same as Discord. | | `max_bot_turns` | u32 | `100` | Same as Discord. | +| `message_processing_mode` | string | `"per-message"` | Same as Discord. See [Message Dispatch Modes](message-dispatch-modes.md). | +| `max_buffered_messages` | u32 | `10` | Same as Discord. | +| `max_batch_tokens` | u32 | `24000` | Same as Discord. | --- @@ -77,6 +83,9 @@ Custom Gateway adapter for platforms like Telegram, LINE, Feishu/Lark, and Googl | `allowed_channels` | string[] | `[]` | Chat/group IDs to allow. Only checked when `allow_all_channels` resolves to false. | | `allow_all_users` | bool \| omit | auto-detect | `true` = any user; `false` = only `allowed_users`. Omitted = inferred from list. | | `allowed_users` | string[] | `[]` | User IDs to allow. Only checked when `allow_all_users` resolves to false. | +| `message_processing_mode` | string | `"per-message"` | Same as Discord. See [Message Dispatch Modes](message-dispatch-modes.md). | +| `max_buffered_messages` | u32 | `10` | Same as Discord. | +| `max_batch_tokens` | u32 | `24000` | Same as Discord. | --- @@ -314,6 +323,9 @@ Key mapping (`values.yaml` → `config.toml`): | `agents..discord.allowBotMessages` | `[discord] allow_bot_messages` | | `agents..discord.trustedBotIds` | `[discord] trusted_bot_ids` | | `agents..discord.allowUserMessages` | `[discord] allow_user_messages` | +| `agents..discord.messageProcessingMode` | `[discord] message_processing_mode` | +| `agents..discord.maxBufferedMessages` | `[discord] max_buffered_messages` | +| `agents..discord.maxBatchTokens` | `[discord] max_batch_tokens` | | `agents..slack.*` | `[slack] *` (same pattern) | | `agents..pool.maxSessions` | `[pool] max_sessions` | | `agents..pool.sessionTtlHours` | `[pool] session_ttl_hours` | From 914ca437be398c3f92150adf1733399531f41c37 Mon Sep 17 00:00:00 2001 From: chaodufashi Date: Sun, 17 May 2026 17:14:28 -0400 Subject: [PATCH 040/100] docs: add CI Visibility for Discord Teams refarch (#838) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * docs: add CI-to-Discord notification reference architecture * docs: add CI-to-Discord notification reference architecture * docs: add Problem/Challenges/Solution sections * docs: remove bot-specific references * docs: add What We Want section * docs: add Polling vs Notification mode comparison * docs: clarify scope difference between polling and notification modes * docs: add comparison table for polling vs notification * docs: rename to CI Observability via Discord * docs: rename file to ci-observability-discord.md --------- Co-authored-by: chaodu-agent[bot] Co-authored-by: 超渡法師 --- docs/refarch/ci-observability-discord.md | 360 +++++++++++++++++++++++ 1 file changed, 360 insertions(+) create mode 100644 docs/refarch/ci-observability-discord.md diff --git a/docs/refarch/ci-observability-discord.md b/docs/refarch/ci-observability-discord.md new file mode 100644 index 000000000..bd1db3a6d --- /dev/null +++ b/docs/refarch/ci-observability-discord.md @@ -0,0 +1,360 @@ +# Reference Architecture: CI Observability via Discord + +> **This doc is meant to be used with Kiro or any coding CLI.** Prompt your AI agent with something like: +> +> ``` +> per https://github.com/openabdev/openab/blob/main/docs/refarch/ci-discord-notify.md set up CI notifications to my Discord channel +> ``` +> +> and it will guide you through the full setup. + +Send GitHub Actions CI results (pass/fail) to a Discord channel or thread via webhook, with clickable links, duration, and user mentions. + +## Problem + +When CI runs in GitHub Actions, the only way to know the result is to check the GitHub UI or wait for an email. For teams collaborating in Discord, this creates friction: + +- **No visibility** — CI failures go unnoticed until someone manually checks GitHub +- **Slow feedback loop** — contributors wait without knowing their PR is broken +- **Context switching** — developers must leave Discord to check CI status +- **No accountability** — nobody gets pinged when CI breaks + +## What We Want + +- CI finishes (pass or fail) → automatically POST result to a specific Discord channel/thread +- Commit message is a **clickable link** pointing to the PR or commit +- Show who committed, how long CI took, and which step failed +- Mention a specific user so they get pinged +- Route notifications to the correct thread based on the PR description +- One reusable workflow that any CI job can call + +## Challenges + +| Challenge | Why it's hard | +|-----------|---------------| +| Notify regardless of outcome | GitHub Actions skips downstream jobs when upstream fails — need `if: always()` | +| Clickable links in Discord | Webhook `content` field does NOT support markdown links — must use embeds | +| Newlines in embed description | `jq --arg` treats `\n` as literal backslash-n — need `printf` for real newlines | +| Route to the right thread | Different PRs need notifications in different threads — need dynamic extraction | +| Don't repeat yourself | Multiple CI workflows need the same notification logic — need reusable workflow | +| Keep secrets safe | Webhook URL contains a token — must never appear in workflow files or logs | + +## Two Approaches + +### Approach 1: Polling Mode (Cronjob) + +OpenAB has a built-in cron scheduler. You can schedule the agent to periodically check CI status and fix failures: + +``` +@bot can you schedule a cronjob for yourself to this thread and remind yourself to +"check https://github.com/owner/repo/actions and fix them if required" every 10min? +``` + +This creates a `[[cron.jobs]]` entry: + +```toml +[[cron.jobs]] +schedule = "*/10 * * * *" +channel = "123456789012345678" +thread_id = "1505664791719710810" +message = "check https://github.com/owner/repo/actions and fix them if required" +``` + +**Pros:** Holistic view — checks everything on your plate (all workflows, all branches, all repos). Agent can auto-fix issues. No webhook configuration needed. + +**Cons:** Up to N-minute delay, unnecessary API calls when nothing changed, burns compute on polling. + +### Approach 2: Notification Mode (Webhook Push) ← This Doc + +CI pushes results to Discord the moment it finishes — zero delay, zero wasted calls. But it only tells you about **this single CI run**. + +``` +GitHub Actions ──finish──► HTTP POST ──► Discord thread + (webhook) +``` + +**Pros:** Instant notification, no polling cost, precise metadata (duration, failed step, commit info) for the specific run. + +**Cons:** Narrow scope — only reports on the workflow that triggered it. Can't see the big picture. Can't auto-fix (notification only). Requires webhook setup. + +### When to Use Which + +| | Polling (Cronjob) | Notification (Webhook) | +|---|---|---| +| **Scope** | Everything on your plate — all workflows, branches, repos | Single CI run only | +| **Latency** | Up to N minutes | Instant (on completion) | +| **Auto-fix** | ✅ Agent can push fixes | ❌ Notification only | +| **Setup** | Just tell the bot | Webhook + secrets + workflow changes | +| **Cost** | Burns compute even when idle | Zero cost when nothing runs | +| **Metadata** | Whatever the agent can scrape | Precise: duration, failed step, commit SHA | +| **Best for** | "Keep my CI green across all repos" | "Tell me the moment this PR breaks" | + +| Scenario | Recommended | +|----------|-------------| +| "Tell me when CI breaks" | Notification mode (this doc) | +| "Check CI and fix it automatically" | Polling mode (cronjob) | +| Both — notify immediately + auto-fix | Combine: webhook notifies, cronjob retries fixes | + +--- + +## Solution + +A **reusable workflow** (`notify-discord.yml`) that any CI workflow calls as its final job. It posts a Discord embed with clickable title, colored sidebar, and user mention — routing to the correct thread based on the PR description. + +## Architecture + +``` ++-- GitHub Actions ----------------------------------------+ +| | +| +-- ci.yml ------------------------------------------+ | +| | | | +| | [check] ──► cargo fmt / clippy / test | | +| | │ | | +| | │ outputs: status, duration, commit_msg, | | +| | │ commit_author, commit_sha | | +| | ▼ | | +| | [notify] (if: always()) | | +| | │ calls ──► notify-discord.yml (reusable) | | +| | │ | | +| +-----|----------------------------------------------+ | +| │ | +| │ inputs: status, commit_msg, pr_body, ... | +| │ secrets: DISCORD_WEBHOOK_URL | +| │ vars: DISCORD_THREAD_ID, DISCORD_MENTION_UID | +| │ | ++--------|─────────────────────────────────────────────────+ + │ + │ HTTP POST (webhook + ?thread_id=xxx) + ▼ ++-- Discord -----------------------------------------------+ +| | +| #channel or thread | +| ┌─────────────────────────────────────────────────┐ | +| │ ✅ feat: add new provider ← clickable │ | +| │ ────────────────────────────────────────────── │ | +| │ ✅ CI success — repo@main │ | +| │ 👤 author │ | +| │ ⏱️ 3m42s │ | +| │ View Run ← clickable │ | +| └─────────────────────────────────────────────────┘ | +| @user-mention | +| | ++----------------------------------------------------------+ +``` + +## Key Design Decisions + +| Decision | Rationale | +|----------|-----------| +| Reusable workflow (`workflow_call`) | Any CI workflow can call it; single source of truth | +| `if: always()` on notify job | Fires on success, failure, and cancellation | +| Discord embed (not plain content) | Supports clickable title, colored sidebar, markdown in description | +| Thread ID from PR body | Dynamic routing — each PR notifies its own thread | +| Fallback to repo variable | Push-to-main events still get notified somewhere | +| `printf` for newlines | `jq --arg` preserves real `\n` from printf output | + +## Setup + +### 1. Create a Discord Webhook + +Server Settings → Integrations → Webhooks → New Webhook → Copy URL. + +### 2. Configure Repository Secrets & Variables + +| Type | Name | Value | +|------|------|-------| +| **Secret** | `DISCORD_WEBHOOK_URL` | The webhook URL (contains token — keep secret) | +| **Variable** | `DISCORD_THREAD_ID` | Default thread ID for fallback notifications | +| **Variable** | `DISCORD_MENTION_USER_ID` | Discord user ID to mention (e.g. `1234567890`) | + +Set via CLI: + +```bash +gh secret set DISCORD_WEBHOOK_URL --repo / +gh variable set DISCORD_THREAD_ID --repo / --body "" +gh variable set DISCORD_MENTION_USER_ID --repo / --body "" +``` + +### 3. Create the Reusable Workflow + +`.github/workflows/notify-discord.yml`: + +```yaml +name: Discord Notify + +on: + workflow_call: + inputs: + status: + required: true + type: string + failed_step: + required: false + type: string + duration: + required: false + type: string + commit_msg: + required: false + type: string + commit_author: + required: false + type: string + commit_sha: + required: false + type: string + pr_body: + required: false + type: string + secrets: + DISCORD_WEBHOOK_URL: + required: true + +jobs: + notify: + runs-on: ubuntu-latest + steps: + - name: Send Discord notification + env: + WEBHOOK_URL: ${{ secrets.DISCORD_WEBHOOK_URL }} + DEFAULT_THREAD_ID: ${{ vars.DISCORD_THREAD_ID }} + MENTION_USER_ID: ${{ vars.DISCORD_MENTION_USER_ID }} + STATUS: ${{ inputs.status }} + FAILED_STEP: ${{ inputs.failed_step }} + DURATION: ${{ inputs.duration }} + COMMIT_MSG: ${{ inputs.commit_msg }} + COMMIT_AUTHOR: ${{ inputs.commit_author }} + COMMIT_SHA: ${{ inputs.commit_sha }} + PR_BODY: ${{ inputs.pr_body }} + RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} + REPO: ${{ github.repository }} + REF: ${{ github.ref_name }} + PR: ${{ github.event.pull_request.number }} + SERVER_URL: ${{ github.server_url }} + run: | + # Extract Thread ID from PR body, fallback to variable + THREAD_ID="" + if [ -n "$PR_BODY" ]; then + THREAD_ID=$(echo "$PR_BODY" | grep -ioP '^Thread:\s*\K[0-9]+' | head -1) + fi + [ -z "$THREAD_ID" ] && THREAD_ID="$DEFAULT_THREAD_ID" + + if [ "$STATUS" = "success" ]; then + COLOR=3066993; EMOJI="✅" + else + COLOR=15158332; EMOJI="❌" + fi + + # Embed title = commit msg (clickable link to PR or commit) + TITLE="${COMMIT_MSG:-CI ${STATUS}}" + if [ -n "$PR" ]; then + TITLE_URL="${SERVER_URL}/${REPO}/pull/${PR}" + elif [ -n "$COMMIT_SHA" ]; then + TITLE_URL="${SERVER_URL}/${REPO}/commit/${COMMIT_SHA}" + else + TITLE_URL="${RUN_URL}" + fi + + # Build description using printf for real newlines + DESC="${EMOJI} **CI ${STATUS}** — \`${REPO}@${REF}\`" + [ -n "$PR" ] && DESC="${DESC} | PR #${PR}" + [ -n "$COMMIT_AUTHOR" ] && DESC=$(printf "%s\n👤 %s" "$DESC" "$COMMIT_AUTHOR") + [ -n "$DURATION" ] && DESC=$(printf "%s\n⏱️ %s" "$DESC" "$DURATION") + [ "$STATUS" != "success" ] && [ -n "$FAILED_STEP" ] && \ + DESC=$(printf "%s\n💥 Failed at: **%s**" "$DESC" "$FAILED_STEP") + DESC=$(printf "%s\n[View Run](%s)" "$DESC" "$RUN_URL") + + # Build JSON payload + CONTENT="" + [ -n "$MENTION_USER_ID" ] && CONTENT="<@${MENTION_USER_ID}>" + + PAYLOAD=$(jq -n \ + --arg content "$CONTENT" \ + --arg title "$TITLE" \ + --arg url "$TITLE_URL" \ + --arg desc "$DESC" \ + --argjson color "$COLOR" \ + '{content: $content, embeds: [{title: $title, url: $url, description: $desc, color: $color}]}') + + URL="${WEBHOOK_URL}" + [ -n "$THREAD_ID" ] && URL="${URL}?thread_id=${THREAD_ID}" + + curl -sf -X POST "$URL" \ + -H "Content-Type: application/json" \ + -d "$PAYLOAD" +``` + +### 4. Wire Into Your CI Workflow + +Add a `notify` job at the end of any workflow: + +```yaml +jobs: + check: + runs-on: ubuntu-latest + outputs: + duration: ${{ steps.duration.outputs.value }} + commit_msg: ${{ steps.meta.outputs.commit_msg }} + commit_author: ${{ steps.meta.outputs.commit_author }} + failed_step: ${{ steps.meta.outputs.failed_step }} + steps: + - name: Record start time + id: start + run: echo "ts=$(date +%s)" >> "$GITHUB_OUTPUT" + + # ... your build/test steps (give each an id) ... + + - name: Collect metadata + id: meta + if: always() + run: | + echo "commit_msg=$(git log -1 --pretty=%s)" >> "$GITHUB_OUTPUT" + echo "commit_author=$(git log -1 --pretty=%an)" >> "$GITHUB_OUTPUT" + # Detect which step failed + FAILED="" + # if [ "${{ steps.test.outcome }}" = "failure" ]; then FAILED="Tests"; fi + echo "failed_step=${FAILED}" >> "$GITHUB_OUTPUT" + + - name: Calculate duration + id: duration + if: always() + run: | + ELAPSED=$(( $(date +%s) - ${{ steps.start.outputs.ts }} )) + echo "value=$((ELAPSED/60))m$((ELAPSED%60))s" >> "$GITHUB_OUTPUT" + + notify: + needs: [check] + if: always() + uses: ./.github/workflows/notify-discord.yml + with: + status: ${{ needs.check.result }} + failed_step: ${{ needs.check.outputs.failed_step }} + duration: ${{ needs.check.outputs.duration }} + commit_msg: ${{ needs.check.outputs.commit_msg }} + commit_author: ${{ needs.check.outputs.commit_author }} + commit_sha: ${{ github.event.pull_request.head.sha || github.sha }} + pr_body: ${{ github.event.pull_request.body }} + secrets: + DISCORD_WEBHOOK_URL: ${{ secrets.DISCORD_WEBHOOK_URL }} +``` + +### 5. Dynamic Thread Routing via PR Description + +Add a `Thread:` line anywhere in your PR description: + +``` +Thread: 1505664791719710810 +``` + +The workflow extracts the first match and posts to that thread. If absent, it falls back to `DISCORD_THREAD_ID` variable. + +## Gotchas + +| Issue | Solution | +|-------|----------| +| `content` field doesn't support markdown links | Use `embeds` with `title`/`url` for clickable links | +| `\n` in `jq --arg` becomes literal `\\n` | Use `printf` to produce real newlines before passing to jq | +| Duplicate YAML keys silently break workflows | Validate with `actionlint` or check Actions run errors | +| Webhook URL contains a token | Always store as a **secret**, never in workflow files or docs | +| `if: always()` required on notify job | Otherwise it's skipped when upstream jobs fail | +| Mention requires numeric Discord user ID | Use `<@USER_ID>` format in `content` (not in embed) | From ffcc8e8466a92f21361dbbb9db404c1f20290c65 Mon Sep 17 00:00:00 2001 From: chaodufashi Date: Sun, 17 May 2026 21:14:47 -0400 Subject: [PATCH 041/100] docs: update CI observability refarch with final implementation (#839) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: 超渡法師 --- docs/refarch/ci-observability-discord.md | 105 +++++++++-------------- 1 file changed, 40 insertions(+), 65 deletions(-) diff --git a/docs/refarch/ci-observability-discord.md b/docs/refarch/ci-observability-discord.md index bd1db3a6d..34159bf75 100644 --- a/docs/refarch/ci-observability-discord.md +++ b/docs/refarch/ci-observability-discord.md @@ -3,12 +3,12 @@ > **This doc is meant to be used with Kiro or any coding CLI.** Prompt your AI agent with something like: > > ``` -> per https://github.com/openabdev/openab/blob/main/docs/refarch/ci-discord-notify.md set up CI notifications to my Discord channel +> per https://github.com/openabdev/openab/blob/main/docs/refarch/ci-observability-discord.md set up CI notifications to my Discord channel > ``` > > and it will guide you through the full setup. -Send GitHub Actions CI results (pass/fail) to a Discord channel or thread via webhook, with clickable links, duration, and user mentions. +Send GitHub Actions CI results (pass/fail) to a Discord channel or thread via webhook, with full CI metadata and user mentions. ## Problem @@ -22,23 +22,13 @@ When CI runs in GitHub Actions, the only way to know the result is to check the ## What We Want - CI finishes (pass or fail) → automatically POST result to a specific Discord channel/thread -- Commit message is a **clickable link** pointing to the PR or commit - Show who committed, how long CI took, and which step failed +- Include both PR URL and Run URL for quick navigation - Mention a specific user so they get pinged +- Bot-readable content (not hidden in embeds) so mentioned bots can act on it - Route notifications to the correct thread based on the PR description - One reusable workflow that any CI job can call -## Challenges - -| Challenge | Why it's hard | -|-----------|---------------| -| Notify regardless of outcome | GitHub Actions skips downstream jobs when upstream fails — need `if: always()` | -| Clickable links in Discord | Webhook `content` field does NOT support markdown links — must use embeds | -| Newlines in embed description | `jq --arg` treats `\n` as literal backslash-n — need `printf` for real newlines | -| Route to the right thread | Different PRs need notifications in different threads — need dynamic extraction | -| Don't repeat yourself | Multiple CI workflows need the same notification logic — need reusable workflow | -| Keep secrets safe | Webhook URL contains a token — must never appear in workflow files or logs | - ## Two Approaches ### Approach 1: Polling Mode (Cronjob) @@ -77,7 +67,7 @@ GitHub Actions ──finish──► HTTP POST ──► Discord thread **Cons:** Narrow scope — only reports on the workflow that triggered it. Can't see the big picture. Can't auto-fix (notification only). Requires webhook setup. -### When to Use Which +### Comparison | | Polling (Cronjob) | Notification (Webhook) | |---|---|---| @@ -99,7 +89,7 @@ GitHub Actions ──finish──► HTTP POST ──► Discord thread ## Solution -A **reusable workflow** (`notify-discord.yml`) that any CI workflow calls as its final job. It posts a Discord embed with clickable title, colored sidebar, and user mention — routing to the correct thread based on the PR description. +A **reusable workflow** (`notify-discord.yml`) that any CI workflow calls as its final job. It posts CI results as plain-text content (bot-readable) with user mention — routing to the correct thread based on the PR description. ## Architecture @@ -111,7 +101,7 @@ A **reusable workflow** (`notify-discord.yml`) that any CI workflow calls as its | | [check] ──► cargo fmt / clippy / test | | | | │ | | | | │ outputs: status, duration, commit_msg, | | -| | │ commit_author, commit_sha | | +| | │ commit_author, failed_step | | | | ▼ | | | | [notify] (if: always()) | | | | │ calls ──► notify-discord.yml (reusable) | | @@ -130,14 +120,14 @@ A **reusable workflow** (`notify-discord.yml`) that any CI workflow calls as its | | | #channel or thread | | ┌─────────────────────────────────────────────────┐ | -| │ ✅ feat: add new provider ← clickable │ | -| │ ────────────────────────────────────────────── │ | -| │ ✅ CI success — repo@main │ | -| │ 👤 author │ | +| │ ❌ CI failure — repo@main | PR #42 │ | +| │ 👤 author — commit message │ | | │ ⏱️ 3m42s │ | -| │ View Run ← clickable │ | +| │ 💥 Failed at: Tests │ | +| │ https://github.com/.../pull/42 │ | +| │ https://github.com/.../actions/runs/123 │ | +| │ @user-mention │ | | └─────────────────────────────────────────────────┘ | -| @user-mention | | | +----------------------------------------------------------+ ``` @@ -148,10 +138,11 @@ A **reusable workflow** (`notify-discord.yml`) that any CI workflow calls as its |----------|-----------| | Reusable workflow (`workflow_call`) | Any CI workflow can call it; single source of truth | | `if: always()` on notify job | Fires on success, failure, and cancellation | -| Discord embed (not plain content) | Supports clickable title, colored sidebar, markdown in description | +| Plain-text content (not embed) | Bots can read `message.content`; embeds are invisible to bots | +| `printf` + `jq --rawfile` | Only reliable way to get real newlines into JSON payload | | Thread ID from PR body | Dynamic routing — each PR notifies its own thread | | Fallback to repo variable | Push-to-main events still get notified somewhere | -| `printf` for newlines | `jq --arg` preserves real `\n` from printf output | +| Both PR URL and Run URL | PR for context, Run for debugging logs | ## Setup @@ -200,9 +191,6 @@ on: commit_author: required: false type: string - commit_sha: - required: false - type: string pr_body: required: false type: string @@ -224,7 +212,6 @@ jobs: DURATION: ${{ inputs.duration }} COMMIT_MSG: ${{ inputs.commit_msg }} COMMIT_AUTHOR: ${{ inputs.commit_author }} - COMMIT_SHA: ${{ inputs.commit_sha }} PR_BODY: ${{ inputs.pr_body }} RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} REPO: ${{ github.repository }} @@ -240,41 +227,29 @@ jobs: [ -z "$THREAD_ID" ] && THREAD_ID="$DEFAULT_THREAD_ID" if [ "$STATUS" = "success" ]; then - COLOR=3066993; EMOJI="✅" - else - COLOR=15158332; EMOJI="❌" - fi - - # Embed title = commit msg (clickable link to PR or commit) - TITLE="${COMMIT_MSG:-CI ${STATUS}}" - if [ -n "$PR" ]; then - TITLE_URL="${SERVER_URL}/${REPO}/pull/${PR}" - elif [ -n "$COMMIT_SHA" ]; then - TITLE_URL="${SERVER_URL}/${REPO}/commit/${COMMIT_SHA}" + EMOJI="✅" else - TITLE_URL="${RUN_URL}" + EMOJI="❌" fi - # Build description using printf for real newlines - DESC="${EMOJI} **CI ${STATUS}** — \`${REPO}@${REF}\`" - [ -n "$PR" ] && DESC="${DESC} | PR #${PR}" - [ -n "$COMMIT_AUTHOR" ] && DESC=$(printf "%s\n👤 %s" "$DESC" "$COMMIT_AUTHOR") - [ -n "$DURATION" ] && DESC=$(printf "%s\n⏱️ %s" "$DESC" "$DURATION") - [ "$STATUS" != "success" ] && [ -n "$FAILED_STEP" ] && \ - DESC=$(printf "%s\n💥 Failed at: **%s**" "$DESC" "$FAILED_STEP") - DESC=$(printf "%s\n[View Run](%s)" "$DESC" "$RUN_URL") - - # Build JSON payload - CONTENT="" - [ -n "$MENTION_USER_ID" ] && CONTENT="<@${MENTION_USER_ID}>" - - PAYLOAD=$(jq -n \ - --arg content "$CONTENT" \ - --arg title "$TITLE" \ - --arg url "$TITLE_URL" \ - --arg desc "$DESC" \ - --argjson color "$COLOR" \ - '{content: $content, embeds: [{title: $title, url: $url, description: $desc, color: $color}]}') + # Build message into a temp file for proper newlines + { + printf '%s **CI %s** — `%s@%s`' "$EMOJI" "$STATUS" "$REPO" "$REF" + [ -n "$PR" ] && printf ' | PR #%s' "$PR" + echo "" + [ -n "$COMMIT_AUTHOR" ] && printf '👤 %s' "$COMMIT_AUTHOR" + [ -n "$COMMIT_MSG" ] && printf ' — `%s`' "$COMMIT_MSG" + [ -n "$COMMIT_AUTHOR" ] && echo "" + [ -n "$DURATION" ] && echo "⏱️ ${DURATION}" + [ "$STATUS" != "success" ] && [ -n "$FAILED_STEP" ] && echo "💥 Failed at: **${FAILED_STEP}**" + [ -n "$PR" ] && echo "${SERVER_URL}/${REPO}/pull/${PR}" + echo "$RUN_URL" + [ -n "$MENTION_USER_ID" ] && echo "<@${MENTION_USER_ID}>" + } > /tmp/msg.txt + + # Use jq --rawfile to preserve real newlines in JSON + PAYLOAD=$(jq -n --rawfile msg /tmp/msg.txt \ + '{content: $msg, allowed_mentions: {parse: ["users"]}}') URL="${WEBHOOK_URL}" [ -n "$THREAD_ID" ] && URL="${URL}?thread_id=${THREAD_ID}" @@ -332,7 +307,6 @@ jobs: duration: ${{ needs.check.outputs.duration }} commit_msg: ${{ needs.check.outputs.commit_msg }} commit_author: ${{ needs.check.outputs.commit_author }} - commit_sha: ${{ github.event.pull_request.head.sha || github.sha }} pr_body: ${{ github.event.pull_request.body }} secrets: DISCORD_WEBHOOK_URL: ${{ secrets.DISCORD_WEBHOOK_URL }} @@ -352,9 +326,10 @@ The workflow extracts the first match and posts to that thread. If absent, it fa | Issue | Solution | |-------|----------| -| `content` field doesn't support markdown links | Use `embeds` with `title`/`url` for clickable links | -| `\n` in `jq --arg` becomes literal `\\n` | Use `printf` to produce real newlines before passing to jq | +| Embed content invisible to bots | Use plain-text `content` field — bots only see `message.content` | +| `\n` in `jq --arg` becomes literal `\\n` | Write to temp file, use `jq --rawfile` to preserve real newlines | | Duplicate YAML keys silently break workflows | Validate with `actionlint` or check Actions run errors | | Webhook URL contains a token | Always store as a **secret**, never in workflow files or docs | | `if: always()` required on notify job | Otherwise it's skipped when upstream jobs fail | -| Mention requires numeric Discord user ID | Use `<@USER_ID>` format in `content` (not in embed) | +| Mention requires numeric Discord user ID | Use `<@USER_ID>` format in `content` | +| Webhook mentions don't trigger bots | Webhook messages don't fire bot `MESSAGE_CREATE` — mention real users instead | From 26b10e0ca1088660dc6fa816f1ddff806ce698f0 Mon Sep 17 00:00:00 2001 From: chaodufashi Date: Mon, 18 May 2026 00:35:43 -0400 Subject: [PATCH 042/100] =?UTF-8?q?feat:=20add=20xai-proxy=20=E2=80=94=20O?= =?UTF-8?q?Auth=20PKCE=20sidecar=20for=20xAI=20SuperGrok=20(#841)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: add xai-proxy — OAuth PKCE sidecar for xAI SuperGrok Lightweight Rust binary that authenticates with xAI via browser OAuth (PKCE, borrowing Grok CLI's public client ID) and proxies OpenAI-compatible requests to api.x.ai/v1 with the subscription token injected. Allows any OpenAI-compatible coding agent (Claude Code, OpenCode, Codex CLI, etc.) to use SuperGrok subscription quota instead of per-token API credits. * ci: add workflow for xai-proxy (check, clippy, test, build) * feat(xai-proxy): add device-code login for headless environments Adds `login-device` subcommand that uses RFC 8628 device authorization grant. Works in K8s exec, ECS exec, SSH — no browser or port-forward needed. Usage: xai-proxy login-device # prints verification URL + user code # poll until user approves on any device * fix(xai-proxy): install rustls crypto provider + add XAI_PROXY_TOKEN_PATH env var - Add rustls as direct dep and call install_default() in main() to fix 'Could not automatically determine CryptoProvider' panic on serve - Support XAI_PROXY_TOKEN_PATH env var for custom token file location (useful for K8s PVC persistence at e.g. /home/agent/.openab/xai-proxy/tokens.json) - Add Dockerfile for container builds * docs(xai-proxy): update README with architecture diagram, docker, k8s sidecar * docs: add xai-proxy deployment guide --------- Co-authored-by: 超渡法師 Co-authored-by: chaodu-agent Co-authored-by: thepagent --- .github/workflows/ci-xai-proxy.yml | 42 + docs/xai-proxy.md | 140 ++ xai-proxy/.gitignore | 1 + xai-proxy/Cargo.lock | 2381 ++++++++++++++++++++++++++++ xai-proxy/Cargo.toml | 35 + xai-proxy/Dockerfile | 11 + xai-proxy/README.md | 143 ++ xai-proxy/src/main.rs | 546 +++++++ 8 files changed, 3299 insertions(+) create mode 100644 .github/workflows/ci-xai-proxy.yml create mode 100644 docs/xai-proxy.md create mode 100644 xai-proxy/.gitignore create mode 100644 xai-proxy/Cargo.lock create mode 100644 xai-proxy/Cargo.toml create mode 100644 xai-proxy/Dockerfile create mode 100644 xai-proxy/README.md create mode 100644 xai-proxy/src/main.rs diff --git a/.github/workflows/ci-xai-proxy.yml b/.github/workflows/ci-xai-proxy.yml new file mode 100644 index 000000000..45ac2ed74 --- /dev/null +++ b/.github/workflows/ci-xai-proxy.yml @@ -0,0 +1,42 @@ +name: CI (xai-proxy) + +on: + pull_request: + paths: + - "xai-proxy/**" + push: + branches: [main] + paths: + - "xai-proxy/**" + +env: + CARGO_TERM_COLOR: always + +jobs: + check: + runs-on: ubuntu-latest + defaults: + run: + working-directory: xai-proxy + steps: + - uses: actions/checkout@v6 + + - uses: dtolnay/rust-toolchain@stable + with: + components: clippy + + - uses: Swatinem/rust-cache@v2 + with: + workspaces: xai-proxy + + - name: cargo check + run: cargo check + + - name: cargo clippy + run: cargo clippy -- -D warnings + + - name: cargo test + run: cargo test + + - name: cargo build (release) + run: cargo build --release diff --git a/docs/xai-proxy.md b/docs/xai-proxy.md new file mode 100644 index 000000000..93bbbd443 --- /dev/null +++ b/docs/xai-proxy.md @@ -0,0 +1,140 @@ +# xAI Proxy (SuperGrok Sidecar) + +xai-proxy is a lightweight Rust sidecar that lets any OpenAI-compatible agent use your **SuperGrok subscription** instead of per-token API credits. It authenticates via OAuth and proxies requests to `api.x.ai/v1`. + +## Architecture + +``` +┌─ Kubernetes Pod ──────────────────────────────────────────────┐ +│ │ +│ openab → opencode acp │ +│ │ POST /v1/chat/completions │ +│ ▼ │ +│ xai-proxy :9090 │ +│ • Injects OAuth Bearer token │ +│ • Auto-refreshes 120s before expiry │ +│ │ │ +│ PVC: /home/agent/.openab/xai-proxy/tokens.json │ +└───────────────┼───────────────────────────────────────────────┘ + ▼ + https://api.x.ai/v1 (SuperGrok) +``` + +## Prerequisites + +- Active SuperGrok subscription (any tier) +- A machine with browser access (or SSH tunnel) for initial login + +## Helm Install + +```bash +# 1. Login locally to get tokens +xai-proxy login-device + +# 2. Create K8s secret from token file +kubectl create secret generic xai-proxy-tokens \ + --from-file=tokens.json=$HOME/.xai-proxy/tokens.json + +# 3. Deploy with opencode + xai-proxy sidecar +helm install openab openab/openab \ + --set agents.kiro.enabled=false \ + --set agents.mybot.discord.botToken="$DISCORD_BOT_TOKEN" \ + --set agents.mybot.discord.allowAllChannels=true \ + --set agents.mybot.command=opencode \ + --set-json 'agents.mybot.args=["acp"]' \ + --set agents.mybot.image=ghcr.io/openabdev/openab-opencode \ + --set-json 'agents.mybot.extraVolumes=[{"name":"xai-tokens-src","secret":{"secretName":"xai-proxy-tokens"}},{"name":"opencode-config","configMap":{"name":"opencode-xai-config"}},{"name":"opencode-auth","configMap":{"name":"opencode-xai-auth"}}]' \ + --set-json 'agents.mybot.extraVolumeMounts=[{"name":"opencode-config","mountPath":"/home/agent/opencode.json","subPath":"opencode.json"},{"name":"opencode-config","mountPath":"/home/agent/.config/opencode/opencode.json","subPath":"opencode.json"},{"name":"opencode-auth","mountPath":"/home/agent/.local/share/opencode/auth.json","subPath":"auth.json"}]' \ + --set-json 'agents.mybot.extraInitContainers=[{"name":"copy-tokens","image":"busybox","command":["sh","-c","if [ ! -f /dest/.openab/xai-proxy/tokens.json ]; then mkdir -p /dest/.openab/xai-proxy && cp /src/tokens.json /dest/.openab/xai-proxy/tokens.json; fi"],"volumeMounts":[{"name":"xai-tokens-src","mountPath":"/src","readOnly":true},{"name":"data","mountPath":"/dest"}]}]' \ + --set-json 'agents.mybot.extraContainers=[{"name":"xai-proxy","image":"xai-proxy:latest","args":["serve","--bind","0.0.0.0"],"env":[{"name":"XAI_PROXY_TOKEN_PATH","value":"/home/agent/.openab/xai-proxy/tokens.json"}],"ports":[{"containerPort":9090}],"volumeMounts":[{"name":"data","mountPath":"/home/agent"}]}]' +``` + +## OpenCode Configuration + +Create a ConfigMap for the opencode provider config: + +```bash +kubectl create configmap opencode-xai-config --from-file=opencode.json=- <<'EOF' +{ + "$schema": "https://opencode.ai/config.json", + "model": "xai/grok-4.3", + "provider": { + "xai": { + "npm": "@ai-sdk/openai-compatible", + "name": "xAI (SuperGrok)", + "options": { + "baseURL": "http://localhost:9090/v1", + "apiKey": "dummy" + }, + "models": { + "grok-4.3": { "name": "Grok 4.3" } + } + } + } +} +EOF + +kubectl create configmap opencode-xai-auth --from-file=auth.json=- <<'EOF' +{ "xai": "dummy" } +EOF +``` + +## Authentication + +### Device-code flow (recommended for headless) + +```bash +xai-proxy login-device +``` + +Prints a URL and code. Open the URL in any browser, enter the code, and authorize. + +### Browser OAuth (local machine) + +```bash +xai-proxy login +``` + +Opens your browser to `auth.x.ai`. Sign in and authorize. Callback is received on `127.0.0.1:56121`. + +### Token refresh + +xai-proxy auto-refreshes the OAuth token 120 seconds before expiry. The refreshed token is written back to the token file (persisted on PVC across pod restarts). + +## Environment Variables + +| Variable | Default | Description | +|----------|---------|-------------| +| `XAI_PROXY_TOKEN_PATH` | `~/.xai-proxy/tokens.json` | Custom token file path | +| `RUST_LOG` | `xai_proxy=info` | Log level | + +## Token Persistence + +The init container seeds the token from the K8s secret on first boot only. After that, xai-proxy reads and writes the token directly on the PVC. This means: + +- Token refreshes survive pod restarts +- The K8s secret is only needed for initial bootstrap +- To force a token reset, delete `/home/agent/.openab/xai-proxy/tokens.json` from the PVC + +## Standalone Usage (no K8s) + +```bash +# Build +cargo build --release + +# Login +./target/release/xai-proxy login-device + +# Serve +./target/release/xai-proxy serve --port 9090 + +# Use with any OpenAI-compatible client +export OPENAI_BASE_URL=http://127.0.0.1:9090/v1 +export OPENAI_API_KEY=dummy +opencode +``` + +## Limitations + +- **codex-acp** and **claude-agent-acp** require their own proprietary auth and won't use `OPENAI_BASE_URL` — use opencode or hermes-acp instead +- Browser OAuth (`xai-proxy login`) requires Cloudflare to not block your IP — use `login-device` if blocked diff --git a/xai-proxy/.gitignore b/xai-proxy/.gitignore new file mode 100644 index 000000000..ea8c4bf7f --- /dev/null +++ b/xai-proxy/.gitignore @@ -0,0 +1 @@ +/target diff --git a/xai-proxy/Cargo.lock b/xai-proxy/Cargo.lock new file mode 100644 index 000000000..5fe2be21d --- /dev/null +++ b/xai-proxy/Cargo.lock @@ -0,0 +1,2381 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + +[[package]] +name = "anstream" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" + +[[package]] +name = "anstyle-parse" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys 0.61.2", +] + +[[package]] +name = "anyhow" +version = "1.0.102" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "aws-lc-rs" +version = "1.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ec2f1fc3ec205783a5da9a7e6c1509cc69dedf09a1949e412c1e18469326d00" +dependencies = [ + "aws-lc-sys", + "zeroize", +] + +[[package]] +name = "aws-lc-sys" +version = "0.41.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a2f9779ce85b93ab6170dd940ad0169b5766ff848247aff13bb788b832fe3f4" +dependencies = [ + "cc", + "cmake", + "dunce", + "fs_extra", +] + +[[package]] +name = "axum" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "31b698c5f9a010f6573133b09e0de5408834d0c82f8d7475a89fc1867a71cd90" +dependencies = [ + "axum-core", + "bytes", + "form_urlencoded", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-util", + "itoa", + "matchit", + "memchr", + "mime", + "percent-encoding", + "pin-project-lite", + "serde_core", + "serde_json", + "serde_path_to_error", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tower", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "axum-core" +version = "0.5.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08c78f31d7b1291f7ee735c1c6780ccde7785daae9a9206026862dab7d8792d1" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "http-body-util", + "mime", + "pin-project-lite", + "sync_wrapper", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "bitflags" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3" + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "bumpalo" +version = "3.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" + +[[package]] +name = "bytes" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" + +[[package]] +name = "cc" +version = "1.2.62" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1dce859f0832a7d088c4f1119888ab94ef4b5d6795d1ce05afb7fe159d79f98" +dependencies = [ + "find-msvc-tools", + "jobserver", + "libc", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "cfg_aliases" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" + +[[package]] +name = "clap" +version = "4.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ddb117e43bbf7dacf0a4190fef4d345b9bad68dfc649cb349e7d17d28428e51" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2ce8604710f6733aa641a2b3731eaa1e8b3d9973d5e3565da11800813f997a9" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "clap_lex" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" + +[[package]] +name = "cmake" +version = "0.1.58" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0f78a02292a74a88ac736019ab962ece0bc380e3f977bf72e376c5d78ff0678" +dependencies = [ + "cc", +] + +[[package]] +name = "colorchoice" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" + +[[package]] +name = "core-foundation" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", +] + +[[package]] +name = "dirs" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3e8aa94d75141228480295a7d0e7feb620b1a5ad9f12bc40be62411e38cce4e" +dependencies = [ + "dirs-sys", +] + +[[package]] +name = "dirs-sys" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab" +dependencies = [ + "libc", + "option-ext", + "redox_users", + "windows-sys 0.61.2", +] + +[[package]] +name = "displaydoc" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "dunce" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "fs_extra" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" + +[[package]] +name = "futures-channel" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" +dependencies = [ + "futures-core", +] + +[[package]] +name = "futures-core" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" + +[[package]] +name = "futures-sink" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" + +[[package]] +name = "futures-task" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" + +[[package]] +name = "futures-util" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +dependencies = [ + "futures-core", + "futures-task", + "pin-project-lite", + "slab", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "wasi", + "wasm-bindgen", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "r-efi 5.3.0", + "wasip2", + "wasm-bindgen", +] + +[[package]] +name = "getrandom" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" +dependencies = [ + "cfg-if", + "libc", + "r-efi 6.0.0", + "wasip2", + "wasip3", +] + +[[package]] +name = "h2" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "171fefbc92fe4a4de27e0698d6a5b392d6a0e333506bc49133760b3bcf948733" +dependencies = [ + "atomic-waker", + "bytes", + "fnv", + "futures-core", + "futures-sink", + "http", + "indexmap", + "slab", + "tokio", + "tokio-util", + "tracing", +] + +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "foldhash", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "http" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3ba2a386d7f85a81f119ad7498ebe444d2e22c2af0b86b069416ace48b3311a" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + +[[package]] +name = "hyper" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6299f016b246a94207e63da54dbe807655bf9e00044f73ded42c3ac5305fbcca" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "h2", + "http", + "http-body", + "httparse", + "httpdate", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-rustls" +version = "0.27.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f" +dependencies = [ + "http", + "hyper", + "hyper-util", + "log", + "rustls", + "rustls-native-certs", + "tokio", + "tokio-rustls", + "tower-service", + "webpki-roots", +] + +[[package]] +name = "hyper-util" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" +dependencies = [ + "base64", + "bytes", + "futures-channel", + "futures-util", + "http", + "http-body", + "hyper", + "ipnet", + "libc", + "percent-encoding", + "pin-project-lite", + "socket2", + "tokio", + "tower-service", + "tracing", +] + +[[package]] +name = "icu_collections" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38" + +[[package]] +name = "icu_properties" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de" +dependencies = [ + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14" + +[[package]] +name = "icu_provider" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "id-arena" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "indexmap" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown 0.17.1", + "serde", + "serde_core", +] + +[[package]] +name = "ipnet" +version = "2.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" + +[[package]] +name = "is-docker" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "928bae27f42bc99b60d9ac7334e3a21d10ad8f1835a4e12ec3ec0464765ed1b3" +dependencies = [ + "once_cell", +] + +[[package]] +name = "is-wsl" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "173609498df190136aa7dea1a91db051746d339e18476eed5ca40521f02d7aa5" +dependencies = [ + "is-docker", + "once_cell", +] + +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "jobserver" +version = "0.1.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33" +dependencies = [ + "getrandom 0.3.4", + "libc", +] + +[[package]] +name = "js-sys" +version = "0.3.98" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67df7112613f8bfd9150013a0314e196f4800d3201ae742489d999db2f979f08" +dependencies = [ + "cfg-if", + "futures-util", + "once_cell", + "wasm-bindgen", +] + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + +[[package]] +name = "leb128fmt" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" + +[[package]] +name = "libc" +version = "0.2.186" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" + +[[package]] +name = "libredox" +version = "0.1.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e02f3bb43d335493c96bf3fd3a321600bf6bd07ed34bc64118e9293bdffea46c" +dependencies = [ + "libc", +] + +[[package]] +name = "litemap" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" + +[[package]] +name = "lru-slab" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" + +[[package]] +name = "matchers" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9" +dependencies = [ + "regex-automata", +] + +[[package]] +name = "matchit" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" + +[[package]] +name = "memchr" +version = "2.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" + +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "mio" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "nu-ansi-term" +version = "0.50.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + +[[package]] +name = "open" +version = "5.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2fbaa89d2ddc8473c78a3adf69eea8cffa28c483b8e02a971ef31527cd0fc92c" +dependencies = [ + "is-wsl", + "libc", + "pathdiff", +] + +[[package]] +name = "openssl-probe" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" + +[[package]] +name = "option-ext" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link", +] + +[[package]] +name = "pathdiff" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df94ce210e5bc13cb6651479fa48d14f601d9858cfe0467f43ae157023b938d3" + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "potential_utf" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" +dependencies = [ + "zerovec", +] + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn", +] + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quinn" +version = "0.11.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e20a958963c291dc322d98411f541009df2ced7b5a4f2bd52337638cfccf20" +dependencies = [ + "bytes", + "cfg_aliases", + "pin-project-lite", + "quinn-proto", + "quinn-udp", + "rustc-hash", + "rustls", + "socket2", + "thiserror", + "tokio", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-proto" +version = "0.11.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "434b42fec591c96ef50e21e886936e66d3cc3f737104fdb9b737c40ffb94c098" +dependencies = [ + "bytes", + "getrandom 0.3.4", + "lru-slab", + "rand 0.9.4", + "ring", + "rustc-hash", + "rustls", + "rustls-pki-types", + "slab", + "thiserror", + "tinyvec", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-udp" +version = "0.5.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd" +dependencies = [ + "cfg_aliases", + "libc", + "once_cell", + "socket2", + "tracing", + "windows-sys 0.60.2", +] + +[[package]] +name = "quote" +version = "1.0.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "rand" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a" +dependencies = [ + "libc", + "rand_chacha 0.3.1", + "rand_core 0.6.4", +] + +[[package]] +name = "rand" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea" +dependencies = [ + "rand_chacha 0.9.0", + "rand_core 0.9.5", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core 0.6.4", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core 0.9.5", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.17", +] + +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", +] + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags", +] + +[[package]] +name = "redox_users" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac" +dependencies = [ + "getrandom 0.2.17", + "libredox", + "thiserror", +] + +[[package]] +name = "regex-automata" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" + +[[package]] +name = "reqwest" +version = "0.12.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" +dependencies = [ + "base64", + "bytes", + "futures-core", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-util", + "js-sys", + "log", + "percent-encoding", + "pin-project-lite", + "quinn", + "rustls", + "rustls-pki-types", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tokio-rustls", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "webpki-roots", +] + +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + +[[package]] +name = "rustc-hash" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe" + +[[package]] +name = "rustls" +version = "0.23.40" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef86cd5876211988985292b91c96a8f2d298df24e75989a43a3c73f2d4d8168b" +dependencies = [ + "aws-lc-rs", + "log", + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-native-certs" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "612460d5f7bea540c490b2b6395d8e34a953e52b491accd6c86c8164c5932a63" +dependencies = [ + "openssl-probe", + "rustls-pki-types", + "schannel", + "security-framework", +] + +[[package]] +name = "rustls-pki-types" +version = "1.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30a7197ae7eb376e574fe940d068c30fe0462554a3ddbe4eca7838e049c937a9" +dependencies = [ + "web-time", + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" +dependencies = [ + "aws-lc-rs", + "ring", + "rustls-pki-types", + "untrusted", +] + +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "schannel" +version = "0.1.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "security-framework" +version = "3.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" +dependencies = [ + "bitflags", + "core-foundation", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.149" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_path_to_error" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457" +dependencies = [ + "itoa", + "serde", + "serde_core", +] + +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "sharded-slab" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" +dependencies = [ + "lazy_static", +] + +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" + +[[package]] +name = "socket2" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "syn" +version = "2.0.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +dependencies = [ + "futures-core", +] + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "thiserror" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "thread_local" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "tinystr" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tinyvec" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e61e67053d25a4e82c844e8424039d9745781b3fc4f32b8d55ed50f5f667ef3" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + +[[package]] +name = "tokio" +version = "1.52.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe" +dependencies = [ + "bytes", + "libc", + "mio", + "parking_lot", + "pin-project-lite", + "signal-hook-registry", + "socket2", + "tokio-macros", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-macros" +version = "2.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tokio-rustls" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" +dependencies = [ + "rustls", + "tokio", +] + +[[package]] +name = "tokio-util" +version = "0.7.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "tower" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tower-http" +version = "0.6.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68d6fdd9f81c2819c9a8b0e0cd91660e7746a8e6ea2ba7c6b2b057985f6bcb51" +dependencies = [ + "bitflags", + "bytes", + "futures-util", + "http", + "http-body", + "pin-project-lite", + "tower", + "tower-layer", + "tower-service", + "tracing", + "url", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "log", + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", + "valuable", +] + +[[package]] +name = "tracing-log" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" +dependencies = [ + "log", + "once_cell", + "tracing-core", +] + +[[package]] +name = "tracing-subscriber" +version = "0.3.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319" +dependencies = [ + "matchers", + "nu-ansi-term", + "once_cell", + "regex-automata", + "sharded-slab", + "smallvec", + "thread_local", + "tracing", + "tracing-core", + "tracing-log", +] + +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + +[[package]] +name = "typenum" +version = "1.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40ce102ab67701b8526c123c1bab5cbe42d7040ccfd0f64af1a385808d2f43de" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + +[[package]] +name = "urlencoding" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da" + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + +[[package]] +name = "uuid" +version = "1.23.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd74a9687298c6858e9b88ec8935ec45d22e8fd5e6394fa1bd4e99a87789c76" +dependencies = [ + "getrandom 0.4.2", + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "valuable" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.3+wasi-0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20064672db26d7cdc89c7798c48a0fdfac8213434a1186e5ef29fd560ae223d6" +dependencies = [ + "wit-bindgen 0.57.1", +] + +[[package]] +name = "wasip3" +version = "0.4.0+wasi-0.3.0-rc-2026-01-06" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" +dependencies = [ + "wit-bindgen 0.51.0", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.121" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49ace1d07c165b0864824eee619580c4689389afa9dc9ed3a4c75040d82e6790" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.71" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96492d0d3ffba25305a7dc88720d250b1401d7edca02cc3bcd50633b424673b8" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.121" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e68e6f4afd367a562002c05637acb8578ff2dea1943df76afb9e83d177c8578" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.121" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d95a9ec35c64b2a7cb35d3fead40c4238d0940c86d107136999567a4703259f2" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.121" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4e0100b01e9f0d03189a92b96772a1fb998639d981193d7dbab487302513441" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "wasm-encoder" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" +dependencies = [ + "leb128fmt", + "wasmparser", +] + +[[package]] +name = "wasm-metadata" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" +dependencies = [ + "anyhow", + "indexmap", + "wasm-encoder", + "wasmparser", +] + +[[package]] +name = "wasmparser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" +dependencies = [ + "bitflags", + "hashbrown 0.15.5", + "indexmap", + "semver", +] + +[[package]] +name = "web-sys" +version = "0.3.98" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b572dff8bcf38bad0fa19729c89bb5748b2b9b1d8be70cf90df697e3a8f32aa" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "webpki-roots" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52f5ee44c96cf55f1b349600768e3ece3a8f26010c05265ab73f945bb1a2eb9d" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" +dependencies = [ + "windows-targets 0.53.5", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm 0.52.6", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", +] + +[[package]] +name = "windows-targets" +version = "0.53.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" +dependencies = [ + "windows-link", + "windows_aarch64_gnullvm 0.53.1", + "windows_aarch64_msvc 0.53.1", + "windows_i686_gnu 0.53.1", + "windows_i686_gnullvm 0.53.1", + "windows_i686_msvc 0.53.1", + "windows_x86_64_gnu 0.53.1", + "windows_x86_64_gnullvm 0.53.1", + "windows_x86_64_msvc 0.53.1", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_i686_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" + +[[package]] +name = "wit-bindgen" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" +dependencies = [ + "wit-bindgen-rust-macro", +] + +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + +[[package]] +name = "wit-bindgen-core" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" +dependencies = [ + "anyhow", + "heck", + "wit-parser", +] + +[[package]] +name = "wit-bindgen-rust" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" +dependencies = [ + "anyhow", + "heck", + "indexmap", + "prettyplease", + "syn", + "wasm-metadata", + "wit-bindgen-core", + "wit-component", +] + +[[package]] +name = "wit-bindgen-rust-macro" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a" +dependencies = [ + "anyhow", + "prettyplease", + "proc-macro2", + "quote", + "syn", + "wit-bindgen-core", + "wit-bindgen-rust", +] + +[[package]] +name = "wit-component" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" +dependencies = [ + "anyhow", + "bitflags", + "indexmap", + "log", + "serde", + "serde_derive", + "serde_json", + "wasm-encoder", + "wasm-metadata", + "wasmparser", + "wit-parser", +] + +[[package]] +name = "wit-parser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" +dependencies = [ + "anyhow", + "id-arena", + "indexmap", + "log", + "semver", + "serde", + "serde_derive", + "serde_json", + "unicode-xid", + "wasmparser", +] + +[[package]] +name = "writeable" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" + +[[package]] +name = "xai-proxy" +version = "0.1.0" +dependencies = [ + "anyhow", + "axum", + "base64", + "clap", + "dirs", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-util", + "open", + "rand 0.8.6", + "reqwest", + "rustls", + "serde", + "serde_json", + "sha2", + "tokio", + "tower", + "tower-http", + "tracing", + "tracing-subscriber", + "url", + "urlencoding", + "uuid", +] + +[[package]] +name = "yoke" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "abe8c5fda708d9ca3df187cae8bfb9ceda00dd96231bed36e445a1a48e66f9ca" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zerocopy" +version = "0.8.48" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eed437bf9d6692032087e337407a86f04cd8d6a16a37199ed57949d415bd68e9" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.48" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70e3cd084b1788766f53af483dd21f93881ff30d7320490ec3ef7526d203bad4" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zerofrom" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zeroize" +version = "1.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" + +[[package]] +name = "zerotrie" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" diff --git a/xai-proxy/Cargo.toml b/xai-proxy/Cargo.toml new file mode 100644 index 000000000..a392d436d --- /dev/null +++ b/xai-proxy/Cargo.toml @@ -0,0 +1,35 @@ +[package] +name = "xai-proxy" +version = "0.1.0" +edition = "2021" +description = "Lightweight xAI OAuth proxy sidecar — PKCE login via accounts.x.ai, forwards OpenAI-compatible requests to api.x.ai/v1" + +[dependencies] +tokio = { version = "1.44", features = ["full"] } +axum = { version = "0.8", features = ["tokio"] } +hyper = { version = "1.6", features = ["full"] } +hyper-util = { version = "0.1", features = ["tokio", "client-legacy", "http1", "http2"] } +rustls = { version = "0.23", default-features = false, features = ["ring", "std"] } +hyper-rustls = { version = "0.27", features = ["http2", "ring", "native-tokio"] } +http-body-util = "0.1" +reqwest = { version = "0.12", features = ["json", "rustls-tls"], default-features = false } +serde = { version = "1", features = ["derive"] } +serde_json = "1" +clap = { version = "4", features = ["derive"] } +sha2 = "0.10" +base64 = "0.22" +rand = "0.8" +uuid = { version = "1", features = ["v4"] } +url = "2" +urlencoding = "2" +dirs = "6" +anyhow = "1" +tracing = "0.1" +tracing-subscriber = { version = "0.3", features = ["env-filter"] } +open = "5" +tower = { version = "0.5", features = ["util"] } +tower-http = { version = "0.6", features = ["trace"] } + +[profile.release] +strip = true +lto = true diff --git a/xai-proxy/Dockerfile b/xai-proxy/Dockerfile new file mode 100644 index 000000000..21dc548cf --- /dev/null +++ b/xai-proxy/Dockerfile @@ -0,0 +1,11 @@ +FROM rust:1.86-slim AS builder +WORKDIR /app +COPY Cargo.toml Cargo.lock ./ +COPY src ./src +RUN cargo build --release + +FROM debian:bookworm-slim +RUN apt-get update && apt-get install -y ca-certificates && rm -rf /var/lib/apt/lists/* +COPY --from=builder /app/target/release/xai-proxy /usr/local/bin/ +ENTRYPOINT ["xai-proxy"] +CMD ["serve", "--bind", "0.0.0.0"] diff --git a/xai-proxy/README.md b/xai-proxy/README.md new file mode 100644 index 000000000..ccd638683 --- /dev/null +++ b/xai-proxy/README.md @@ -0,0 +1,143 @@ +# xai-proxy + +Lightweight Rust sidecar that authenticates with xAI via OAuth PKCE (SuperGrok subscription) and proxies OpenAI-compatible requests to `api.x.ai/v1`. + +## Why + +Use your SuperGrok subscription quota (instead of API credits) with any OpenAI-compatible coding agent — OpenCode, Hermes, etc. + +## How it works + +``` +┌──────────────────────────────────────────────────────────────┐ +│ Pod / Host │ +│ │ +│ ┌────────────────────┐ POST /v1/chat/completions │ +│ │ coding agent │──────────────────────┐ │ +│ │ (OpenCode, etc.) │ ▼ │ +│ └────────────────────┘ ┌─────────────────────────────┐ │ +│ │ xai-proxy :9090 │ │ +│ │ │ │ +│ │ • Injects Bearer token │ │ +│ │ • Auto-refreshes < 120s │ │ +│ └──────────────┬──────────────┘ │ +└───────────────────────────────────────────┼──────────────────┘ + │ + ▼ + ┌─────────────────────────────┐ + │ https://api.x.ai/v1 │ + │ (SuperGrok subscription) │ + └─────────────────────────────┘ +``` + +## Build + +```bash +cargo build --release +``` + +## Docker + +```bash +docker build -t xai-proxy . +docker run --rm -v ~/.xai-proxy:/root/.xai-proxy xai-proxy serve --bind 0.0.0.0 +``` + +## Usage + +### 1. Login (one-time) + +```bash +# Browser OAuth (local machine) +./target/release/xai-proxy login + +# Device-code flow (headless / K8s / ECS) +./target/release/xai-proxy login-device +``` + +Token is saved to `~/.xai-proxy/tokens.json` (or custom path via `XAI_PROXY_TOKEN_PATH`). + +### 2. Start proxy + +```bash +./target/release/xai-proxy serve --port 9090 +``` + +### 3. Point your client + +```bash +export OPENAI_BASE_URL=http://127.0.0.1:9090/v1 +export OPENAI_API_KEY=dummy +``` + +## Environment Variables + +| Variable | Default | Description | +|----------|---------|-------------| +| `XAI_PROXY_TOKEN_PATH` | `~/.xai-proxy/tokens.json` | Custom token file location | +| `RUST_LOG` | `xai_proxy=info` | Log level | + +## Kubernetes Sidecar + +Deploy alongside openab as a sidecar container with PVC persistence: + +```yaml +extraInitContainers: + - name: copy-tokens + image: busybox + command: ["sh", "-c", "if [ ! -f /dest/.openab/xai-proxy/tokens.json ]; then mkdir -p /dest/.openab/xai-proxy && cp /src/tokens.json /dest/.openab/xai-proxy/tokens.json; fi"] + volumeMounts: + - name: xai-tokens-src + mountPath: /src + readOnly: true + - name: data + mountPath: /dest + +extraContainers: + - name: xai-proxy + image: xai-proxy:latest + args: ["serve", "--bind", "0.0.0.0"] + env: + - name: XAI_PROXY_TOKEN_PATH + value: /home/agent/.openab/xai-proxy/tokens.json + volumeMounts: + - name: data + mountPath: /home/agent + +extraVolumes: + - name: xai-tokens-src + secret: + secretName: xai-proxy-tokens +``` + +## OAuth details + +| Item | Value | +|------|-------| +| Auth server | `https://auth.x.ai` | +| Client ID | Grok CLI public client | +| Flow | OAuth 2.0 PKCE (loopback) or device-code | +| Scope | `openid profile email offline_access grok-cli:access api:access` | +| Token storage | `~/.xai-proxy/tokens.json` (chmod 600) | +| Auto-refresh | Yes, 120s before expiry | + +## Requirements + +- Active SuperGrok subscription (any tier) +- Rust 1.86+ (build) +- Browser or device-code access for initial login + +## Headless / SSH login + +```bash +# Option A: device-code (recommended) +xai-proxy login-device + +# Option B: SSH port-forward +ssh -N -L 56121:127.0.0.1:56121 user@remote-host +xai-proxy login # open the URL in your local browser +``` + +## License + +MIT diff --git a/xai-proxy/src/main.rs b/xai-proxy/src/main.rs new file mode 100644 index 000000000..c39649f78 --- /dev/null +++ b/xai-proxy/src/main.rs @@ -0,0 +1,546 @@ +use anyhow::{anyhow, Context, Result}; +use axum::{body::Body, extract::State, http::Request, response::Response, routing::any, Router}; +use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine}; +use clap::{Parser, Subcommand}; +use hyper_util::{client::legacy::Client, rt::TokioExecutor}; +use rand::RngCore; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; +use std::{ + net::SocketAddr, + path::PathBuf, + sync::Arc, + time::{SystemTime, UNIX_EPOCH}, +}; +use tokio::{ + io::{AsyncBufReadExt, AsyncWriteExt, BufReader}, + net::TcpListener, + sync::RwLock, +}; +use tracing::{error, info}; + +// === Constants (borrowed from Hermes) === + +const XAI_OAUTH_DISCOVERY_URL: &str = "https://auth.x.ai/.well-known/openid-configuration"; +const XAI_OAUTH_CLIENT_ID: &str = "b1a00492-073a-47ea-816f-4c329264a828"; +const XAI_OAUTH_SCOPE: &str = "openid profile email offline_access grok-cli:access api:access"; +const XAI_OAUTH_REDIRECT_PORT: u16 = 56121; +const XAI_API_BASE: &str = "https://api.x.ai"; +const REFRESH_SKEW_SECONDS: u64 = 120; + +// === CLI === + +#[derive(Parser)] +#[command(name = "xai-proxy", about = "xAI OAuth proxy sidecar")] +struct Cli { + #[command(subcommand)] + command: Commands, +} + +#[derive(Subcommand)] +enum Commands { + /// Authenticate with xAI via browser OAuth (PKCE) + Login, + /// Authenticate with xAI via device-code flow (headless/K8s/ECS) + LoginDevice, + /// Start the proxy server + Serve { + /// Listen port + #[arg(short, long, default_value = "9090")] + port: u16, + /// Listen address + #[arg(long, default_value = "127.0.0.1")] + bind: String, + }, +} + +// === Token Storage === + +#[derive(Debug, Clone, Serialize, Deserialize)] +struct TokenStore { + access_token: String, + refresh_token: String, + #[serde(default)] + expires_at: u64, // unix timestamp + #[serde(default)] + token_endpoint: String, +} + +fn token_path() -> PathBuf { + if let Ok(p) = std::env::var("XAI_PROXY_TOKEN_PATH") { + let path = PathBuf::from(p); + if let Some(dir) = path.parent() { + std::fs::create_dir_all(dir).ok(); + } + return path; + } + let dir = dirs::home_dir() + .unwrap_or_else(|| PathBuf::from(".")) + .join(".xai-proxy"); + std::fs::create_dir_all(&dir).ok(); + dir.join("tokens.json") +} + +fn load_tokens() -> Result { + let path = token_path(); + let data = std::fs::read_to_string(&path) + .with_context(|| format!("No token file at {}. Run `xai-proxy login` first.", path.display()))?; + serde_json::from_str(&data).context("Invalid token file") +} + +fn save_tokens(store: &TokenStore) -> Result<()> { + let path = token_path(); + let data = serde_json::to_string_pretty(store)?; + std::fs::write(&path, data)?; + // chmod 600 + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600))?; + } + Ok(()) +} + +// === OIDC Discovery === + +#[derive(Deserialize)] +struct OidcDiscovery { + authorization_endpoint: String, + token_endpoint: String, + #[serde(default)] + device_authorization_endpoint: String, +} + +async fn discover_endpoints() -> Result { + let client = reqwest::Client::new(); + let resp = client + .get(XAI_OAUTH_DISCOVERY_URL) + .send() + .await? + .error_for_status()?; + resp.json().await.context("Failed to parse OIDC discovery") +} + +// === PKCE === + +fn pkce_verifier() -> String { + let mut buf = [0u8; 64]; + rand::thread_rng().fill_bytes(&mut buf); + URL_SAFE_NO_PAD.encode(buf) +} + +fn pkce_challenge(verifier: &str) -> String { + let digest = Sha256::digest(verifier.as_bytes()); + URL_SAFE_NO_PAD.encode(digest) +} + +// === OAuth Login === + +async fn do_login() -> Result<()> { + info!("Starting xAI OAuth PKCE login..."); + let discovery = discover_endpoints().await?; + + let code_verifier = pkce_verifier(); + let code_challenge = pkce_challenge(&code_verifier); + let state = uuid::Uuid::new_v4().to_string(); + let nonce = uuid::Uuid::new_v4().to_string(); + + let redirect_uri = format!("http://127.0.0.1:{}/callback", XAI_OAUTH_REDIRECT_PORT); + + let authorize_url = format!( + "{}?response_type=code&client_id={}&redirect_uri={}&scope={}&code_challenge={}&code_challenge_method=S256&state={}&nonce={}&plan=generic&referrer=xai-proxy", + discovery.authorization_endpoint, + urlencoding::encode(XAI_OAUTH_CLIENT_ID), + urlencoding::encode(&redirect_uri), + urlencoding::encode(XAI_OAUTH_SCOPE), + urlencoding::encode(&code_challenge), + urlencoding::encode(&state), + urlencoding::encode(&nonce), + ); + + // Start local callback server + let listener = TcpListener::bind(format!("127.0.0.1:{}", XAI_OAUTH_REDIRECT_PORT)) + .await + .context("Failed to bind callback port 56121")?; + + println!("\nOpen this URL to authorize:\n"); + println!(" {}\n", authorize_url); + + // Try to open browser + if open::that(&authorize_url).is_ok() { + println!("Browser opened. Waiting for callback..."); + } else { + println!("Could not open browser. Please open the URL above manually."); + } + + // Wait for callback + let (mut stream, _) = listener.accept().await?; + let mut reader = BufReader::new(&mut stream); + let mut request_line = String::new(); + reader.read_line(&mut request_line).await?; + + // Parse GET /callback?code=...&state=... HTTP/1.1 + let path = request_line + .split_whitespace() + .nth(1) + .ok_or_else(|| anyhow!("Invalid HTTP request"))?; + + let url = url::Url::parse(&format!("http://localhost{}", path))?; + let params: std::collections::HashMap<_, _> = url.query_pairs().collect(); + + // Drain remaining headers + loop { + let mut line = String::new(); + reader.read_line(&mut line).await?; + if line.trim().is_empty() { + break; + } + } + + // Send response + let body = "

xAI authorization received.

You can close this tab."; + let response = format!( + "HTTP/1.1 200 OK\r\nContent-Type: text/html\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}", + body.len(), + body + ); + stream.write_all(response.as_bytes()).await?; + + // Validate state + let received_state = params.get("state").ok_or_else(|| anyhow!("No state in callback"))?; + if received_state.as_ref() != state { + return Err(anyhow!("State mismatch — possible CSRF")); + } + + let code = params + .get("code") + .ok_or_else(|| anyhow!("No code in callback. Error: {:?}", params.get("error")))?; + + // Exchange code for tokens + info!("Exchanging authorization code for tokens..."); + let client = reqwest::Client::new(); + let resp = client + .post(&discovery.token_endpoint) + .header("Content-Type", "application/x-www-form-urlencoded") + .form(&[ + ("grant_type", "authorization_code"), + ("code", code.as_ref()), + ("redirect_uri", &redirect_uri), + ("client_id", XAI_OAUTH_CLIENT_ID), + ("code_verifier", &code_verifier), + ("code_challenge", &code_challenge), + ("code_challenge_method", "S256"), + ]) + .send() + .await?; + + if !resp.status().is_success() { + let status = resp.status(); + let body = resp.text().await.unwrap_or_default(); + return Err(anyhow!("Token exchange failed (HTTP {}): {}", status, body)); + } + + let token_resp: serde_json::Value = resp.json().await?; + let access_token = token_resp["access_token"] + .as_str() + .ok_or_else(|| anyhow!("No access_token in response"))?; + let refresh_token = token_resp["refresh_token"] + .as_str() + .ok_or_else(|| anyhow!("No refresh_token in response"))?; + let expires_in = token_resp["expires_in"].as_u64().unwrap_or(3600); + + let now = SystemTime::now().duration_since(UNIX_EPOCH)?.as_secs(); + let store = TokenStore { + access_token: access_token.to_string(), + refresh_token: refresh_token.to_string(), + expires_at: now + expires_in, + token_endpoint: discovery.token_endpoint, + }; + save_tokens(&store)?; + + println!("\n✅ Login successful! Token saved to {:?}", token_path()); + println!(" Run `xai-proxy serve` to start the proxy."); + Ok(()) +} + +// === Device-Code Login (headless) === + +async fn do_login_device() -> Result<()> { + info!("Starting xAI device-code login..."); + let discovery = discover_endpoints().await?; + + let device_endpoint = if discovery.device_authorization_endpoint.is_empty() { + "https://auth.x.ai/oauth2/device/code".to_string() + } else { + discovery.device_authorization_endpoint + }; + + let client = reqwest::Client::new(); + let resp = client + .post(&device_endpoint) + .form(&[ + ("client_id", XAI_OAUTH_CLIENT_ID), + ("scope", XAI_OAUTH_SCOPE), + ]) + .send() + .await?; + + if !resp.status().is_success() { + let body = resp.text().await.unwrap_or_default(); + return Err(anyhow!("Device authorization failed: {}", body)); + } + + let device_resp: serde_json::Value = resp.json().await?; + let device_code = device_resp["device_code"] + .as_str() + .ok_or_else(|| anyhow!("No device_code in response"))?; + let user_code = device_resp["user_code"] + .as_str() + .ok_or_else(|| anyhow!("No user_code in response"))?; + let verification_uri = device_resp["verification_uri"] + .as_str() + .or_else(|| device_resp["verification_url"].as_str()) + .unwrap_or("https://auth.x.ai/oauth2/device"); + let interval = device_resp["interval"].as_u64().unwrap_or(5); + + println!("\n Go to: {}", verification_uri); + println!(" Enter code: {}\n", user_code); + println!("Waiting for authorization..."); + + loop { + tokio::time::sleep(tokio::time::Duration::from_secs(interval)).await; + + let resp = client + .post(&discovery.token_endpoint) + .form(&[ + ("grant_type", "urn:ietf:params:oauth:grant-type:device_code"), + ("client_id", XAI_OAUTH_CLIENT_ID), + ("device_code", device_code), + ]) + .send() + .await?; + + let status = resp.status(); + let payload: serde_json::Value = resp.json().await?; + + if status.is_success() { + let access_token = payload["access_token"] + .as_str() + .ok_or_else(|| anyhow!("No access_token"))?; + let refresh_token = payload["refresh_token"] + .as_str() + .ok_or_else(|| anyhow!("No refresh_token"))?; + let expires_in = payload["expires_in"].as_u64().unwrap_or(3600); + let now = SystemTime::now().duration_since(UNIX_EPOCH)?.as_secs(); + + let store = TokenStore { + access_token: access_token.to_string(), + refresh_token: refresh_token.to_string(), + expires_at: now + expires_in, + token_endpoint: discovery.token_endpoint, + }; + save_tokens(&store)?; + println!("\n✅ Login successful! Token saved to {:?}", token_path()); + println!(" Run `xai-proxy serve` to start the proxy."); + return Ok(()); + } + + let error = payload["error"].as_str().unwrap_or_default(); + match error { + "authorization_pending" | "slow_down" => continue, + "expired_token" => return Err(anyhow!("Device code expired. Try again.")), + "access_denied" => return Err(anyhow!("Authorization denied by user.")), + _ => return Err(anyhow!("Device-code error: {} — {:?}", error, payload)), + } + } +} + +// === Token Refresh === + +async fn refresh_token(store: &TokenStore) -> Result { + let client = reqwest::Client::new(); + let resp = client + .post(&store.token_endpoint) + .form(&[ + ("grant_type", "refresh_token"), + ("refresh_token", &store.refresh_token), + ("client_id", XAI_OAUTH_CLIENT_ID), + ]) + .send() + .await?; + + if !resp.status().is_success() { + let status = resp.status(); + let body = resp.text().await.unwrap_or_default(); + return Err(anyhow!("Token refresh failed (HTTP {}): {}", status, body)); + } + + let token_resp: serde_json::Value = resp.json().await?; + let access_token = token_resp["access_token"] + .as_str() + .ok_or_else(|| anyhow!("No access_token in refresh response"))?; + let refresh_token = token_resp["refresh_token"] + .as_str() + .unwrap_or(&store.refresh_token); + let expires_in = token_resp["expires_in"].as_u64().unwrap_or(3600); + + let now = SystemTime::now().duration_since(UNIX_EPOCH)?.as_secs(); + Ok(TokenStore { + access_token: access_token.to_string(), + refresh_token: refresh_token.to_string(), + expires_at: now + expires_in, + token_endpoint: store.token_endpoint.clone(), + }) +} + +// === Proxy State === + +struct ProxyState { + tokens: RwLock, + http_client: Client, Body>, +} + +impl ProxyState { + async fn get_valid_token(&self) -> Result { + let now = SystemTime::now().duration_since(UNIX_EPOCH)?.as_secs(); + { + let tokens = self.tokens.read().await; + if tokens.expires_at > now + REFRESH_SKEW_SECONDS { + return Ok(tokens.access_token.clone()); + } + } + // Need refresh + let mut tokens = self.tokens.write().await; + // Double-check after acquiring write lock + let now = SystemTime::now().duration_since(UNIX_EPOCH)?.as_secs(); + if tokens.expires_at > now + REFRESH_SKEW_SECONDS { + return Ok(tokens.access_token.clone()); + } + info!("Refreshing xAI OAuth token..."); + let new_tokens = refresh_token(&tokens).await?; + save_tokens(&new_tokens)?; + *tokens = new_tokens; + Ok(tokens.access_token.clone()) + } +} + +// === Proxy Handler === + +async fn proxy_handler( + State(state): State>, + mut req: Request, +) -> Response { + let token = match state.get_valid_token().await { + Ok(t) => t, + Err(e) => { + error!("Failed to get token: {}", e); + return Response::builder() + .status(502) + .body(Body::from(format!("Token error: {}", e))) + .unwrap(); + } + }; + + // Rewrite URI to api.x.ai + let path_and_query = req + .uri() + .path_and_query() + .map(|pq| pq.as_str()) + .unwrap_or("/"); + let target_uri = format!("{}{}", XAI_API_BASE, path_and_query); + + *req.uri_mut() = target_uri.parse().unwrap(); + + // Inject auth header + req.headers_mut().insert( + hyper::header::AUTHORIZATION, + format!("Bearer {}", token).parse().unwrap(), + ); + req.headers_mut().insert( + hyper::header::HOST, + "api.x.ai".parse().unwrap(), + ); + + // Forward + match state.http_client.request(req).await { + Ok(resp) => { + let (parts, body) = resp.into_parts(); + Response::from_parts(parts, Body::new(body)) + } + Err(e) => { + error!("Upstream error: {}", e); + Response::builder() + .status(502) + .body(Body::from(format!("Upstream error: {}", e))) + .unwrap() + } + } +} + +// === Serve === + +async fn do_serve(bind: &str, port: u16) -> Result<()> { + let store = load_tokens()?; + + // Check if token needs immediate refresh + let now = SystemTime::now().duration_since(UNIX_EPOCH)?.as_secs(); + let store = if store.expires_at <= now + REFRESH_SKEW_SECONDS { + info!("Token expired, refreshing..."); + let new_store = refresh_token(&store).await?; + save_tokens(&new_store)?; + new_store + } else { + store + }; + + // Build HTTPS client for upstream + let https = hyper_rustls::HttpsConnectorBuilder::new() + .with_native_roots()? + .https_or_http() + .enable_http1() + .enable_http2() + .build(); + let http_client = Client::builder(TokioExecutor::new()).build(https); + + let state = Arc::new(ProxyState { + tokens: RwLock::new(store), + http_client, + }); + + let app = Router::new() + .route("/{*path}", any(proxy_handler)) + .route("/", any(proxy_handler)) + .with_state(state); + + let addr: SocketAddr = format!("{}:{}", bind, port).parse()?; + let listener = TcpListener::bind(addr).await?; + info!("xai-proxy listening on http://{}", addr); + println!("xai-proxy listening on http://{}", addr); + println!("Set your client's base URL to: http://{}/v1", addr); + + axum::serve(listener, app).await?; + Ok(()) +} + +// === Main === + +#[tokio::main] +async fn main() -> Result<()> { + rustls::crypto::ring::default_provider() + .install_default() + .expect("Failed to install rustls crypto provider"); + + tracing_subscriber::fmt() + .with_env_filter( + tracing_subscriber::EnvFilter::try_from_default_env() + .unwrap_or_else(|_| "xai_proxy=info".into()), + ) + .init(); + + let cli = Cli::parse(); + match cli.command { + Commands::Login => do_login().await, + Commands::LoginDevice => do_login_device().await, + Commands::Serve { port, bind } => do_serve(&bind, port).await, + } +} From 1597129e9c5ddd25a0d113380ef09324c8c63cdc Mon Sep 17 00:00:00 2001 From: chaodufashi Date: Mon, 18 May 2026 17:56:58 -0400 Subject: [PATCH 043/100] docs: add reference architecture for Telegram via Cloudflare Tunnel (#845) Co-authored-by: Kiro --- docs/refarch/telegram-cloudflare-tunnel.md | 158 +++++++++++++++++++++ 1 file changed, 158 insertions(+) create mode 100644 docs/refarch/telegram-cloudflare-tunnel.md diff --git a/docs/refarch/telegram-cloudflare-tunnel.md b/docs/refarch/telegram-cloudflare-tunnel.md new file mode 100644 index 000000000..31f4893e1 --- /dev/null +++ b/docs/refarch/telegram-cloudflare-tunnel.md @@ -0,0 +1,158 @@ +# Reference Architecture: Telegram via Cloudflare Tunnel + +Deploy OpenAB on K3s with the Custom Gateway receiving Telegram webhooks through a Cloudflare Tunnel — no public IP, no ingress controller, no TLS certificates required. + +## Architecture + +``` +Telegram Cloud + │ HTTPS POST + ▼ +Cloudflare Edge (bot.example.com) + │ Tunnel + ▼ +┌─────────────────────────────────────────┐ +│ Gateway Pod │ +│ ┌───────────────┐ ┌────────────────┐ │ +│ │ cloudflared │──│ gateway :8080 │ │ +│ │ (sidecar) │ │ │ │ +│ └───────────────┘ └───────▲────────┘ │ +└─────────────────────────────│───────────┘ + │ WebSocket (cluster-internal) + ┌─────────┴─────────┐ + │ OAB Pod │ + └───────────────────┘ +``` + +- **Telegram** sends webhook POSTs to the Cloudflare edge hostname. +- **Cloudflare Tunnel** routes traffic to the `cloudflared` sidecar inside the cluster. +- **Custom Gateway** receives the POST, normalizes it, and forwards to OAB over WebSocket. +- **OAB** connects outbound to the gateway — no inbound ports needed. + +## Prerequisites + +| Requirement | Notes | +|-------------|-------| +| K3s cluster | Any single-node or multi-node K3s setup | +| Helm 3 | Installed on the node or a workstation with kubeconfig access | +| Cloudflare account | Free plan is sufficient | +| Telegram Bot Token | Create via [@BotFather](https://t.me/BotFather) | +| Domain on Cloudflare | DNS managed by Cloudflare | + +## Step 1: Create a Cloudflare Tunnel + +1. Go to **Zero Trust → Networks → Tunnels → Create a tunnel** +2. Name it (e.g. `openab-telegram`) +3. Copy the **tunnel token** +4. Add a **public hostname**: + - Subdomain: your choice (e.g. `bot`) + - Domain: your Cloudflare-managed domain + - Service: `http://localhost:8080` + +## Step 2: Deploy with Helm + +```bash +cd openab + +RELEASE_NAME="my-openab" + +helm upgrade --install "$RELEASE_NAME" ./charts/openab \ + --set agents.kiro.discord.enabled=false \ + --set agents.kiro.gateway.enabled=true \ + --set agents.kiro.gateway.deploy=true \ + --set agents.kiro.gateway.url="ws://${RELEASE_NAME}-kiro-gateway:8080/ws" \ + --set agents.kiro.gateway.platform=telegram \ + --set agents.kiro.gateway.image="ghcr.io/openabdev/openab-gateway" \ + --set agents.kiro.gateway.tag="0.4.0" \ + --set-literal agents.kiro.gateway.telegram.botToken="" \ + --set agents.kiro.gateway.extraContainers[0].name=cloudflared \ + --set agents.kiro.gateway.extraContainers[0].image="cloudflare/cloudflared:2024.12.2" \ + --set agents.kiro.gateway.extraContainers[0].args[0]="tunnel" \ + --set agents.kiro.gateway.extraContainers[0].args[1]="--no-autoupdate" \ + --set agents.kiro.gateway.extraContainers[0].args[2]="run" \ + --set-literal agents.kiro.gateway.extraContainers[0].env[0].name=TUNNEL_TOKEN \ + --set-literal agents.kiro.gateway.extraContainers[0].env[0].value="" \ + --namespace openab --create-namespace +``` + +## Step 3: Authenticate the Agent + +```bash +kubectl exec -it deployment/${RELEASE_NAME}-kiro -n openab -- kiro-cli login --use-device-flow +``` + +After login, restart the pod to pick up credentials: + +```bash +kubectl rollout restart deployment/${RELEASE_NAME}-kiro -n openab +``` + +## Step 4: Set the Telegram Webhook + +```bash +curl "https://api.telegram.org/bot/setWebhook" \ + -d "url=https://bot.example.com/webhook/telegram" +``` + +Verify: + +```bash +curl "https://api.telegram.org/bot/getWebhookInfo" +``` + +## Resulting Resources + +``` +$ kubectl get pods -n openab +NAME READY STATUS AGE +my-openab-kiro-xxxxx-yyyyy 1/1 Running ... +my-openab-kiro-gateway-xxxxx-yyyyy 2/2 Running ... +``` + +The gateway pod runs 2 containers: `gateway` and `cloudflared`. + +## Configuration + +The rendered `config.toml` for the OAB agent: + +```toml +[agent] +command = "kiro-cli" +args = ["acp", "--trust-all-tools"] +working_dir = "/home/agent" + +[pool] +max_sessions = 10 +session_ttl_hours = 24 + +[reactions] +enabled = true +remove_after_reply = false + +[gateway] +url = "ws://my-openab-kiro-gateway:8080/ws" +platform = "telegram" +allow_all_channels = true +allowed_channels = [] +# ⚠️ Recommended: restrict to specific Telegram user IDs +allow_all_users = false +allowed_users = [""] +``` + +## Restricting Access + +To limit which Telegram users can interact with the bot: + +```bash +helm upgrade $RELEASE_NAME ./charts/openab \ + ... \ + --set agents.kiro.gateway.allowAllUsers=false \ + --set-string agents.kiro.gateway.allowedUsers[0]="" +``` + +## Why Cloudflare Tunnel? + +- **No public IP required** — the K3s node can be behind NAT or a firewall. +- **No TLS management** — Cloudflare terminates TLS at the edge. +- **No ingress controller config** — bypasses Traefik/nginx entirely. +- **Sidecar pattern** — `cloudflared` runs alongside the gateway in the same pod, routing to `localhost:8080`. From 07e17ee2c2bddae51f84962e66065cbc2f04d2a5 Mon Sep 17 00:00:00 2001 From: chaodufashi Date: Mon, 18 May 2026 18:45:54 -0400 Subject: [PATCH 044/100] docs: refarch single-pod architecture (#848) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * docs: update refarch to single-pod architecture (OAB + gateway + cloudflared) * docs: fix diagram alignment --------- Co-authored-by: 超渡法師 --- docs/refarch/telegram-cloudflare-tunnel.md | 75 ++++++++++++---------- 1 file changed, 40 insertions(+), 35 deletions(-) diff --git a/docs/refarch/telegram-cloudflare-tunnel.md b/docs/refarch/telegram-cloudflare-tunnel.md index 31f4893e1..67df7a84f 100644 --- a/docs/refarch/telegram-cloudflare-tunnel.md +++ b/docs/refarch/telegram-cloudflare-tunnel.md @@ -8,26 +8,27 @@ Deploy OpenAB on K3s with the Custom Gateway receiving Telegram webhooks through Telegram Cloud │ HTTPS POST ▼ -Cloudflare Edge (bot.example.com) - │ Tunnel +Cloudflare Edge (your_custom.domain.com) + │ Tunnel (QUIC) ▼ -┌─────────────────────────────────────────┐ -│ Gateway Pod │ -│ ┌───────────────┐ ┌────────────────┐ │ -│ │ cloudflared │──│ gateway :8080 │ │ -│ │ (sidecar) │ │ │ │ -│ └───────────────┘ └───────▲────────┘ │ -└─────────────────────────────│───────────┘ - │ WebSocket (cluster-internal) - ┌─────────┴─────────┐ - │ OAB Pod │ - └───────────────────┘ +┌─────────────────────────────────────────────────────┐ +│ Single Pod │ +│ │ +│ ┌─────────────┐ ┌──────────────┐ ┌──────────────┐ │ +│ │ cloudflared │─▶│gateway :8080 │◀─│ OAB │ │ +│ │ (sidecar) │ │ (sidecar) │ws│ (main) │ │ +│ └─────────────┘ └──────────────┘ └──────────────┘ │ +│ localhost localhost │ +└─────────────────────────────────────────────────────┘ ``` -- **Telegram** sends webhook POSTs to the Cloudflare edge hostname. -- **Cloudflare Tunnel** routes traffic to the `cloudflared` sidecar inside the cluster. -- **Custom Gateway** receives the POST, normalizes it, and forwards to OAB over WebSocket. -- **OAB** connects outbound to the gateway — no inbound ports needed. +All three components run as containers in the **same pod**: + +- **cloudflared** — tunnel client, forwards Cloudflare traffic to `localhost:8080` +- **gateway** — receives Telegram webhooks, normalizes events, serves WebSocket on `:8080` +- **OAB** — connects to `ws://localhost:8080/ws`, runs the agent + +This keeps all communication on `localhost` — no K8s Services or cross-pod networking required. ## Prerequisites @@ -59,22 +60,25 @@ RELEASE_NAME="my-openab" helm upgrade --install "$RELEASE_NAME" ./charts/openab \ --set agents.kiro.discord.enabled=false \ --set agents.kiro.gateway.enabled=true \ - --set agents.kiro.gateway.deploy=true \ - --set agents.kiro.gateway.url="ws://${RELEASE_NAME}-kiro-gateway:8080/ws" \ + --set agents.kiro.gateway.deploy=false \ + --set agents.kiro.gateway.url="ws://localhost:8080/ws" \ --set agents.kiro.gateway.platform=telegram \ - --set agents.kiro.gateway.image="ghcr.io/openabdev/openab-gateway" \ - --set agents.kiro.gateway.tag="0.4.0" \ - --set-literal agents.kiro.gateway.telegram.botToken="" \ - --set agents.kiro.gateway.extraContainers[0].name=cloudflared \ - --set agents.kiro.gateway.extraContainers[0].image="cloudflare/cloudflared:2024.12.2" \ - --set agents.kiro.gateway.extraContainers[0].args[0]="tunnel" \ - --set agents.kiro.gateway.extraContainers[0].args[1]="--no-autoupdate" \ - --set agents.kiro.gateway.extraContainers[0].args[2]="run" \ - --set-literal agents.kiro.gateway.extraContainers[0].env[0].name=TUNNEL_TOKEN \ - --set-literal agents.kiro.gateway.extraContainers[0].env[0].value="" \ + --set agents.kiro.extraContainers[0].name=gateway \ + --set agents.kiro.extraContainers[0].image="ghcr.io/openabdev/openab-gateway:0.4.0" \ + --set agents.kiro.extraContainers[0].env[0].name=TELEGRAM_BOT_TOKEN \ + --set-literal agents.kiro.extraContainers[0].env[0].value="" \ + --set agents.kiro.extraContainers[1].name=cloudflared \ + --set agents.kiro.extraContainers[1].image="cloudflare/cloudflared:latest" \ + --set agents.kiro.extraContainers[1].args[0]="tunnel" \ + --set agents.kiro.extraContainers[1].args[1]="--no-autoupdate" \ + --set agents.kiro.extraContainers[1].args[2]="run" \ + --set agents.kiro.extraContainers[1].args[3]="--token" \ + --set-literal agents.kiro.extraContainers[1].args[4]="" \ --namespace openab --create-namespace ``` +> **Key difference:** `gateway.deploy=false` skips the separate gateway Deployment/Service. Instead, gateway and cloudflared run as `extraContainers` sidecars in the OAB pod, communicating over `localhost`. + ## Step 3: Authenticate the Agent ```bash @@ -91,7 +95,7 @@ kubectl rollout restart deployment/${RELEASE_NAME}-kiro -n openab ```bash curl "https://api.telegram.org/bot/setWebhook" \ - -d "url=https://bot.example.com/webhook/telegram" + -d "url=https://your_custom.domain.com/webhook/telegram" ``` Verify: @@ -105,11 +109,10 @@ curl "https://api.telegram.org/bot/getWebhookInfo" ``` $ kubectl get pods -n openab NAME READY STATUS AGE -my-openab-kiro-xxxxx-yyyyy 1/1 Running ... -my-openab-kiro-gateway-xxxxx-yyyyy 2/2 Running ... +my-openab-kiro-xxxxx-yyyyy 3/3 Running ... ``` -The gateway pod runs 2 containers: `gateway` and `cloudflared`. +The single pod runs 3 containers: `kiro` (OAB agent), `gateway`, and `cloudflared`. ## Configuration @@ -130,7 +133,9 @@ enabled = true remove_after_reply = false [gateway] -url = "ws://my-openab-kiro-gateway:8080/ws" +url = "ws://localhost:8080/ws" +platform = "telegram" +url = "ws://localhost:8080/ws" platform = "telegram" allow_all_channels = true allowed_channels = [] @@ -155,4 +160,4 @@ helm upgrade $RELEASE_NAME ./charts/openab \ - **No public IP required** — the K3s node can be behind NAT or a firewall. - **No TLS management** — Cloudflare terminates TLS at the edge. - **No ingress controller config** — bypasses Traefik/nginx entirely. -- **Sidecar pattern** — `cloudflared` runs alongside the gateway in the same pod, routing to `localhost:8080`. +- **Single-pod simplicity** — all components share `localhost`, no cross-pod networking needed. From 6afe59a145a6836a064995691a3c88427e375bd2 Mon Sep 17 00:00:00 2001 From: chaodufashi Date: Mon, 18 May 2026 21:57:53 -0400 Subject: [PATCH 045/100] =?UTF-8?q?docs(adr):=20ECS=20Control=20Plane=20?= =?UTF-8?q?=E2=80=94=20CRD+Operator=20pattern=20(#850)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * docs(adr): add ECS Control Plane (CRD+Operator pattern) Proposes a Kubernetes-style reconciliation loop targeting ECS: - Declarative YAML manifests in S3 as desired state - Controller reconciles against ECS API - DynamoDB for status tracking and leader election - Phased MVP approach starting with poll-based single instance * docs(adr): simplify to S3-only state store, add oabctl CLI UX - Remove DynamoDB dependency from Phase 1 (S3 strong consistency is sufficient) - Add oabctl CLI section (apply, get, delete, diff, logs, wait) - Clarify S3 bucket layout: manifests/ + status/ prefixes - Restructure phases: Phase 1 pure S3, Phase 2 adds DDB for HA * docs(adr): add capacityProvider (FARGATE/FARGATE_SPOT) and instance size selection * docs(adr): add per-agent secrets via SSM Parameter Store reference * docs(adr): controller provisions Discord bot token via API, stores in Secrets Manager * docs(adr): add bootstrapFrom — restore agent HOME from S3 archive on startup OAuth tokens, config, steering, and memory all live in the bootstrap archive. No need for controller to provision secrets via external APIs. * docs(adr): remove backend field — agent config lives in bootstrapFrom HOME Controller only manages infra (container, compute, networking). Backend/model/channel config is inside the bootstrap archive's config.toml. * docs(adr): use direct cpu/memory instead of named sizes No abstraction layer — values map 1:1 to ECS task definition. Users already know Fargate cpu/memory combos. * docs(adr): add multi-agent fleet example (5 Kiro + 3 CC + 2 Codex) * docs(adr): add per-agent secret injection section Each agent owns its secrets (1:1). Controller wires SSM/Secrets Manager references into ECS TaskDefinition native secrets field. IAM scoped per-agent path. No token sharing between agents. * docs(adr): add structured spec.config — controller validates and generates config.toml Controller understands config schema (channels, backend, steering, features). spec.config takes precedence over bootstrap archive's config.toml. * docs(adr): rewrite ECS Control Plane ADR incorporating team review Addresses feedback from 擺渡/普渡/口渡: - Fixed API identity: oab.dev/v1 OABService (no more inconsistency) - Clear separation: bootstrapFrom=state, spec.config=config, secrets=SSM - Tombstone deletion pattern (not raw S3 delete) - Entrypoint wrapper (not init container — ECS has no such thing) - Controller renders config to S3 artifact (not volume mount) - Explicit generation/observedGeneration (not just S3 VersionId) - replicas:1 enforced for bot-type agents - Schema versioning & validation section - Tightened Phase 1 scope with explicit out-of-scope list * docs(adr): no LB for agents — they are outbound-only connections * docs(adr): model config.toml as structured YAML in spec Full structured config in spec.config — controller renders to TOML. Enables schema validation at apply time, clean diffs, and controller decisions based on config (e.g. adapter ports). * docs(adr): incorporate all review feedback from 法師團隊 Major changes: - Unified API identity: oab.dev/v1 / OABService throughout - bootstrapFrom = mutable state only (memory, KB); secrets never in archive - Config delivery: controller renders config.toml to S3 artifact, startup wrapper downloads - Replicas: reject >1 for WebSocket adapters (validation error) - Secret rotation lifecycle with failure handling - Controller upgrade strategy (Phase 1: brief gap; Phase 2: leader election) - Explicit metadata.generation / status.observedGeneration (not S3 VersionId) - Narrowed Phase 1 scope per review feedback - Removed answered items from Open Questions * docs(adr): agents are always single-instance, no LB Scale horizontally by deploying more agents (each with own token), not by replicating one agent. * docs(adr): add OABFleet kind and Discord auto-registration flow Enterprise fleet provisioning: one YAML → N agents auto-provisioned with Discord Bot registration, token storage, and ECS service creation. User only needs to paste OAuth URL to add bots to server. * docs(adr): add Multi-Runtime Support section (ECS + K8s) Same YAML deploys to both ECS and K8s. Core spec is platform-agnostic; platform-specific config lives in optional platform: overlay. Each controller reads only its own key, ignores the other. * docs(adr): address 普渡 review feedback - Add executionRole/taskRole to platform.ecs overlay - Clarify Discord auto-register requires user OAuth2 bearer token - Mark OABFleet + autoRegister as Phase 2 - Add IAM requirements for startup wrapper (s3:GetObject) * docs(adr): address 口渡 review - immutable config artifacts + strict validation - Config artifacts now immutable per generation: artifacts/{ns}/{name}/{gen}/config.toml - TaskDefinition pins CONFIG_ARTIFACT_PATH to exact generation (safe rolling updates) - Platform overlay: strict-validate own keys, ignore other platform keys * docs(adr): address 擺渡 review feedback - Fix startup order: bootstrap first, then config.toml overlay (prevents stale config) - Discord auto-register: marked as future research (API doesn't exist publicly) - Fleet uses pre-created bot credentials from SSM - Controller observes ECS status and writes back (enables oabctl get/wait) - oabctl snapshot must exclude config.toml --------- Co-authored-by: Kiro Co-authored-by: 超渡法師 --- docs/adr/ecs-control-plane.md | 689 ++++++++++++++++++++++++++++++++++ 1 file changed, 689 insertions(+) create mode 100644 docs/adr/ecs-control-plane.md diff --git a/docs/adr/ecs-control-plane.md b/docs/adr/ecs-control-plane.md new file mode 100644 index 000000000..191487e8b --- /dev/null +++ b/docs/adr/ecs-control-plane.md @@ -0,0 +1,689 @@ +# ADR: ECS Control Plane (CRD + Operator Pattern on ECS) + +- **Status:** Proposed +- **Date:** 2026-05-18 +- **Author:** @pahud.hsieh +- **Related:** [Multi-Platform Adapters](./multi-platform-adapters.md), [Basic CronJob](./basic-cronjob.md) + +--- + +## 1. Context & Motivation + +OpenAB currently deploys on Kubernetes using Helm charts. While K8s provides a mature operator pattern (CRD + Controller), many teams prefer or require **Amazon ECS** for its operational simplicity and tighter AWS integration. + +We want to bring the same declarative, self-healing deployment model to ECS: + +- Operators declare desired state in YAML manifests (analogous to CRDs) +- A controller reconciles desired state against actual ECS resources +- OAB instances + arbitrary backends are deployed and maintained automatically + +This enables a "GitOps for ECS" workflow where pushing a YAML change triggers the controller to converge the cluster to the new desired state. + +--- + +## 2. Design Overview + +``` +┌──────────────────────────────────────────────────────┐ +│ ECS Control Plane (runs as ECS Service) │ +│ │ +│ ┌────────────┐ ┌──────────────┐ ┌─────────────┐ │ +│ │ State Store│ │ Reconciler │ │ ECS API / │ │ +│ │ (S3) │◄─│ Controller │─►│ CloudMap │ │ +│ │ │ │ │ │ │ │ +│ └────────────┘ └──────────────┘ └─────────────┘ │ +│ ▲ │ +│ │ events / poll │ +│ ▼ │ +│ ┌──────────────┐ │ +│ │ S3 Events / │ │ +│ │ EventBridge │ │ +│ └──────────────┘ │ +└──────────────────────────────────────────────────────┘ +``` + +### Core Loop (Reconciliation) + +1. Load all YAML manifests from S3 (desired state) +2. Describe current ECS services/tasks (observed state) +3. Compute diff (compare `metadata.generation` vs `status.observedGeneration`) +4. Apply changes: create, update, or delete ECS resources +5. Write status back to S3 (separate prefix), set `status.observedGeneration = metadata.generation` +6. Sleep / wait for next event + +--- + +## 3. Manifest Schema + +### API Identity (fixed) + +| Field | Value | +|-------|-------| +| `apiVersion` | `oab.dev/v1` | +| `kind` | `OABService` | + +All examples in this ADR use this identity. No other combinations (`openab.dev/v1`, `AgentDeployment`) are valid. + +### Full Example + +```yaml +apiVersion: oab.dev/v1 +kind: OABService +metadata: + name: my-agent + namespace: prod + generation: 4 # incremented by oabctl on each apply +spec: + replicas: 1 + capacityProvider: FARGATE # FARGATE (default) or FARGATE_SPOT + cpu: 256 # vCPU units (256 = 0.25 vCPU) + memory: 512 # MB + taskDefinition: + image: 123456789.dkr.ecr.us-east-1.amazonaws.com/openab:latest + bootstrapFrom: s3://oab-state/agents/my-agent/latest.tar.gz + secrets: + - name: DISCORD_BOT_TOKEN + source: ssm + path: /oab/my-agent/discord-token + - name: LLM_API_KEY + source: secretsmanager + arn: arn:aws:secretsmanager:us-east-1:123:secret:oab/my-agent/llm-key + networking: + subnets: [subnet-abc, subnet-def] + securityGroups: [sg-123] + assignPublicIp: false + config: + agent: + name: my-agent + backend: bedrock + model: us.anthropic.claude-sonnet-4-20250514 + discord: + enabled: true + botId: "123456789" + guildId: "987654321" + channelIds: ["111111111"] + steering: + source: s3 + bucket: oab-steering + prefix: agents/my-agent/ + memory: + backend: s3 + bucket: oab-memory + prefix: agents/my-agent/ + tools: + github: { enabled: true } + web: { enabled: true } +status: + phase: Running # Pending | Running | Failed | Terminating + observedGeneration: 4 # last generation the controller reconciled + taskArns: + - arn:aws:ecs:us-east-1:123456789012:task/cluster/abc123 + lastReconciled: "2026-05-18T22:50:00Z" + conditions: + - type: Available + status: "True" + lastTransitionTime: "2026-05-18T22:50:00Z" +``` + +### Key Fields + +| Field | Description | +|-------|-------------| +| `metadata.generation` | Monotonically increasing counter, bumped by `oabctl apply` | +| `spec.capacityProvider` | `FARGATE` (on-demand) or `FARGATE_SPOT` (up to 70% savings, tolerates interruption) | +| `spec.cpu` / `spec.memory` | Maps to ECS task definition (must be valid Fargate combination) | +| `spec.taskDefinition.image` | Container image | +| `spec.bootstrapFrom` | S3 path to mutable state archive (memory, knowledge base — **no secrets**) | +| `spec.secrets` | Per-agent secret references (SSM / Secrets Manager) | +| `spec.config` | Structured agent config; controller renders to `config.toml` | +| `spec.networking` | ECS awsvpc configuration | +| `status.observedGeneration` | Last generation the controller successfully reconciled | + +### Replicas Semantics + +OAB agents are **single-instance** by design — each agent holds one adapter connection (WebSocket gateway for Discord/Telegram/Slack). There is no load balancing across agent replicas. + +**Rules:** +- `replicas: 1` — the only valid value +- Controller **rejects** `replicas > 1` at validation time +- Scaling is horizontal by deploying **more agents** (each with its own bot token), not by replicating one agent + +### Fleet Provisioning (`OABFleet`) + +Enterprise scenario: provision 10-20 agents in one apply. Controller handles everything including Discord Bot registration. + +```yaml +apiVersion: oab.dev/v1 +kind: OABFleet +metadata: + name: enterprise-team + namespace: prod +spec: + defaults: + capacityProvider: FARGATE_SPOT + cpu: 512 + memory: 1024 + taskDefinition: + image: ghcr.io/openabdev/openab:latest + networking: + subnets: [subnet-abc, subnet-def] + securityGroups: [sg-oab] + discord: + enabled: true # all agents use Discord + agents: + - name: kiro-01 + config: { agent: { backend: kiro } } + - name: kiro-02 + config: { agent: { backend: kiro } } + - name: kiro-03 + config: { agent: { backend: kiro } } + - name: codex-01 + config: { agent: { backend: codex } } + cpu: 1024 + memory: 2048 # override defaults + - name: codex-02 + config: { agent: { backend: codex } } + cpu: 1024 + memory: 2048 + - name: gemini-01 + config: { agent: { backend: gemini } } + - name: gemini-02 + config: { agent: { backend: gemini } } + - name: gemini-03 + config: { agent: { backend: gemini } } + - name: gemini-04 + config: { agent: { backend: gemini } } + - name: gemini-05 + config: { agent: { backend: gemini } } +``` + +### Discord Bot Provisioning Flow + +Since Discord does not offer a public API for Bot creation, the actual flow for fleet provisioning is: + +``` +Pre-requisite (manual, one-time per agent): + → Create Bot in Discord Developer Portal + → Store token in SSM: /oab/{namespace}/{name}/discord-token + → Note the OAuth2 invite URL + +oabctl apply -f fleet.yaml + │ + │ For each agent: + ├─ 1. Validate spec + verify secret exists in SSM + ├─ 2. Render config.toml → S3 artifact (immutable, per generation) + ├─ 3. Register ECS TaskDefinition (pinned to config artifact + secrets) + ├─ 4. Create ECS Service (desiredCount=1) + └─ 5. Write status (phase=Running) +``` + +**Apply output:** + +```bash +$ oabctl apply -f fleet.yaml + +✓ kiro-01 provisioned (ECS service created, task running) +✓ kiro-02 provisioned (ECS service created, task running) +✓ kiro-03 provisioned (ECS service created, task running) +✓ codex-01 provisioned (ECS service created, task running) +✓ codex-02 provisioned (ECS service created, task running) +✓ gemini-01 provisioned (ECS service created, task running) +... + +10 agents provisioned. +``` + +**User's manual steps (one-time):** create bots in Discord Developer Portal, store tokens, add bots to server via OAuth URL. + +### Responsibility Model + +| Layer | Responsibility | +|-------|---------------| +| `oabctl` / Controller | Desired state: create ECS Services; **observe** ECS task/service status → write back to `status/` | +| ECS | Runtime health: task dies → auto-restart (desiredCount=1) | +| User | One-time: create bots in Discord Developer Portal, add to server via OAuth URL | + +The controller does **not** restart tasks — ECS handles that. But the controller **does** observe ECS service/task/deployment status on each reconcile cycle and writes it back to `status/{ns}/{name}.json`. This enables `oabctl get`, `oabctl wait --for=Available`, and `status.phase` / `status.conditions` to work. + +### Prerequisites for Auto-Registration + +> **⚠️ Note:** Discord does not provide a public API to programmatically create Bot Applications. `autoRegister` is a **future research item** pending Discord API changes or partnership access. For now, bot credentials must be pre-created manually in the Discord Developer Portal. + +**Phase 1/2 approach:** Each agent's bot token is pre-created and stored in SSM/Secrets Manager. The `OABFleet` spec references existing credentials: + +```yaml +spec: + agents: + - name: kiro-01 + config: { agent: { backend: kiro } } + secrets: + - name: DISCORD_BOT_TOKEN + source: ssm + path: /oab/kiro-01/discord-token # pre-created +``` + +**Future (if Discord API allows):** `autoRegister: true` would automate Bot creation. This requires a separate research ADR. + +--- + +## 4. Multi-Runtime Support (ECS + K8s) + +The same YAML manifest can deploy to **both ECS and Kubernetes**. The spec is platform-agnostic; platform-specific details live in an optional `platform:` overlay. + +### Design Principle + +``` +┌─────────────────────┐ +│ oab.dev/v1 YAML │ ← one spec, platform-agnostic core +└──────────┬──────────┘ + │ + ┌──────┴──────┐ + ▼ ▼ +┌────────┐ ┌────────┐ +│ ECS │ │ K8s │ +│Controller│ │Operator│ +└────┬───┘ └────┬───┘ + ▼ ▼ + ECS Service Deployment + ConfigMap + ExternalSecret +``` + +Each controller reads the core spec and its own `platform:` overlay, ignoring the other. + +### Spec with Platform Overlay + +```yaml +apiVersion: oab.dev/v1 +kind: OABService +metadata: + name: chaodu + namespace: prod +spec: + # Core (cross-platform) + cpu: 512 + memory: 1024 + config: + agent: { backend: kiro } + discord: { enabled: true, botId: "123" } + secrets: + - name: DISCORD_BOT_TOKEN + source: ssm + path: /oab/chaodu/discord-token + + # Platform-specific (each controller reads only its own key) + platform: + ecs: + capacityProvider: FARGATE_SPOT + executionRole: arn:aws:iam::123456789012:role/oab-task-execution + taskRole: arn:aws:iam::123456789012:role/oab-chaodu-task + networking: + subnets: [subnet-abc, subnet-def] + securityGroups: [sg-oab] + assignPublicIp: false + k8s: + nodeSelector: { node.kubernetes.io/capacity-type: spot } + serviceAccount: oab-agent + storageClass: gp3 +``` + +### Translation Table + +| Core Spec | ECS Controller | K8s Operator | +|-----------|---------------|--------------| +| `cpu: 512` | TaskDef `cpu=512` | `resources.requests.cpu: 500m` | +| `memory: 1024` | TaskDef `memory=1024` | `resources.requests.memory: 1Gi` | +| `spec.config` | Render → S3 artifact → startup wrapper | Render → ConfigMap → volume mount | +| `spec.secrets[].source: ssm` | ECS native `secrets` field | ExternalSecret → K8s Secret | +| `platform.ecs.capacityProvider` | Fargate capacity provider | _(ignored)_ | +| `platform.ecs.executionRole` | ECS task execution role | _(ignored)_ | +| `platform.ecs.taskRole` | ECS task role | _(ignored)_ | +| `platform.k8s.nodeSelector` | _(ignored)_ | Pod nodeSelector | + +### Rules + +- `platform:` is optional. If omitted, controller uses its own defaults. +- Controller **strict-validates** its own platform key (e.g., ECS controller rejects invalid `platform.ecs.*` fields with an error). +- Controller **ignores** other platform keys entirely (ECS controller skips `platform.k8s`, and vice versa). +- Core spec fields (`cpu`, `memory`, `config`, `secrets`) are mandatory and cross-platform. +- `OABFleet` also supports `platform:` at both `defaults` and per-agent level. + +### Phase Plan + +- **Phase 1**: ECS controller only. `platform.ecs` supported, `platform.k8s` ignored. +- **Phase 3**: K8s operator reads same manifests (from S3 or as native CRD). Shared schema, different runtime. + +--- + +## 5. Config Delivery Model + +The controller does **not** mount config into containers (ECS/Fargate has no shared volume equivalent to K8s ConfigMap). Instead: + +### Flow + +``` +oabctl apply -f agent.yaml + → writes manifest to S3 (manifests/{ns}/{name}.yaml, generation incremented) + +Controller reconcile: + → reads spec.config from manifest + → renders config.toml + → writes to s3://oab-control-plane/artifacts/{ns}/{name}/{generation}/config.toml (immutable) + → registers new ECS TaskDefinition with env CONFIG_ARTIFACT_PATH pinned to this generation + → updates ECS Service (rolling deployment) + +ECS Task startup (entrypoint wrapper): + → s3:GetObject ${CONFIG_ARTIFACT_PATH} → /home/agent/config.toml + → s3:GetObject ${BOOTSTRAP_FROM} → tar xzf → /home/agent/ (mutable state only) + → exec openab +``` + +Config artifacts are **immutable per generation** — once written, never overwritten. During rolling updates, old tasks keep fetching their pinned generation while new tasks use the new one. + +### Entrypoint Wrapper + +```bash +#!/bin/bash +set -e +# 1. Restore mutable state FIRST (memory, knowledge base) +if [ -n "$BOOTSTRAP_FROM" ]; then + aws s3 cp "$BOOTSTRAP_FROM" /tmp/bootstrap.tar.gz + tar xzf /tmp/bootstrap.tar.gz -C /home/agent/ + rm /tmp/bootstrap.tar.gz +fi +# 2. Download controller-rendered config LAST (overwrites any config.toml from archive) +aws s3 cp "$CONFIG_ARTIFACT_PATH" /home/agent/config.toml +exec /usr/local/bin/openab +``` + +**Order matters:** bootstrap archive is restored first, then the controller-rendered `config.toml` overwrites any stale config from the archive. `oabctl snapshot` must exclude `config.toml` from the archive (it's controller-managed, not user state). + +### What Goes Where + +| Content | Location | Managed By | +|---------|----------|------------| +| `config.toml` | S3 artifact (controller renders) | `spec.config` in manifest | +| Secrets (bot tokens, API keys) | SSM / Secrets Manager | `spec.secrets` in manifest | +| Memory / knowledge base | `bootstrapFrom` archive | `oabctl snapshot` | +| Steering files | S3 (referenced in config.toml) | Separate steering bucket | + +**Secrets never go in the bootstrap archive.** The archive contains only mutable runtime state that the agent accumulates over time. + +### IAM Requirements (Task Execution Role) + +The ECS task execution role (`platform.ecs.executionRole`) must have: + +```json +{ + "Effect": "Allow", + "Action": ["s3:GetObject"], + "Resource": [ + "arn:aws:s3:::oab-control-plane/artifacts/${namespace}/${name}/*", + "arn:aws:s3:::oab-state/agents/${name}/*" + ] +} +``` + +This allows the startup wrapper to download the controller-rendered `config.toml` and the bootstrap archive. + +--- + +## 6. Per-Agent Secret Injection + +Each agent/bot has its **own** credentials — no token sharing between agents. + +### Design Principles + +- Each `OABService` owns its secrets (1:1 mapping) +- Controller never touches secret values — it only wires references into ECS Task Definitions +- ECS native `secrets` field handles injection at runtime +- IAM scoping ensures each task role can only read its own secret path + +### Spec + +```yaml +spec: + secrets: + - name: DISCORD_BOT_TOKEN + source: ssm + path: /oab/chaodu/discord-token + - name: LLM_API_KEY + source: secretsmanager + arn: arn:aws:secretsmanager:us-east-1:123:secret:oab/chaodu/llm-key +``` + +### Controller Behavior + +1. **Deploy** — maps `spec.secrets` to ECS TaskDefinition `secrets` field: + ```json + { + "secrets": [ + { "name": "DISCORD_BOT_TOKEN", "valueFrom": "/oab/chaodu/discord-token" }, + { "name": "LLM_API_KEY", "valueFrom": "arn:aws:secretsmanager:us-east-1:123:secret:oab/chaodu/llm-key" } + ] + } + ``` +2. **IAM** — task execution role scoped to the agent's secret path: + ```json + { + "Effect": "Allow", + "Action": ["ssm:GetParameters", "secretsmanager:GetSecretValue"], + "Resource": [ + "arn:aws:ssm:*:*:parameter/oab/chaodu/*", + "arn:aws:secretsmanager:*:*:secret:oab/chaodu/*" + ] + } + ``` + +### Secret Rotation Lifecycle + +``` +1. Operator rotates secret in SSM/Secrets Manager (manual or auto-rotation) +2. Controller detects rotation: + - Option A: spec.secrets[].autoRestart: true → controller forces new deployment + - Option B: operator runs `oabctl restart my-agent` +3. ECS launches new task → new task fetches fresh secret value at startup +4. Old task drains and stops (ECS rolling update) +5. Controller updates status: + - conditions[].type: SecretsRefreshed + - conditions[].lastTransitionTime: +``` + +**Failure handling:** +- If new task fails to start (bad secret value), ECS circuit breaker stops the rollout +- Controller sets `status.phase: Failed`, `conditions[].type: SecretInjectionFailed` +- Old task remains running (ECS deployment circuit breaker preserves last healthy state) + +--- + +## 7. State Store Design (S3-Only) + +``` +s3://oab-control-plane/ + ├── manifests/{namespace}/{name}.yaml ← desired state (oabctl writes) + ├── status/{namespace}/{name}.json ← observed state (controller writes) + └── artifacts/{namespace}/{name}/{generation}/ ← immutable config per generation + └── config.toml +``` + +| Concern | Mechanism | Rationale | +|---------|-----------|-----------| +| Desired state | `manifests/` prefix | Human-readable, git-syncable, versioned via S3 versioning | +| Status | `status/` prefix | Controller writes after each reconcile cycle | +| Config artifacts | `artifacts/` prefix | Controller-rendered config.toml for task startup | +| Generation tracking | `metadata.generation` in manifest YAML | Explicit counter, not tied to S3 VersionId | +| Change detection | S3 Event Notifications → EventBridge (Phase 2) | Phase 1 uses polling | +| Consistency | S3 strong read-after-write | Sufficient for single-controller | +| Optimistic locking | S3 conditional writes (If-None-Match / ETag) | Prevents concurrent `oabctl apply` conflicts | + +### Generation vs S3 VersionId + +S3 VersionId is an opaque string — not suitable for comparing "which is newer." Instead: +- `metadata.generation` is an explicit integer, incremented by `oabctl apply` +- `status.observedGeneration` records the last generation the controller reconciled +- Controller skips reconcile if `observedGeneration == generation` (no-op) +- Stale status writes are detected: if status.observedGeneration < manifest.generation, the status is outdated + +### Delete Semantics (Phase 2) + +Phase 1: `oabctl delete` removes the manifest from S3; controller detects absence and tears down ECS resources. + +Phase 2: Proper deletion with finalizers: +1. `oabctl delete` sets `metadata.deletionTimestamp` in the manifest (tombstone) +2. Controller runs finalizers (drain connections, cleanup CloudMap, remove artifacts) +3. Controller removes manifest and status objects only after all finalizers complete + +--- + +## 8. Controller Upgrade Strategy + +The controller runs as a single-replica ECS Service. + +### Phase 1 (acceptable brief gap) + +```yaml +# Controller's own ECS Service config +deploymentConfiguration: + minimumHealthyPercent: 0 # allow old to stop before new starts + maximumPercent: 100 +``` + +- ECS rolling update: stop old → start new +- Brief reconciliation gap (30-60s) during upgrade +- No in-flight reconcile is lost — next cycle picks up any drift +- Acceptable for Phase 1 because reconcile is idempotent + +### Phase 2 (zero-downtime) + +- DynamoDB-based leader election (two controller replicas) +- Active/standby: standby takes over within seconds if active fails health check +- Version skew handling: new controller must handle manifests written by old `oabctl` versions (schema backward compatibility) + +### Rollback + +- Controller image is pinned in its own ECS TaskDefinition +- Rollback = `aws ecs update-service --task-definition ` +- Controller state is in S3 (stateless process), so rollback is safe + +--- + +## 9. CLI UX (`oabctl`) + +### Core Commands + +```bash +oabctl apply -f agent.yaml # declare/update desired state +oabctl get oabservice # list all services + status +oabctl get oabservice my-agent # single service detail +oabctl delete oabservice my-agent # remove (Phase 1: immediate; Phase 2: finalizer) +oabctl diff -f agent.yaml # show local vs remote diff +oabctl logs my-agent # shortcut to ECS task logs (CloudWatch) +oabctl restart my-agent # force new deployment (pick up rotated secrets) +oabctl snapshot my-agent # capture runtime state → bootstrapFrom archive +oabctl wait my-agent --for=Available # block until condition met +``` + +### `apply` Semantics + +``` +$ oabctl apply -f prod/my-agent.yaml + +✓ Schema validated (oab.dev/v1 OABService) +✓ Replicas check passed (replicas=1) +✓ Uploaded to s3://oab-control-plane/manifests/prod/my-agent.yaml +✓ Generation: 3 → 4 +⏳ Waiting for reconciliation... +✓ Service my-agent reconciled (observedGeneration=4, 1/1 tasks running) +``` + +### `diff` Granularity + +```bash +oabctl diff -f agent.yaml # spec-only: local YAML vs remote manifest +oabctl diff -f agent.yaml --rendered # rendered: show generated config.toml diff +oabctl diff -f agent.yaml --status # include status comparison +``` + +### Implementation + +`oabctl` talks directly to S3 via AWS SDK. No API server needed. Auth is standard IAM (role, profile, env vars). Config stored in `~/.oabctl/config`: + +```toml +[default] +region = "us-east-1" +bucket = "oab-control-plane" +cluster = "oab-prod" +``` + +--- + +## 10. Phase Scope + +### Phase 1 — MVP (target) + +| In Scope | Out of Scope | +|----------|--------------| +| S3 manifest store (versioning enabled) | EventBridge triggers | +| Single-instance controller (poll every 30s) | Multi-replica controller / leader election | +| `oabctl apply` / `oabctl get` | `oabctl delete` with finalizers | +| Controller renders config.toml → S3 artifact | DynamoDB state store | +| ECS service create / update | Rollback (`oabctl rollback`) | +| Startup wrapper downloads config + bootstrap | EFS / shared volumes | +| `metadata.generation` / `status.observedGeneration` | Multi-region | +| Per-agent secrets via SSM/SM | Auto-rotation detection | +| Replicas validation (reject >1) | Auto-scaling policies | + +### Phase 2 + +- `OABFleet` kind + Discord `autoRegister` (batch Bot provisioning) +- Event-driven triggers (S3 → EventBridge → controller) +- `oabctl delete` with tombstone + finalizers +- `oabctl diff`, `oabctl logs`, `oabctl restart` +- DynamoDB for leader election (active/standby controller) +- Secret auto-rotation detection + auto-restart +- Rollback via generation history + +### Phase 3 + +- **K8s Operator** — same `oab.dev/v1` schema consumed as native CRD; `platform.k8s` overlay +- Multi-region (controller per region, S3 cross-region replication) +- Dependency graph (service A depends on service B) +- Auto-scaling policies in manifest spec +- GitOps integration (GitHub Actions → `oabctl apply` on push) +- Schema versioning + migration tooling + +--- + +## 11. Alternatives Considered + +| Alternative | Why not chosen | +|-------------|---------------| +| AWS Proton | Opinionated, limited customization for OAB-specific logic | +| AWS Copilot | Good for simple apps, no custom reconciliation loop | +| CDK Pipelines | Deployment tool, not a runtime controller with drift detection | +| Step Functions orchestrator | Stateless execution model, no continuous reconciliation | +| Run K8s anyway (EKS) | Valid but adds operational overhead for teams that chose ECS | +| DynamoDB as primary store | Adds infra; S3 sufficient for single-controller Phase 1 | + +--- + +## 12. Open Questions + +1. **Multi-region** — single controller per region, or global controller with regional reconcilers? +2. **Observability** — CloudWatch metrics from the controller, or push to a shared OAB dashboard? +3. **Networking isolation** — shared VPC with per-service SG rules, or per-namespace VPC? +4. **Schema versioning** — how to handle `oab.dev/v2` migration when spec evolves? + +--- + +## 13. Decision + +We adopt the CRD + Operator pattern on ECS with an **S3-only state store**, **explicit generation tracking**, and a **`oabctl` CLI** for the operator interface. The controller runs as a single ECS service that reconciles `OABService` manifests against actual ECS state. + +Key design choices: +- **Config delivery**: controller renders `config.toml` to S3 artifact; startup wrapper downloads it +- **Secrets**: per-agent SSM/Secrets Manager references; never in bootstrap archive +- **Bootstrap**: mutable runtime state only (memory, knowledge base) +- **Replicas**: always 1; scale by deploying more agents, not replicating one +- **Generation**: explicit `metadata.generation` / `status.observedGeneration` (not S3 VersionId) +- **Phase 1 scope**: narrow (create/update only, poll-based, single controller) + +DynamoDB, EventBridge, finalizers, and multi-region are deferred to Phase 2+. From 496873251647f36dc72a61b188f7591029f64ff3 Mon Sep 17 00:00:00 2001 From: chaodufashi Date: Mon, 18 May 2026 23:32:21 -0400 Subject: [PATCH 046/100] =?UTF-8?q?feat(operator):=20oabctl=20Phase=201=20?= =?UTF-8?q?=E2=80=94=20apply,=20get,=20delete=20(#851)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(operator): add oabctl Phase 1 — apply, get, delete for ECS Implements the CLI provisioner from ADR docs/adr/ecs-control-plane.md: - oabctl apply -f : validate manifest, render config to S3, register task def, create/update ECS service - oabctl get oabservice: list services via ECS DescribeServices - oabctl delete oabservice : teardown ECS service + S3 cleanup - entrypoint.sh: wrapper script for ECS tasks (bootstrap + config download) Schema: oab.dev/v1 OABService with capacityProvider, cpu, memory, bootstrapFrom, networking, config, secrets fields. * ci: add operator/oabctl build job to CI workflow * fix(operator): handle AWS SDK builder Result types * fix(operator): capacity_provider() returns &str not Option * fix(operator): resolve clippy warnings (is_some_and, useless format) * fix(operator): inline literal in format string (clippy::print_literal) * fix(operator): address blocking review feedback - #1: Read generation from S3 manifest, increment on each apply (immutable config path) - #2: Remove launch_type (conflicts with capacity_provider_strategy) - #3: Add generation field to Metadata struct --------- Co-authored-by: Kiro --- .github/workflows/ci.yml | 30 + operator/.gitignore | 1 + operator/Cargo.lock | 2711 ++++++++++++++++++++++++++++++++++++++ operator/Cargo.toml | 21 + operator/entrypoint.sh | 23 + operator/src/apply.rs | 277 ++++ operator/src/delete.rs | 70 + operator/src/get.rs | 114 ++ operator/src/main.rs | 60 + operator/src/manifest.rs | 135 ++ 10 files changed, 3442 insertions(+) create mode 100644 operator/.gitignore create mode 100644 operator/Cargo.lock create mode 100644 operator/Cargo.toml create mode 100755 operator/entrypoint.sh create mode 100644 operator/src/apply.rs create mode 100644 operator/src/delete.rs create mode 100644 operator/src/get.rs create mode 100644 operator/src/main.rs create mode 100644 operator/src/manifest.rs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 59d15ae2a..a5fa031db 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -6,6 +6,8 @@ on: - "src/**" - "gateway/**" - "gateway/Cargo.lock" + - "operator/**" + - "operator/Cargo.lock" - "Cargo.toml" - "Cargo.lock" - "Dockerfile*" @@ -58,3 +60,31 @@ jobs: - name: cargo test run: cargo test + + operator: + runs-on: ubuntu-latest + defaults: + run: + working-directory: operator + steps: + - uses: actions/checkout@v6 + + - uses: dtolnay/rust-toolchain@stable + with: + components: clippy + + - uses: Swatinem/rust-cache@v2 + with: + workspaces: operator + + - name: cargo check + run: cargo check + + - name: cargo clippy + run: cargo clippy -- -D warnings + + - name: cargo test + run: cargo test + + - name: cargo build + run: cargo build --release diff --git a/operator/.gitignore b/operator/.gitignore new file mode 100644 index 000000000..ea8c4bf7f --- /dev/null +++ b/operator/.gitignore @@ -0,0 +1 @@ +/target diff --git a/operator/Cargo.lock b/operator/Cargo.lock new file mode 100644 index 000000000..435dd4af7 --- /dev/null +++ b/operator/Cargo.lock @@ -0,0 +1,2711 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + +[[package]] +name = "anstream" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" + +[[package]] +name = "anstyle-parse" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys 0.61.2", +] + +[[package]] +name = "anyhow" +version = "1.0.102" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "autocfg" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" + +[[package]] +name = "aws-config" +version = "1.8.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50f156acdd2cf55f5aa53ee416c4ac851cf1222694506c0b1f78c85695e9ca9d" +dependencies = [ + "aws-credential-types", + "aws-runtime", + "aws-sdk-sso", + "aws-sdk-ssooidc", + "aws-sdk-sts", + "aws-smithy-async", + "aws-smithy-http", + "aws-smithy-json", + "aws-smithy-runtime", + "aws-smithy-runtime-api", + "aws-smithy-types", + "aws-types", + "bytes", + "fastrand", + "hex", + "http 1.4.0", + "sha1 0.10.6", + "time", + "tokio", + "tracing", + "url", + "zeroize", +] + +[[package]] +name = "aws-credential-types" +version = "1.2.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f20799b373a1be121fe3005fba0c2090af9411573878f224df44b42727fcaf7" +dependencies = [ + "aws-smithy-async", + "aws-smithy-runtime-api", + "aws-smithy-types", + "zeroize", +] + +[[package]] +name = "aws-lc-rs" +version = "1.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ec2f1fc3ec205783a5da9a7e6c1509cc69dedf09a1949e412c1e18469326d00" +dependencies = [ + "aws-lc-sys", + "zeroize", +] + +[[package]] +name = "aws-lc-sys" +version = "0.41.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a2f9779ce85b93ab6170dd940ad0169b5766ff848247aff13bb788b832fe3f4" +dependencies = [ + "cc", + "cmake", + "dunce", + "fs_extra", +] + +[[package]] +name = "aws-runtime" +version = "1.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5dcd93c82209ac7413532388067dce79be5a8780c1786e5fae3df22e4dee2864" +dependencies = [ + "aws-credential-types", + "aws-sigv4", + "aws-smithy-async", + "aws-smithy-eventstream", + "aws-smithy-http", + "aws-smithy-runtime", + "aws-smithy-runtime-api", + "aws-smithy-types", + "aws-types", + "bytes", + "bytes-utils", + "fastrand", + "http 0.2.12", + "http 1.4.0", + "http-body 0.4.6", + "http-body 1.0.1", + "percent-encoding", + "pin-project-lite", + "tracing", + "uuid", +] + +[[package]] +name = "aws-sdk-ecs" +version = "1.124.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e28ffb1fa2097067108e6a0a6dd92cf9075391b50c709b268ef5f42ae0642a0" +dependencies = [ + "aws-credential-types", + "aws-runtime", + "aws-smithy-async", + "aws-smithy-http", + "aws-smithy-json", + "aws-smithy-observability", + "aws-smithy-runtime", + "aws-smithy-runtime-api", + "aws-smithy-types", + "aws-types", + "bytes", + "fastrand", + "http 0.2.12", + "http 1.4.0", + "regex-lite", + "tracing", +] + +[[package]] +name = "aws-sdk-s3" +version = "1.132.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5575840a3a6b11f6011463ebe359320dfe5b67babb5e9b06fed6ddf809a9ab40" +dependencies = [ + "aws-credential-types", + "aws-runtime", + "aws-sigv4", + "aws-smithy-async", + "aws-smithy-checksums", + "aws-smithy-eventstream", + "aws-smithy-http", + "aws-smithy-json", + "aws-smithy-observability", + "aws-smithy-runtime", + "aws-smithy-runtime-api", + "aws-smithy-types", + "aws-smithy-xml", + "aws-types", + "bytes", + "fastrand", + "hex", + "hmac 0.13.0", + "http 0.2.12", + "http 1.4.0", + "http-body 1.0.1", + "lru", + "percent-encoding", + "regex-lite", + "sha2 0.11.0", + "tracing", + "url", +] + +[[package]] +name = "aws-sdk-ssm" +version = "1.109.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69f4bdbeea2c7d18632093cd158644902f1e91ae025a3f68afaa449f620ae658" +dependencies = [ + "aws-credential-types", + "aws-runtime", + "aws-smithy-async", + "aws-smithy-http", + "aws-smithy-json", + "aws-smithy-observability", + "aws-smithy-runtime", + "aws-smithy-runtime-api", + "aws-smithy-types", + "aws-types", + "bytes", + "fastrand", + "http 0.2.12", + "http 1.4.0", + "regex-lite", + "tracing", +] + +[[package]] +name = "aws-sdk-sso" +version = "1.98.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d69c77aafa20460c68b6b3213c84f6423b6e76dbf89accd3e1789a686ffd9489" +dependencies = [ + "aws-credential-types", + "aws-runtime", + "aws-smithy-async", + "aws-smithy-http", + "aws-smithy-json", + "aws-smithy-observability", + "aws-smithy-runtime", + "aws-smithy-runtime-api", + "aws-smithy-types", + "aws-types", + "bytes", + "fastrand", + "http 0.2.12", + "http 1.4.0", + "regex-lite", + "tracing", +] + +[[package]] +name = "aws-sdk-ssooidc" +version = "1.100.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c7e7b09346d5ca22a2a08267555843a6a0127fb20d8964cb6ecfb8fdb190225" +dependencies = [ + "aws-credential-types", + "aws-runtime", + "aws-smithy-async", + "aws-smithy-http", + "aws-smithy-json", + "aws-smithy-observability", + "aws-smithy-runtime", + "aws-smithy-runtime-api", + "aws-smithy-types", + "aws-types", + "bytes", + "fastrand", + "http 0.2.12", + "http 1.4.0", + "regex-lite", + "tracing", +] + +[[package]] +name = "aws-sdk-sts" +version = "1.103.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2249b81a2e73a8027c41c378463a81ec39b8510f184f2caab87de912af0f49b" +dependencies = [ + "aws-credential-types", + "aws-runtime", + "aws-smithy-async", + "aws-smithy-http", + "aws-smithy-json", + "aws-smithy-observability", + "aws-smithy-query", + "aws-smithy-runtime", + "aws-smithy-runtime-api", + "aws-smithy-types", + "aws-smithy-xml", + "aws-types", + "fastrand", + "http 0.2.12", + "http 1.4.0", + "regex-lite", + "tracing", +] + +[[package]] +name = "aws-sigv4" +version = "1.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68dc0b907359b120170613b5c09ccc61304eac3998ff6274b97d93ee6490115a" +dependencies = [ + "aws-credential-types", + "aws-smithy-eventstream", + "aws-smithy-http", + "aws-smithy-runtime-api", + "aws-smithy-types", + "bytes", + "crypto-bigint 0.5.5", + "form_urlencoded", + "hex", + "hmac 0.13.0", + "http 0.2.12", + "http 1.4.0", + "p256", + "percent-encoding", + "ring", + "sha2 0.11.0", + "subtle", + "time", + "tracing", + "zeroize", +] + +[[package]] +name = "aws-smithy-async" +version = "1.2.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ffcaf626bdda484571968400c326a244598634dc75fd451325a54ad1a59acfc" +dependencies = [ + "futures-util", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "aws-smithy-checksums" +version = "0.64.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10efbbcec1e044b81600e2fc562a391951d291152d95b482d5b7e7132299d762" +dependencies = [ + "aws-smithy-http", + "aws-smithy-types", + "bytes", + "crc-fast", + "hex", + "http 1.4.0", + "http-body 1.0.1", + "http-body-util", + "md-5", + "pin-project-lite", + "sha1 0.11.0", + "sha2 0.11.0", + "tracing", +] + +[[package]] +name = "aws-smithy-eventstream" +version = "0.60.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "faf09d74e5e32f76b8762da505a3cd59303e367a664ca67295387baa8c1d7548" +dependencies = [ + "aws-smithy-types", + "bytes", + "crc32fast", +] + +[[package]] +name = "aws-smithy-http" +version = "0.63.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba1ab2dc1c2c3749ead27180d333c42f11be8b0e934058fb4b2258ee8dbe5231" +dependencies = [ + "aws-smithy-eventstream", + "aws-smithy-runtime-api", + "aws-smithy-types", + "bytes", + "bytes-utils", + "futures-core", + "futures-util", + "http 1.4.0", + "http-body 1.0.1", + "http-body-util", + "percent-encoding", + "pin-project-lite", + "pin-utils", + "tracing", +] + +[[package]] +name = "aws-smithy-http-client" +version = "1.1.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a2f165a7feee6f263028b899d0a181987f4fa7179a6411a32a439fba7c5f769" +dependencies = [ + "aws-smithy-async", + "aws-smithy-runtime-api", + "aws-smithy-types", + "h2 0.3.27", + "h2 0.4.14", + "http 0.2.12", + "http 1.4.0", + "http-body 0.4.6", + "hyper 0.14.32", + "hyper 1.9.0", + "hyper-rustls 0.24.2", + "hyper-rustls 0.27.9", + "hyper-util", + "pin-project-lite", + "rustls 0.21.12", + "rustls 0.23.40", + "rustls-native-certs", + "rustls-pki-types", + "tokio", + "tokio-rustls 0.26.4", + "tower", + "tracing", +] + +[[package]] +name = "aws-smithy-json" +version = "0.62.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9648b0bb82a2eedd844052c6ad2a1a822d1f8e3adee5fbf668366717e428856a" +dependencies = [ + "aws-smithy-types", +] + +[[package]] +name = "aws-smithy-observability" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a06c2315d173edbf1920da8ba3a7189695827002e4c0fc961973ab1c54abca9c" +dependencies = [ + "aws-smithy-runtime-api", +] + +[[package]] +name = "aws-smithy-query" +version = "0.60.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a56d79744fb3edb5d722ef79d86081e121d3b9422cb209eb03aea6aa4f21ebd" +dependencies = [ + "aws-smithy-types", + "urlencoding", +] + +[[package]] +name = "aws-smithy-runtime" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0504b1ab12debb5959e5165ee5fe97dd387e7aa7ea6a477bfd7635dfe769a4f5" +dependencies = [ + "aws-smithy-async", + "aws-smithy-http", + "aws-smithy-http-client", + "aws-smithy-observability", + "aws-smithy-runtime-api", + "aws-smithy-types", + "bytes", + "fastrand", + "http 0.2.12", + "http 1.4.0", + "http-body 0.4.6", + "http-body 1.0.1", + "http-body-util", + "pin-project-lite", + "pin-utils", + "tokio", + "tracing", +] + +[[package]] +name = "aws-smithy-runtime-api" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b71a13df6ada0aafbf21a73bdfcdf9324cfa9df77d96b8446045be3cde61b42e" +dependencies = [ + "aws-smithy-async", + "aws-smithy-runtime-api-macros", + "aws-smithy-types", + "bytes", + "http 0.2.12", + "http 1.4.0", + "pin-project-lite", + "tokio", + "tracing", + "zeroize", +] + +[[package]] +name = "aws-smithy-runtime-api-macros" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8d7396fd9500589e62e460e987ecb671bad374934e55ec3b5f498cc7a8a8a7b7" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "aws-smithy-types" +version = "1.4.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d73dbfbaa8e4bc57b9045137680b958d274823509a360abfd8e1d514d40c95c" +dependencies = [ + "base64-simd", + "bytes", + "bytes-utils", + "futures-core", + "http 0.2.12", + "http 1.4.0", + "http-body 0.4.6", + "http-body 1.0.1", + "http-body-util", + "itoa", + "num-integer", + "pin-project-lite", + "pin-utils", + "ryu", + "serde", + "time", + "tokio", + "tokio-util", +] + +[[package]] +name = "aws-smithy-xml" +version = "0.60.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ce02add1aa3677d022f8adf81dcbe3046a95f17a1b1e8979c145cd21d3d22b3" +dependencies = [ + "xmlparser", +] + +[[package]] +name = "aws-types" +version = "1.3.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f4bbcaa9304ea40902d3d5f42a0428d1bd895a2b0f6999436fb279ffddc58ac" +dependencies = [ + "aws-credential-types", + "aws-smithy-async", + "aws-smithy-runtime-api", + "aws-smithy-types", + "rustc_version", + "tracing", +] + +[[package]] +name = "base16ct" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "349a06037c7bf932dd7e7d1f653678b2038b9ad46a74102f1fc7bd7872678cce" + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "base64-simd" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "339abbe78e73178762e23bea9dfd08e697eb3f3301cd4be981c0f78ba5859195" +dependencies = [ + "outref", + "vsimd", +] + +[[package]] +name = "base64ct" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" + +[[package]] +name = "bitflags" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3" + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "block-buffer" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdd35008169921d80bc60d3d0ab416eecb028c4cd653352907921d95084790be" +dependencies = [ + "hybrid-array", +] + +[[package]] +name = "bumpalo" +version = "3.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" + +[[package]] +name = "bytes" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" + +[[package]] +name = "bytes-utils" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dafe3a8757b027e2be6e4e5601ed563c55989fcf1546e933c66c8eb3a058d35" +dependencies = [ + "bytes", + "either", +] + +[[package]] +name = "cc" +version = "1.2.62" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1dce859f0832a7d088c4f1119888ab94ef4b5d6795d1ce05afb7fe159d79f98" +dependencies = [ + "find-msvc-tools", + "jobserver", + "libc", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "clap" +version = "4.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ddb117e43bbf7dacf0a4190fef4d345b9bad68dfc649cb349e7d17d28428e51" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2ce8604710f6733aa641a2b3731eaa1e8b3d9973d5e3565da11800813f997a9" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "clap_lex" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" + +[[package]] +name = "cmake" +version = "0.1.58" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0f78a02292a74a88ac736019ab962ece0bc380e3f977bf72e376c5d78ff0678" +dependencies = [ + "cc", +] + +[[package]] +name = "cmov" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f88a43d011fc4a6876cb7344703e297c71dda42494fee094d5f7c76bf13f746" + +[[package]] +name = "colorchoice" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" + +[[package]] +name = "const-oid" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" + +[[package]] +name = "const-oid" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c" + +[[package]] +name = "core-foundation" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "cpufeatures" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201" +dependencies = [ + "libc", +] + +[[package]] +name = "crc" +version = "3.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9710d3b3739c2e349eb44fe848ad0b7c8cb1e42bd87ee49371df2f7acaf3e675" +dependencies = [ + "crc-catalog", +] + +[[package]] +name = "crc-catalog" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "217698eaf96b4a3f0bc4f3662aaa55bdf913cd54d7204591faa790070c6d0853" + +[[package]] +name = "crc-fast" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2fd92aca2c6001b1bf5ba0ff84ee74ec8501b52bbef0cac80bf25a6c1d87a83d" +dependencies = [ + "crc", + "digest 0.10.7", + "rustversion", + "spin", +] + +[[package]] +name = "crc32fast" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "crypto-bigint" +version = "0.4.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef2b4b23cddf68b89b8f8069890e8c270d54e2d5fe1b143820234805e4cb17ef" +dependencies = [ + "generic-array", + "rand_core", + "subtle", + "zeroize", +] + +[[package]] +name = "crypto-bigint" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0dc92fb57ca44df6db8059111ab3af99a63d5d0f8375d9972e319a379c6bab76" +dependencies = [ + "rand_core", + "subtle", +] + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "crypto-common" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77727bb15fa921304124b128af125e7e3b968275d1b108b379190264f4423710" +dependencies = [ + "hybrid-array", +] + +[[package]] +name = "ctutils" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d5515a3834141de9eafb9717ad39eea8247b5674e6066c404e8c4b365d2a29e" +dependencies = [ + "cmov", +] + +[[package]] +name = "der" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1a467a65c5e759bce6e65eaf91cc29f466cdc57cb65777bd646872a8a1fd4de" +dependencies = [ + "const-oid 0.9.6", + "zeroize", +] + +[[package]] +name = "deranged" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" +dependencies = [ + "powerfmt", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer 0.10.4", + "crypto-common 0.1.7", + "subtle", +] + +[[package]] +name = "digest" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" +dependencies = [ + "block-buffer 0.12.0", + "const-oid 0.10.2", + "crypto-common 0.2.1", + "ctutils", +] + +[[package]] +name = "displaydoc" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "dunce" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" + +[[package]] +name = "ecdsa" +version = "0.14.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "413301934810f597c1d19ca71c8710e99a3f1ba28a0d2ebc01551a2daeea3c5c" +dependencies = [ + "der", + "elliptic-curve", + "rfc6979", + "signature", +] + +[[package]] +name = "either" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" + +[[package]] +name = "elliptic-curve" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7bb888ab5300a19b8e5bceef25ac745ad065f3c9f7efc6de1b91958110891d3" +dependencies = [ + "base16ct", + "crypto-bigint 0.4.9", + "der", + "digest 0.10.7", + "ff", + "generic-array", + "group", + "pkcs8", + "rand_core", + "sec1", + "subtle", + "zeroize", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "fastrand" +version = "2.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" + +[[package]] +name = "ff" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d013fc25338cc558c5c2cfbad646908fb23591e2404481826742b651c9af7160" +dependencies = [ + "rand_core", + "subtle", +] + +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "fs_extra" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" + +[[package]] +name = "futures-channel" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" +dependencies = [ + "futures-core", +] + +[[package]] +name = "futures-core" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" + +[[package]] +name = "futures-sink" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" + +[[package]] +name = "futures-task" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" + +[[package]] +name = "futures-util" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +dependencies = [ + "futures-core", + "futures-task", + "pin-project-lite", + "slab", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi", + "wasip2", +] + +[[package]] +name = "group" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5dfbfb3a6cfbd390d5c9564ab283a0349b9b9fcd46a706c1eb10e0db70bfbac7" +dependencies = [ + "ff", + "rand_core", + "subtle", +] + +[[package]] +name = "h2" +version = "0.3.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0beca50380b1fc32983fc1cb4587bfa4bb9e78fc259aad4a0032d2080309222d" +dependencies = [ + "bytes", + "fnv", + "futures-core", + "futures-sink", + "futures-util", + "http 0.2.12", + "indexmap", + "slab", + "tokio", + "tokio-util", + "tracing", +] + +[[package]] +name = "h2" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "171fefbc92fe4a4de27e0698d6a5b392d6a0e333506bc49133760b3bcf948733" +dependencies = [ + "atomic-waker", + "bytes", + "fnv", + "futures-core", + "futures-sink", + "http 1.4.0", + "indexmap", + "slab", + "tokio", + "tokio-util", + "tracing", +] + +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "hmac" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +dependencies = [ + "digest 0.10.7", +] + +[[package]] +name = "hmac" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6303bc9732ae41b04cb554b844a762b4115a61bfaa81e3e83050991eeb56863f" +dependencies = [ + "digest 0.11.3", +] + +[[package]] +name = "http" +version = "0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "601cbb57e577e2f5ef5be8e7b83f0f63994f25aa94d673e54a92d5c516d101f1" +dependencies = [ + "bytes", + "fnv", + "itoa", +] + +[[package]] +name = "http" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3ba2a386d7f85a81f119ad7498ebe444d2e22c2af0b86b069416ace48b3311a" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ceab25649e9960c0311ea418d17bee82c0dcec1bd053b5f9a66e265a693bed2" +dependencies = [ + "bytes", + "http 0.2.12", + "pin-project-lite", +] + +[[package]] +name = "http-body" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" +dependencies = [ + "bytes", + "http 1.4.0", +] + +[[package]] +name = "http-body-util" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" +dependencies = [ + "bytes", + "futures-core", + "http 1.4.0", + "http-body 1.0.1", + "pin-project-lite", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + +[[package]] +name = "hybrid-array" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9155a582abd142abc056962c29e3ce5ff2ad5469f4246b537ed42c5deba857da" +dependencies = [ + "typenum", +] + +[[package]] +name = "hyper" +version = "0.14.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41dfc780fdec9373c01bae43289ea34c972e40ee3c9f6b3c8801a35f35586ce7" +dependencies = [ + "bytes", + "futures-channel", + "futures-core", + "futures-util", + "h2 0.3.27", + "http 0.2.12", + "http-body 0.4.6", + "httparse", + "httpdate", + "itoa", + "pin-project-lite", + "socket2 0.5.10", + "tokio", + "tower-service", + "tracing", + "want", +] + +[[package]] +name = "hyper" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6299f016b246a94207e63da54dbe807655bf9e00044f73ded42c3ac5305fbcca" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "h2 0.4.14", + "http 1.4.0", + "http-body 1.0.1", + "httparse", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-rustls" +version = "0.24.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec3efd23720e2049821a693cbc7e65ea87c72f1c58ff2f9522ff332b1491e590" +dependencies = [ + "futures-util", + "http 0.2.12", + "hyper 0.14.32", + "log", + "rustls 0.21.12", + "tokio", + "tokio-rustls 0.24.1", +] + +[[package]] +name = "hyper-rustls" +version = "0.27.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f" +dependencies = [ + "http 1.4.0", + "hyper 1.9.0", + "hyper-util", + "rustls 0.23.40", + "rustls-native-certs", + "tokio", + "tokio-rustls 0.26.4", + "tower-service", +] + +[[package]] +name = "hyper-util" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" +dependencies = [ + "base64", + "bytes", + "futures-channel", + "futures-util", + "http 1.4.0", + "http-body 1.0.1", + "hyper 1.9.0", + "ipnet", + "libc", + "percent-encoding", + "pin-project-lite", + "socket2 0.6.3", + "tokio", + "tower-service", + "tracing", +] + +[[package]] +name = "icu_collections" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38" + +[[package]] +name = "icu_properties" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de" +dependencies = [ + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14" + +[[package]] +name = "icu_provider" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "indexmap" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown 0.17.1", +] + +[[package]] +name = "ipnet" +version = "2.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" + +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "jobserver" +version = "0.1.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33" +dependencies = [ + "getrandom 0.3.4", + "libc", +] + +[[package]] +name = "js-sys" +version = "0.3.98" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67df7112613f8bfd9150013a0314e196f4800d3201ae742489d999db2f979f08" +dependencies = [ + "cfg-if", + "futures-util", + "once_cell", + "wasm-bindgen", +] + +[[package]] +name = "libc" +version = "0.2.186" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" + +[[package]] +name = "litemap" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" + +[[package]] +name = "lru" +version = "0.16.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f66e8d5d03f609abc3a39e6f08e4164ebf1447a732906d39eb9b99b7919ef39" +dependencies = [ + "hashbrown 0.16.1", +] + +[[package]] +name = "md-5" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69b6441f590336821bb897fb28fc622898ccceb1d6cea3fde5ea86b090c4de98" +dependencies = [ + "cfg-if", + "digest 0.11.3", +] + +[[package]] +name = "memchr" +version = "2.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" + +[[package]] +name = "mio" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "num-conv" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" + +[[package]] +name = "num-integer" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "oabctl" +version = "0.1.0" +dependencies = [ + "anyhow", + "aws-config", + "aws-sdk-ecs", + "aws-sdk-s3", + "aws-sdk-ssm", + "clap", + "serde", + "serde_yaml", + "tokio", + "toml", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + +[[package]] +name = "openssl-probe" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" + +[[package]] +name = "outref" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a80800c0488c3a21695ea981a54918fbb37abf04f4d0720c453632255e2ff0e" + +[[package]] +name = "p256" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "51f44edd08f51e2ade572f141051021c5af22677e42b7dd28a88155151c33594" +dependencies = [ + "ecdsa", + "elliptic-curve", + "sha2 0.10.9", +] + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link", +] + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "pin-utils" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" + +[[package]] +name = "pkcs8" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9eca2c590a5f85da82668fa685c09ce2888b9430e83299debf1f34b65fd4a4ba" +dependencies = [ + "der", + "spki", +] + +[[package]] +name = "potential_utf" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" +dependencies = [ + "zerovec", +] + +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.17", +] + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags", +] + +[[package]] +name = "regex-lite" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cab834c73d247e67f4fae452806d17d3c7501756d98c8808d7c9c7aa7d18f973" + +[[package]] +name = "rfc6979" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7743f17af12fa0b03b803ba12cd6a8d9483a587e89c69445e3909655c0b9fabb" +dependencies = [ + "crypto-bigint 0.4.9", + "hmac 0.12.1", + "zeroize", +] + +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + +[[package]] +name = "rustls" +version = "0.21.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f56a14d1f48b391359b22f731fd4bd7e43c97f3c50eee276f3aa09c94784d3e" +dependencies = [ + "log", + "ring", + "rustls-webpki 0.101.7", + "sct", +] + +[[package]] +name = "rustls" +version = "0.23.40" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef86cd5876211988985292b91c96a8f2d298df24e75989a43a3c73f2d4d8168b" +dependencies = [ + "aws-lc-rs", + "once_cell", + "rustls-pki-types", + "rustls-webpki 0.103.13", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-native-certs" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "612460d5f7bea540c490b2b6395d8e34a953e52b491accd6c86c8164c5932a63" +dependencies = [ + "openssl-probe", + "rustls-pki-types", + "schannel", + "security-framework", +] + +[[package]] +name = "rustls-pki-types" +version = "1.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30a7197ae7eb376e574fe940d068c30fe0462554a3ddbe4eca7838e049c937a9" +dependencies = [ + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.101.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b6275d1ee7a1cd780b64aca7726599a1dbc893b1e64144529e55c3c2f745765" +dependencies = [ + "ring", + "untrusted", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" +dependencies = [ + "aws-lc-rs", + "ring", + "rustls-pki-types", + "untrusted", +] + +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "schannel" +version = "0.1.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "sct" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da046153aa2352493d6cb7da4b6e5c0c057d8a1d0a9aa8560baffdd945acd414" +dependencies = [ + "ring", + "untrusted", +] + +[[package]] +name = "sec1" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3be24c1842290c45df0a7bf069e0c268a747ad05a192f2fd7dcfdbc1cba40928" +dependencies = [ + "base16ct", + "der", + "generic-array", + "pkcs8", + "subtle", + "zeroize", +] + +[[package]] +name = "security-framework" +version = "3.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" +dependencies = [ + "bitflags", + "core-foundation", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_spanned" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3" +dependencies = [ + "serde", +] + +[[package]] +name = "serde_yaml" +version = "0.9.34+deprecated" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a8b1a1a2ebf674015cc02edccce75287f1a0130d394307b36743c2f5d504b47" +dependencies = [ + "indexmap", + "itoa", + "ryu", + "serde", + "unsafe-libyaml", +] + +[[package]] +name = "sha1" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "digest 0.10.7", +] + +[[package]] +name = "sha1" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aacc4cc499359472b4abe1bf11d0b12e688af9a805fa5e3016f9a386dc2d0214" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.0", + "digest 0.11.3", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "digest 0.10.7", +] + +[[package]] +name = "sha2" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.0", + "digest 0.11.3", +] + +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "signature" +version = "1.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "74233d3b3b2f6d4b006dc19dee745e73e2a6bfb6f93607cd3b02bd5b00797d7c" +dependencies = [ + "digest 0.10.7", + "rand_core", +] + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" + +[[package]] +name = "socket2" +version = "0.5.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e22376abed350d73dd1cd119b57ffccad95b4e585a7cda43e286245ce23c0678" +dependencies = [ + "libc", + "windows-sys 0.52.0", +] + +[[package]] +name = "socket2" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "spin" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d5fe4ccb98d9c292d56fec89a5e07da7fc4cf0dc11e156b41793132775d3e591" + +[[package]] +name = "spki" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67cf02bbac7a337dc36e4f5a693db6c21e7863f45070f7064577eb4367a3212b" +dependencies = [ + "base64ct", + "der", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "syn" +version = "2.0.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "time" +version = "0.3.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c" +dependencies = [ + "deranged", + "num-conv", + "powerfmt", + "serde_core", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca" + +[[package]] +name = "time-macros" +version = "0.2.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e70e4c5a0e0a8a4823ad65dfe1a6930e4f4d756dcd9dd7939022b5e8c501215" +dependencies = [ + "num-conv", + "time-core", +] + +[[package]] +name = "tinystr" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tokio" +version = "1.52.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe" +dependencies = [ + "bytes", + "libc", + "mio", + "parking_lot", + "pin-project-lite", + "signal-hook-registry", + "socket2 0.6.3", + "tokio-macros", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-macros" +version = "2.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tokio-rustls" +version = "0.24.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c28327cf380ac148141087fbfb9de9d7bd4e84ab5d2c28fbc911d753de8a7081" +dependencies = [ + "rustls 0.21.12", + "tokio", +] + +[[package]] +name = "tokio-rustls" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" +dependencies = [ + "rustls 0.23.40", + "tokio", +] + +[[package]] +name = "tokio-util" +version = "0.7.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "toml" +version = "0.8.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362" +dependencies = [ + "serde", + "serde_spanned", + "toml_datetime", + "toml_edit", +] + +[[package]] +name = "toml_datetime" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c" +dependencies = [ + "serde", +] + +[[package]] +name = "toml_edit" +version = "0.22.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" +dependencies = [ + "indexmap", + "serde", + "serde_spanned", + "toml_datetime", + "toml_write", + "winnow", +] + +[[package]] +name = "toml_write" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" + +[[package]] +name = "tower" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +dependencies = [ + "tower-layer", + "tower-service", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", +] + +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + +[[package]] +name = "typenum" +version = "1.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40ce102ab67701b8526c123c1bab5cbe42d7040ccfd0f64af1a385808d2f43de" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unsafe-libyaml" +version = "0.2.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861" + +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + +[[package]] +name = "urlencoding" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da" + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + +[[package]] +name = "uuid" +version = "1.23.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd74a9687298c6858e9b88ec8935ec45d22e8fd5e6394fa1bd4e99a87789c76" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "vsimd" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c3082ca00d5a5ef149bb8b555a72ae84c9c59f7250f013ac822ac2e49b19c64" + +[[package]] +name = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.3+wasi-0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20064672db26d7cdc89c7798c48a0fdfac8213434a1186e5ef29fd560ae223d6" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.121" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49ace1d07c165b0864824eee619580c4689389afa9dc9ed3a4c75040d82e6790" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.121" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e68e6f4afd367a562002c05637acb8578ff2dea1943df76afb9e83d177c8578" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.121" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d95a9ec35c64b2a7cb35d3fead40c4238d0940c86d107136999567a4703259f2" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.121" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4e0100b01e9f0d03189a92b96772a1fb998639d981193d7dbab487302513441" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "winnow" +version = "0.7.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" +dependencies = [ + "memchr", +] + +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + +[[package]] +name = "writeable" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" + +[[package]] +name = "xmlparser" +version = "0.13.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66fee0b777b0f5ac1c69bb06d361268faafa61cd4682ae064a171c16c433e9e4" + +[[package]] +name = "yoke" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "abe8c5fda708d9ca3df187cae8bfb9ceda00dd96231bed36e445a1a48e66f9ca" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zerofrom" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zeroize" +version = "1.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" + +[[package]] +name = "zerotrie" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] diff --git a/operator/Cargo.toml b/operator/Cargo.toml new file mode 100644 index 000000000..820540a28 --- /dev/null +++ b/operator/Cargo.toml @@ -0,0 +1,21 @@ +[package] +name = "oabctl" +version = "0.1.0" +edition = "2021" +description = "CLI provisioner for OAB agents on ECS" + +[[bin]] +name = "oabctl" +path = "src/main.rs" + +[dependencies] +aws-config = "1.5" +aws-sdk-ecs = "1.53" +aws-sdk-s3 = "1.65" +aws-sdk-ssm = "1.52" +clap = { version = "4.5", features = ["derive"] } +serde = { version = "1.0", features = ["derive"] } +serde_yaml = "0.9" +tokio = { version = "1.40", features = ["full"] } +toml = "0.8" +anyhow = "1.0" diff --git a/operator/entrypoint.sh b/operator/entrypoint.sh new file mode 100755 index 000000000..f93dc039f --- /dev/null +++ b/operator/entrypoint.sh @@ -0,0 +1,23 @@ +#!/bin/bash +set -euo pipefail + +# OAB ECS Entrypoint Wrapper +# Downloads bootstrap archive and rendered config before starting OAB. + +# 1. Restore bootstrap (mutable state: memory, knowledge base) +if [ -n "${BOOTSTRAP_FROM:-}" ]; then + echo "[entrypoint] Restoring bootstrap from ${BOOTSTRAP_FROM}..." + aws s3 cp "${BOOTSTRAP_FROM}" /tmp/bootstrap.tar.gz + tar xzf /tmp/bootstrap.tar.gz -C "$HOME" + rm -f /tmp/bootstrap.tar.gz +fi + +# 2. Overwrite with rendered config (AFTER bootstrap, so desired config wins) +if [ -n "${CONFIG_S3_PATH:-}" ]; then + echo "[entrypoint] Downloading config from ${CONFIG_S3_PATH}..." + aws s3 cp "${CONFIG_S3_PATH}" "$HOME/config.toml" +fi + +# 3. Start OAB (DISCORD_TOKEN etc injected via ECS secrets) +echo "[entrypoint] Starting OpenAB..." +exec /usr/bin/openab "$@" diff --git a/operator/src/apply.rs b/operator/src/apply.rs new file mode 100644 index 000000000..868a1b4f1 --- /dev/null +++ b/operator/src/apply.rs @@ -0,0 +1,277 @@ +use crate::manifest::OABServiceManifest; +use anyhow::{Context, Result}; +use aws_sdk_ecs::types::{ + AssignPublicIp, AwsVpcConfiguration, CapacityProviderStrategyItem, ContainerDefinition, + KeyValuePair, NetworkConfiguration, Secret, +}; +use aws_sdk_s3::primitives::ByteStream; +use std::path::Path; + +pub async fn run(aws_config: &aws_config::SdkConfig, file_path: &str) -> Result<()> { + let path = Path::new(file_path); + let manifests = load_manifests(path)?; + + if manifests.is_empty() { + anyhow::bail!("no manifests found at {}", file_path); + } + + let ecs = aws_sdk_ecs::Client::new(aws_config); + let s3 = aws_sdk_s3::Client::new(aws_config); + + for m in &manifests { + m.validate()?; + println!(" Applying {}...", m.metadata.name); + apply_one(&ecs, &s3, m).await?; + } + + println!("\n{} service(s) applied.", manifests.len()); + Ok(()) +} + +fn load_manifests(path: &Path) -> Result> { + let mut manifests = Vec::new(); + if path.is_dir() { + for entry in std::fs::read_dir(path)? { + let entry = entry?; + let p = entry.path(); + if p.extension().is_some_and(|e| e == "yaml" || e == "yml") { + manifests.push(parse_manifest(&p)?); + } + } + } else { + manifests.push(parse_manifest(path)?); + } + Ok(manifests) +} + +fn parse_manifest(path: &Path) -> Result { + let content = std::fs::read_to_string(path) + .with_context(|| format!("failed to read {}", path.display()))?; + serde_yaml::from_str(&content) + .with_context(|| format!("failed to parse {}", path.display())) +} + +async fn apply_one( + ecs: &aws_sdk_ecs::Client, + s3: &aws_sdk_s3::Client, + m: &OABServiceManifest, +) -> Result<()> { + let service_name = m.ecs_service_name(); + let bucket = "oab-control-plane"; + + // Read current generation from S3 manifest (if exists), increment + let manifest_key = format!("manifests/{}/{}.yaml", m.metadata.namespace, m.metadata.name); + let current_gen = match s3.get_object().bucket(bucket).key(&manifest_key).send().await { + Ok(resp) => { + let bytes = resp.body.collect().await?.into_bytes(); + let existing: OABServiceManifest = serde_yaml::from_slice(&bytes)?; + existing.metadata.generation + } + Err(_) => 0, + }; + let generation = current_gen + 1; + + // 1. Render config.toml and upload to S3 (immutable path) + let config_toml = render_config_toml(&m.spec.config); + let config_key = format!( + "config/{}/{}/{}/config.toml", + m.metadata.namespace, m.metadata.name, generation + ); + s3.put_object() + .bucket(bucket) + .key(&config_key) + .body(ByteStream::from(config_toml.into_bytes())) + .send() + .await + .context("failed to upload config to S3")?; + + // 2. Upload manifest to S3 (record of desired state, with updated generation) + let mut manifest_to_store = serde_yaml::to_value(m)?; + manifest_to_store["metadata"]["generation"] = serde_yaml::Value::Number(generation.into()); + let manifest_yaml = serde_yaml::to_string(&manifest_to_store)?; + let manifest_key = format!("manifests/{}/{}.yaml", m.metadata.namespace, m.metadata.name); + s3.put_object() + .bucket(bucket) + .key(&manifest_key) + .body(ByteStream::from(manifest_yaml.into_bytes())) + .send() + .await + .context("failed to upload manifest to S3")?; + + // 3. Register task definition + let config_s3_path = format!("s3://{}/{}", bucket, config_key); + let task_def_family = service_name.clone(); + + let mut env_vars = vec![ + KeyValuePair::builder() + .name("NAMESPACE") + .value(&m.metadata.namespace) + .build(), + KeyValuePair::builder() + .name("NAME") + .value(&m.metadata.name) + .build(), + KeyValuePair::builder() + .name("CONFIG_S3_PATH") + .value(&config_s3_path) + .build(), + ]; + if let Some(ref bootstrap) = m.spec.bootstrap_from { + env_vars.push( + KeyValuePair::builder() + .name("BOOTSTRAP_FROM") + .value(bootstrap) + .build(), + ); + } + + let secrets: Vec = m + .spec + .secrets + .iter() + .map(|s| { + Secret::builder() + .name(&s.name) + .value_from(&s.value_from) + .build() + .unwrap() + }) + .collect(); + + let container = ContainerDefinition::builder() + .name("openab") + .image(&m.spec.task_definition.image) + .essential(true) + .set_environment(Some(env_vars)) + .set_secrets(if secrets.is_empty() { None } else { Some(secrets) }) + .build(); + + let task_def = ecs + .register_task_definition() + .family(&task_def_family) + .requires_compatibilities(aws_sdk_ecs::types::Compatibility::Fargate) + .network_mode(aws_sdk_ecs::types::NetworkMode::Awsvpc) + .cpu(m.spec.cpu.to_string()) + .memory(m.spec.memory.to_string()) + .container_definitions(container) + .send() + .await + .context("failed to register task definition")?; + + let task_def_arn = task_def + .task_definition() + .and_then(|td| td.task_definition_arn()) + .unwrap_or_default() + .to_string(); + + // 4. Create or update ECS service + let assign_ip = if m.spec.networking.assign_public_ip { + AssignPublicIp::Enabled + } else { + AssignPublicIp::Disabled + }; + + let vpc_config = AwsVpcConfiguration::builder() + .set_subnets(Some(m.spec.networking.subnets.clone())) + .set_security_groups(Some(m.spec.networking.security_groups.clone())) + .assign_public_ip(assign_ip) + .build()?; + + let network_config = NetworkConfiguration::builder() + .awsvpc_configuration(vpc_config) + .build(); + + // Check if service exists + let existing = ecs + .describe_services() + .cluster("default") + .services(&service_name) + .send() + .await; + + let service_active = existing + .as_ref() + .ok() + .and_then(|r| r.services().first()) + .is_some_and(|s| s.status() == Some("ACTIVE")); + + if service_active { + // Update existing service + ecs.update_service() + .cluster("default") + .service(&service_name) + .task_definition(&task_def_arn) + .network_configuration(network_config) + .send() + .await + .context("failed to update ECS service")?; + println!(" ✓ {} updated", m.metadata.name); + } else { + // Create new service + let cap_strategy = CapacityProviderStrategyItem::builder() + .capacity_provider(&m.spec.capacity_provider) + .weight(1) + .build()?; + + ecs.create_service() + .cluster("default") + .service_name(&service_name) + .task_definition(&task_def_arn) + .desired_count(1) + .capacity_provider_strategy(cap_strategy) + .network_configuration(network_config) + .send() + .await + .context("failed to create ECS service")?; + println!( + " ✓ {} created ({}, {}cpu/{}mem)", + m.metadata.name, m.spec.capacity_provider, m.spec.cpu, m.spec.memory + ); + } + + Ok(()) +} + +fn render_config_toml(config: &crate::manifest::AgentConfig) -> String { + let mut out = String::new(); + + if let Some(ref backend) = config.backend { + out.push_str("[backend]\n"); + out.push_str(&format!("type = \"{}\"\n", backend.backend_type)); + if let Some(ref model) = backend.model_id { + out.push_str(&format!("model_id = \"{}\"\n", model)); + } + if let Some(ref region) = backend.region { + out.push_str(&format!("region = \"{}\"\n", region)); + } + out.push('\n'); + } + + for (i, ch) in config.channels.iter().enumerate() { + out.push_str("[[channels]]\n"); + out.push_str(&format!("type = \"{}\"\n", ch.channel_type)); + for (k, v) in &ch.extra { + if let serde_yaml::Value::String(s) = v { + out.push_str(&format!("{} = \"{}\"\n", k, s)); + } + } + if i < config.channels.len() - 1 { + out.push('\n'); + } + } + + if let Some(ref steering) = config.steering { + out.push_str("\n[steering]\n"); + if let Some(ref prompt) = steering.system_prompt { + out.push_str(&format!("system_prompt = \"\"\"\n{}\n\"\"\"\n", prompt)); + } + } + + if let Some(ref features) = config.features { + out.push_str("\n[features]\n"); + out.push_str(&format!("stt = {}\n", features.stt)); + out.push_str(&format!("cronjob = {}\n", features.cronjob)); + } + + out +} diff --git a/operator/src/delete.rs b/operator/src/delete.rs new file mode 100644 index 000000000..199049fd9 --- /dev/null +++ b/operator/src/delete.rs @@ -0,0 +1,70 @@ +use anyhow::{Context, Result}; + +pub async fn run( + aws_config: &aws_config::SdkConfig, + resource: &str, + name: &str, + cluster: &str, + namespace: &str, +) -> Result<()> { + if resource != "oabservice" { + anyhow::bail!("unknown resource type: {}. Use 'oabservice'", resource); + } + + let service_name = format!("oab-{}-{}", namespace, name); + let ecs = aws_sdk_ecs::Client::new(aws_config); + let s3 = aws_sdk_s3::Client::new(aws_config); + let bucket = "oab-control-plane"; + + println!("Deleting {}...", name); + + // 1. Scale to 0 + let _ = ecs + .update_service() + .cluster(cluster) + .service(&service_name) + .desired_count(0) + .send() + .await; + println!(" ✓ Scaled to 0"); + + // 2. Delete ECS service + ecs.delete_service() + .cluster(cluster) + .service(&service_name) + .force(true) + .send() + .await + .context("failed to delete ECS service")?; + println!(" ✓ ECS service deleted"); + + // 3. Clean up S3 manifest + let manifest_key = format!("manifests/{}/{}.yaml", namespace, name); + let _ = s3 + .delete_object() + .bucket(bucket) + .key(&manifest_key) + .send() + .await; + println!(" ✓ Manifest removed from S3"); + + // 4. Clean up S3 config (list and delete all generations) + let config_prefix = format!("config/{}/{}/", namespace, name); + let list = s3 + .list_objects_v2() + .bucket(bucket) + .prefix(&config_prefix) + .send() + .await; + if let Ok(resp) = list { + for obj in resp.contents() { + if let Some(key) = obj.key() { + let _ = s3.delete_object().bucket(bucket).key(key).send().await; + } + } + } + println!(" ✓ Config artifacts removed from S3"); + + println!("\n✓ {} deleted", name); + Ok(()) +} diff --git a/operator/src/get.rs b/operator/src/get.rs new file mode 100644 index 000000000..0011e8378 --- /dev/null +++ b/operator/src/get.rs @@ -0,0 +1,114 @@ +use anyhow::{Context, Result}; + +pub async fn run( + aws_config: &aws_config::SdkConfig, + resource: &str, + name: Option<&str>, + cluster: &str, +) -> Result<()> { + if resource != "oabservice" { + anyhow::bail!("unknown resource type: {}. Use 'oabservice'", resource); + } + + let ecs = aws_sdk_ecs::Client::new(aws_config); + + let services = if let Some(name) = name { + // Describe a specific service + let svc_name = if name.starts_with("oab-") { + name.to_string() + } else { + // Try to find by listing all oab- services and matching the name suffix + format!("oab-prod-{}", name) // TODO: support --namespace flag + }; + vec![svc_name] + } else { + // List all oab- services + let mut service_arns = Vec::new(); + let mut next_token = None; + loop { + let mut req = ecs.list_services().cluster(cluster); + if let Some(token) = &next_token { + req = req.next_token(token); + } + let resp = req.send().await.context("failed to list ECS services")?; + for arn in resp.service_arns() { + if arn.contains("/oab-") { + service_arns.push(arn.to_string()); + } + } + next_token = resp.next_token().map(|s| s.to_string()); + if next_token.is_none() { + break; + } + } + service_arns + }; + + if services.is_empty() { + println!("No OAB services found."); + return Ok(()); + } + + // Describe in batches of 10 + println!( + "{:<12} {:<10} {:<5} {:<6} {:<14} {:<6} STATUS", + "NAME", "NAMESPACE", "CPU", "MEM", "CAPACITY", "TASKS" + ); + + for chunk in services.chunks(10) { + let resp = ecs + .describe_services() + .cluster(cluster) + .set_services(Some(chunk.to_vec())) + .send() + .await + .context("failed to describe ECS services")?; + + for svc in resp.services() { + let svc_name = svc.service_name().unwrap_or("-"); + // Parse oab-{namespace}-{name} + let parts: Vec<&str> = svc_name.splitn(3, '-').collect(); + let (namespace, agent_name) = if parts.len() == 3 { + (parts[1], parts[2]) + } else { + ("?", svc_name) + }; + + let status = svc.status().unwrap_or("UNKNOWN"); + let running = svc.running_count(); + let desired = svc.desired_count(); + + // Get cpu/memory from task definition + let (cpu, mem) = if let Some(td_arn) = svc.task_definition() { + let td_resp = ecs + .describe_task_definition() + .task_definition(td_arn) + .send() + .await; + if let Ok(td) = td_resp { + let td = td.task_definition(); + let cpu = td.and_then(|t| t.cpu()).unwrap_or("-"); + let mem = td.and_then(|t| t.memory()).unwrap_or("-"); + (cpu.to_string(), mem.to_string()) + } else { + ("-".to_string(), "-".to_string()) + } + } else { + ("-".to_string(), "-".to_string()) + }; + + let cap = svc + .capacity_provider_strategy() + .first() + .map(|c| c.capacity_provider()) + .unwrap_or("FARGATE"); + + println!( + "{:<12} {:<10} {:<5} {:<6} {:<14} {}/{:<3} {}", + agent_name, namespace, cpu, mem, cap, running, desired, status + ); + } + } + + Ok(()) +} diff --git a/operator/src/main.rs b/operator/src/main.rs new file mode 100644 index 000000000..878473094 --- /dev/null +++ b/operator/src/main.rs @@ -0,0 +1,60 @@ +mod manifest; +mod apply; +mod get; +mod delete; + +use clap::{Parser, Subcommand}; + +#[derive(Parser)] +#[command(name = "oabctl", about = "OAB agent provisioner for ECS")] +struct Cli { + #[command(subcommand)] + command: Commands, +} + +#[derive(Subcommand)] +enum Commands { + /// Create or update OAB services from manifest files + Apply { + /// Path to manifest file or directory + #[arg(short, long)] + file: String, + }, + /// List OAB services and their status + Get { + /// Resource type + resource: String, + /// Optional resource name + name: Option, + /// ECS cluster name + #[arg(long, default_value = "default")] + cluster: String, + }, + /// Delete an OAB service + Delete { + /// Resource type + resource: String, + /// Resource name + name: String, + /// ECS cluster name + #[arg(long, default_value = "default")] + cluster: String, + /// Namespace + #[arg(long, default_value = "prod")] + namespace: String, + }, +} + +#[tokio::main] +async fn main() -> anyhow::Result<()> { + let cli = Cli::parse(); + let config = aws_config::load_defaults(aws_config::BehaviorVersion::latest()).await; + + match cli.command { + Commands::Apply { file } => apply::run(&config, &file).await, + Commands::Get { resource, name, cluster } => get::run(&config, &resource, name.as_deref(), &cluster).await, + Commands::Delete { resource, name, cluster, namespace } => { + delete::run(&config, &resource, &name, &cluster, &namespace).await + } + } +} diff --git a/operator/src/manifest.rs b/operator/src/manifest.rs new file mode 100644 index 000000000..fd6efb1b1 --- /dev/null +++ b/operator/src/manifest.rs @@ -0,0 +1,135 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct OABServiceManifest { + pub api_version: String, + pub kind: String, + pub metadata: Metadata, + pub spec: Spec, +} + +#[derive(Debug, Deserialize, Serialize)] +pub struct Metadata { + pub name: String, + pub namespace: String, + #[serde(default)] + pub generation: u64, +} + +#[derive(Debug, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct Spec { + #[serde(default = "default_capacity_provider")] + pub capacity_provider: String, + pub cpu: i32, + pub memory: i32, + pub task_definition: TaskDefinition, + #[serde(default)] + pub bootstrap_from: Option, + pub networking: Networking, + pub config: AgentConfig, + #[serde(default)] + pub secrets: Vec, +} + +#[derive(Debug, Deserialize, Serialize)] +pub struct TaskDefinition { + pub image: String, +} + +#[derive(Debug, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct Networking { + pub subnets: Vec, + pub security_groups: Vec, + #[serde(default)] + pub assign_public_ip: bool, +} + +#[derive(Debug, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct SecretRef { + pub name: String, + pub value_from: String, +} + +#[derive(Debug, Deserialize, Serialize)] +pub struct AgentConfig { + #[serde(default)] + pub channels: Vec, + #[serde(default)] + pub backend: Option, + #[serde(default)] + pub steering: Option, + #[serde(default)] + pub features: Option, +} + +#[derive(Debug, Deserialize, Serialize)] +pub struct ChannelConfig { + #[serde(rename = "type")] + pub channel_type: String, + #[serde(flatten)] + pub extra: std::collections::HashMap, +} + +#[derive(Debug, Deserialize, Serialize)] +pub struct BackendConfig { + #[serde(rename = "type")] + pub backend_type: String, + #[serde(default)] + pub model_id: Option, + #[serde(default)] + pub region: Option, +} + +#[derive(Debug, Deserialize, Serialize)] +pub struct SteeringConfig { + #[serde(default)] + pub system_prompt: Option, +} + +#[derive(Debug, Deserialize, Serialize)] +pub struct FeaturesConfig { + #[serde(default)] + pub stt: bool, + #[serde(default)] + pub cronjob: bool, +} + +fn default_capacity_provider() -> String { + "FARGATE".to_string() +} + +impl OABServiceManifest { + pub fn validate(&self) -> anyhow::Result<()> { + if self.api_version != "oab.dev/v1" { + anyhow::bail!("unsupported apiVersion: {}", self.api_version); + } + if self.kind != "OABService" { + anyhow::bail!("unsupported kind: {}", self.kind); + } + if self.metadata.name.is_empty() { + anyhow::bail!("metadata.name is required"); + } + if self.metadata.namespace.is_empty() { + anyhow::bail!("metadata.namespace is required"); + } + let valid_cp = ["FARGATE", "FARGATE_SPOT"]; + if !valid_cp.contains(&self.spec.capacity_provider.as_str()) { + anyhow::bail!("capacityProvider must be FARGATE or FARGATE_SPOT"); + } + if self.spec.networking.subnets.is_empty() { + anyhow::bail!("networking.subnets must not be empty"); + } + if self.spec.networking.security_groups.is_empty() { + anyhow::bail!("networking.securityGroups must not be empty"); + } + Ok(()) + } + + pub fn ecs_service_name(&self) -> String { + format!("oab-{}-{}", self.metadata.namespace, self.metadata.name) + } +} From 8f709594e38ed4c877c7380c94f28060fc1b6812 Mon Sep 17 00:00:00 2001 From: chaodufashi Date: Mon, 18 May 2026 23:41:36 -0400 Subject: [PATCH 047/100] docs(operator): add README and sample manifest (#852) Co-authored-by: Kiro --- operator/README.md | 83 ++++++++++++++++++++++++++++++++++ operator/examples/kiro-01.yaml | 31 +++++++++++++ 2 files changed, 114 insertions(+) create mode 100644 operator/README.md create mode 100644 operator/examples/kiro-01.yaml diff --git a/operator/README.md b/operator/README.md new file mode 100644 index 000000000..5c0ce7334 --- /dev/null +++ b/operator/README.md @@ -0,0 +1,83 @@ +# oabctl — OAB Agent Provisioner for ECS + +CLI tool that provisions and manages OpenAB agents on Amazon ECS Fargate. + +## Quick Start + +```bash +# Build +cd operator && cargo build --release + +# Deploy an agent +oabctl apply -f examples/kiro-01.yaml + +# List running agents +oabctl get oabservice + +# Delete an agent +oabctl delete oabservice kiro-01 --cluster default --namespace prod +``` + +## Prerequisites + +1. **AWS credentials** — IAM role/profile with permissions for ECS, SSM, S3 +2. **S3 bucket** — `oab-control-plane` (manifests + rendered config) +3. **ECS cluster** — default cluster or specify with `--cluster` +4. **VPC** — subnets + security groups for Fargate tasks +5. **ECR image** — OAB container image pushed to ECR +6. **SSM parameters** — bot tokens stored at `/oab/{namespace}/{name}/discord-token` + +## Manifest Schema + +```yaml +apiVersion: oab.dev/v1 +kind: OABService +metadata: + name: kiro-01 + namespace: prod +spec: + capacityProvider: FARGATE_SPOT # FARGATE or FARGATE_SPOT + cpu: 256 # vCPU units + memory: 512 # MB + taskDefinition: + image: + bootstrapFrom: s3://... # agent HOME archive (memory, state) + networking: + subnets: [subnet-xxx] + securityGroups: [sg-xxx] + secrets: + - name: DISCORD_TOKEN + valueFrom: /oab/prod/kiro-01/discord-token + config: + channels: + - type: discord + backend: + type: bedrock + model_id: anthropic.claude-sonnet-4-20250514 + region: us-east-1 + steering: + system_prompt: "..." + features: + stt: false + cronjob: true +``` + +## Commands + +| Command | Description | +|---------|-------------| +| `oabctl apply -f ` | Create or update agents from manifests | +| `oabctl get oabservice [name]` | List agents and their ECS status | +| `oabctl delete oabservice ` | Teardown agent (ECS + S3 cleanup) | + +## How It Works + +1. `oabctl apply` validates the manifest, renders `config.toml` from `spec.config`, uploads to S3 at an immutable path (`config/{ns}/{name}/{generation}/`), registers an ECS task definition, and creates/updates the ECS service. + +2. ECS maintains the desired state — restarts failed tasks, handles rolling deployments. No separate controller needed. + +3. On task startup, `entrypoint.sh` downloads the bootstrap archive and rendered config from S3, then starts OpenAB. + +## Architecture + +See [ADR: ECS Control Plane](../docs/adr/ecs-control-plane.md) for the full design. diff --git a/operator/examples/kiro-01.yaml b/operator/examples/kiro-01.yaml new file mode 100644 index 000000000..6a969c7d0 --- /dev/null +++ b/operator/examples/kiro-01.yaml @@ -0,0 +1,31 @@ +apiVersion: oab.dev/v1 +kind: OABService +metadata: + name: kiro-01 + namespace: prod +spec: + capacityProvider: FARGATE_SPOT + cpu: 256 + memory: 512 + taskDefinition: + image: 123456789.dkr.ecr.us-east-1.amazonaws.com/openab:latest + bootstrapFrom: s3://oab-backups/agents/kiro-01/latest.tar.gz + networking: + subnets: [subnet-aaa, subnet-bbb] + securityGroups: [sg-oab] + assignPublicIp: false + secrets: + - name: DISCORD_TOKEN + valueFrom: /oab/prod/kiro-01/discord-token + config: + channels: + - type: discord + backend: + type: bedrock + model_id: anthropic.claude-sonnet-4-20250514 + region: us-east-1 + steering: + system_prompt: "You are Kiro, an AI agent running on OpenAB." + features: + stt: false + cronjob: true From 8cf6ac8c51687b2c58d53b6b0871bee665f6b26c Mon Sep 17 00:00:00 2001 From: rockexe0000 Date: Tue, 19 May 2026 21:02:27 +0800 Subject: [PATCH 048/100] fix: raise bot turn tracker hard cap to 1000 (#855) --- src/bot_turns.rs | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/src/bot_turns.rs b/src/bot_turns.rs index 92ff7e728..c69971f8e 100644 --- a/src/bot_turns.rs +++ b/src/bot_turns.rs @@ -11,7 +11,7 @@ use std::collections::HashMap; /// A human message resets both soft and hard counters to 0, allowing bots to /// resume. This is *not* a lifetime total — it guards against runaway loops /// between human resets. -pub const HARD_BOT_TURN_LIMIT: u32 = 100; +pub const HARD_BOT_TURN_LIMIT: u32 = 1000; #[derive(Debug, PartialEq, Eq)] pub enum TurnResult { @@ -161,6 +161,14 @@ mod tests { assert_eq!(t.on_bot_message("t1"), TurnResult::HardLimit); } + #[test] + fn hard_limit_does_not_fire_at_legacy_100() { + let mut t = BotTurnTracker::new(HARD_BOT_TURN_LIMIT + 1); + for i in 1..=100 { + assert_eq!(t.on_bot_message("t1"), TurnResult::Ok, "turn {i}"); + } + } + #[test] fn hard_limit_resets_on_human() { let mut t = BotTurnTracker::new(HARD_BOT_TURN_LIMIT + 1); From abef26dc6dce58769bf95549c9d268accc8310fa Mon Sep 17 00:00:00 2001 From: chaodufashi Date: Tue, 19 May 2026 14:31:47 -0400 Subject: [PATCH 049/100] =?UTF-8?q?feat(gateway):=20media=20proxy=20coloca?= =?UTF-8?q?te=20mode=20=E2=80=94=20filesystem=20store=20replaces=20base64?= =?UTF-8?q?=20inline=20(#858)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(gateway): media proxy colocate mode — store to filesystem, deprecate base64 inline Replace base64-over-WebSocket media transport with local filesystem store. Gateway downloads media from platform APIs and writes to ~/.openab/media/inbound/, passing the file path to Core via the WS event. Core reads bytes directly from disk — zero encoding overhead, no WS payload bloat. Key changes: - gateway/src/store.rs: file store with 2-min TTL eviction (OpenClaw pattern) - gateway/src/media.rs: shared image resize/compress + MediaKind enum - gateway/src/schema.rs: Attachment gains optional 'path' field - gateway/src/adapters/telegram.rs: inbound photo/voice/audio/document support - src/gateway.rs: Core reads from path (colocate) with base64 fallback Security: UUID-only filenames (no path traversal), platform tokens never reach Core, TTL auto-eviction prevents disk exhaustion, colocate trust boundary documented. Supersedes #757 (base64 inline approach). Closes #690. * fix(store): add defense-in-depth size guard (20MB max) Prevents future callers from accidentally writing unbounded files. Matches AUDIO_MAX_DOWNLOAD as the largest allowed media type. * refactor(gateway): migrate all adapters from base64 to filesystem store Feishu, Google Chat, and WeCom adapters now use store::store_media() instead of base64 encoding. All media flows through the same ~/.openab/media/inbound/ path — consistent across all platforms. No adapter left on base64 inline. * docs: update telegram/feishu/google-chat docs for filesystem media store Replace base64 references with filesystem store description. Add Telegram inbound media section (images, documents, audio/voice). * docs: add inbound-attachments.md — unified cross-platform media reference Covers architecture, platform support matrix, processing pipeline, size limits, storage security, and future HTTP proxy roadmap. * fix: raise FILE_MAX_DOWNLOAD to 20MB — filesystem store removes size pressure With colocate mode, files go to disk not WS payload. The 512KB limit was a base64-era constraint. Now unified at 20MB (same as store cap). Core decides how much to read/truncate. --------- Co-authored-by: chaodu-agent --- Cargo.lock | 1 + docs/feishu.md | 6 +- docs/google-chat.md | 2 +- docs/inbound-attachments.md | 100 +++++++++++++ docs/telegram.md | 13 ++ gateway/src/adapters/feishu.rs | 19 ++- gateway/src/adapters/googlechat.rs | 22 +-- gateway/src/adapters/telegram.rs | 231 ++++++++++++++++++++++++++++- gateway/src/adapters/wecom.rs | 12 +- gateway/src/main.rs | 13 ++ gateway/src/media.rs | 123 +++++++++++++++ gateway/src/schema.rs | 14 +- gateway/src/store.rs | 132 +++++++++++++++++ src/gateway.rs | 43 ++++-- 14 files changed, 682 insertions(+), 49 deletions(-) create mode 100644 docs/inbound-attachments.md create mode 100644 gateway/src/media.rs create mode 100644 gateway/src/store.rs diff --git a/Cargo.lock b/Cargo.lock index 6950fc242..14dddd655 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -196,6 +196,7 @@ dependencies = [ "iana-time-zone", "js-sys", "num-traits", + "serde", "wasm-bindgen", "windows-link", ] diff --git a/docs/feishu.md b/docs/feishu.md index ef53f4a8a..15e344f12 100644 --- a/docs/feishu.md +++ b/docs/feishu.md @@ -165,14 +165,14 @@ The gateway downloads and forwards image and text file attachments to the AI age | Feishu msg_type | Handling | |-----------------|----------| | `text` | Text extracted, forwarded as prompt | -| `image` | Image downloaded, resized (max 1200px), JPEG compressed, base64 encoded → `ContentBlock::Image` | +| `image` | Image downloaded, resized (max 1200px), JPEG compressed, stored to `~/.openab/media/inbound/` → `ContentBlock::Image` | | `file` | Text files only (`.txt`, `.py`, `.rs`, `.md`, `.json`, etc., max 512KB). Non-text files (`.pdf`, `.zip`, etc.) are silently ignored. | -| `audio` | Voice message downloaded (opus/ogg, max 25MB), base64 encoded, forwarded to core. If `[stt]` is enabled, core transcribes via Whisper API and injects `[Voice message transcript]: ...` into the prompt. If STT is disabled or fails, the message is silently skipped. | +| `audio` | Voice message downloaded (opus/ogg, max 25MB), stored to filesystem, forwarded to core. If `[stt]` is enabled, core transcribes via Whisper API and injects `[Voice message transcript]: ...` into the prompt. If STT is disabled or fails, the message is silently skipped. | | `post` | Rich text: text nodes extracted as prompt, `img` nodes downloaded as image attachments. This is the format Feishu uses when @mention + paste image in a group. | **Group chat limitation:** Feishu does not allow @mention and image upload in the same message. However, @mention + paste (Ctrl+V) an image works — Feishu sends this as a `post` message containing both the mention and the image. Direct image upload (via the attachment button) cannot include @mention, so the bot will not respond in groups. -**Processing pipeline:** Gateway downloads media using `GET /im/v1/messages/{message_id}/resources/{key}?type=image` with `tenant_access_token`, resizes to max 1200px, compresses to JPEG (quality 75), base64 encodes, and embeds in the `GatewayEvent.content.attachments` field. OAB core decodes attachments into `ContentBlock::Image` or `ContentBlock::Text` for the AI agent. +**Processing pipeline:** Gateway downloads media using `GET /im/v1/messages/{message_id}/resources/{key}?type=image` with `tenant_access_token`, resizes to max 1200px, compresses to JPEG (quality 75), and stores to `~/.openab/media/inbound/`. The file path is passed in `GatewayEvent.content.attachments[].path`. OAB core reads the file directly from disk and converts to `ContentBlock::Image` or `ContentBlock::Text` for the AI agent. ## Streaming (Typewriter) diff --git a/docs/google-chat.md b/docs/google-chat.md index bcdc68c35..584ea450e 100644 --- a/docs/google-chat.md +++ b/docs/google-chat.md @@ -143,7 +143,7 @@ working_dir = "/home/agent" - Inline code, fenced code blocks: pass through unchanged - Tables and other unsupported syntax pass through as-is - **Streaming (edit_message)** — when OAB streaming is enabled, the bot edits its initial reply in-place as tokens arrive (typewriter effect) -- **Inbound attachments** — image, text file, and audio attachments are downloaded via Google Chat Media API and forwarded to the agent as base64 (PR #731 pattern): +- **Inbound attachments** — image, text file, and audio attachments are downloaded via Google Chat Media API and stored to `~/.openab/media/inbound/` (colocate filesystem store): - Images: resized to ≤1200px JPEG (q75); GIFs preserved. Max 10 MB. - Text files: only known text extensions (`.txt`, `.md`, `.json`, `.py`, `.rs`, etc.). Max 512 KB. - Audio: forwarded as-is for STT processing by core. Max 25 MB. diff --git a/docs/inbound-attachments.md b/docs/inbound-attachments.md new file mode 100644 index 000000000..47f3136bb --- /dev/null +++ b/docs/inbound-attachments.md @@ -0,0 +1,100 @@ +# Inbound Attachments + +How OAB handles images, audio, and files sent by users across all platforms. + +## Architecture + +``` +User sends media (photo/voice/file) + → Platform webhook delivers to Gateway + → Gateway downloads via platform API (auth stays in Gateway) + → Image: resize ≤1200px, JPEG compress (GIF passthrough ≤5MB) + → Store to ~/.openab/media/inbound/ + → WS event includes file path in attachments[].path + → Core reads from disk (zero encoding overhead) + → Processes: image → LLM, audio → STT, text_file → code block + → File auto-evicted after 2 minutes +``` + +## Platform Support Matrix + +| Platform | Images | Audio/Voice | Text Files | Video | Binary Files | +|----------|--------|-------------|------------|-------|--------------| +| **Discord** | ✅ | ✅ (STT) | ✅ | metadata only | skipped | +| **Telegram** | ✅ | ✅ (STT) | ✅ (whitelist) | skipped | skipped | +| **Feishu** | ✅ | ✅ (STT) | ✅ (whitelist) | skipped | skipped | +| **Google Chat** | ✅ | ✅ (STT) | ✅ (whitelist) | skipped | Drive files skipped | +| **WeCom** | ✅ | — | ✅ (whitelist) | skipped | skipped | +| **LINE** | planned | planned | — | — | — | +| **Slack** | ✅ | ✅ (STT) | ✅ | — | skipped | + +## Processing Pipeline + +### Images + +1. Gateway downloads from platform API +2. `resize_and_compress()` — longest side ≤1200px, JPEG quality 75 +3. GIFs ≤5MB passed through unchanged (preserves animation) +4. Stored to `~/.openab/media/inbound/` +5. Core reads bytes → `ContentBlock::Image` → sent to LLM + +### Audio / Voice Messages + +1. Gateway downloads raw audio (ogg/m4a/mp3) +2. Stored to filesystem (no transcoding) +3. Core reads bytes → STT transcription (Whisper/Groq) → `[Voice message transcript]: ...` +4. If STT disabled: silently skipped + +### Text Files (Documents) + +1. Gateway downloads file +2. Extension whitelist check: `.txt`, `.csv`, `.md`, `.json`, `.yaml`, `.rs`, `.py`, `.js`, `.ts`, `.go`, `.java`, `.c`, `.cpp`, `.sh`, `.sql`, `.html`, `.css`, `.toml`, `.xml`, `.ini`, `.cfg`, `.conf`, etc. +3. UTF-8 validation — non-UTF-8 files rejected +4. Stored to filesystem +5. Core reads → wraps in markdown code block: `` ```filename.ext\n\n``` `` + +### Unsupported Types + +Binary files (zip, pdf, exe, docx), video, and stickers are **silently skipped**. The agent does not receive any notification that a file was sent. + +## Size Limits + +| Type | Max Size | Enforced By | +|------|----------|-------------| +| Images | 10 MB | Gateway (pre-download Content-Length + post-download bytes) | +| Audio | 20 MB | Gateway | +| Text files | 20 MB | Gateway (same as store cap) | +| GIF passthrough | 5 MB | `resize_and_compress()` | +| Store (defense-in-depth) | 20 MB | `store_media()` | + +## Storage (Colocate Mode) + +Media is stored at `~/.openab/media/inbound/`: + +- **Filenames**: Server-generated UUID v4, no extension (MIME type in event payload) +- **TTL**: 2 minutes — background task evicts expired files every 30 seconds +- **Trust boundary**: Gateway and Core share the same `$HOME` (same pod / sidecar) +- **No auth required**: Core reads directly from filesystem, no HTTP/token needed + +### Security + +- **Path traversal**: Impossible — filenames are UUID only, never user-supplied +- **Token leakage**: Platform auth tokens (Telegram bot token, LINE access token, Feishu tenant token) stay in Gateway, never reach Core or agent +- **Disk exhaustion**: TTL eviction + size limits prevent unbounded growth +- **No executable content**: Files are raw data, never executed + +### Future: HTTP Proxy Mode + +For separated deployments (Gateway ≠ Core pod), a future PR will add `GET /media/` on the Gateway, allowing Core to fetch via internal HTTP. The `attachments[].path` field will be replaced by `attachments[].url` in that mode. + +## Configuration + +No additional configuration required. The filesystem store is always active when Gateway is running. Ensure Gateway and Core share the same `$HOME` (default in Helm colocate/sidecar mode). + +## Related + +- [Telegram](telegram.md) — Telegram-specific behavior and limitations +- [Feishu](feishu.md) — Feishu image/file/audio handling +- [Google Chat](google-chat.md) — Google Chat attachment support +- [STT (Speech-to-Text)](stt.md) — Audio transcription configuration +- [Sending Files (Outbound)](sendfiles.md) — Agent → user file delivery (separate mechanism) diff --git a/docs/telegram.md b/docs/telegram.md index d7dd9ae09..b1f576750 100644 --- a/docs/telegram.md +++ b/docs/telegram.md @@ -168,6 +168,19 @@ explain VPC peering ← ignored in groups DMs and replies within forum topics always trigger the agent (no @mention needed). +### File Attachments (Inbound) + +The gateway downloads media from Telegram and stores it locally (`~/.openab/media/inbound/`). Core reads directly from disk — no base64 encoding overhead. + +| Type | Handling | +|------|----------| +| **Images** | Downloaded, resized (max 1200px), JPEG compressed, stored to filesystem. Agent sees the image. | +| **Documents** | Text-based files (`.txt`, `.csv`, `.rs`, `.py`, etc.) up to 20MB read as UTF-8 and passed to agent. Binary files silently skipped. | +| **Audio/Voice** | Downloaded and stored. If STT is enabled in Core, automatically transcribed and passed as text. | + +**Not supported (inbound):** video, stickers, animations (silently skipped). +**Not supported (outbound):** bot cannot send images/files back to the user yet. + ### Emoji reactions The bot shows status reactions on your message as the agent works: diff --git a/gateway/src/adapters/feishu.rs b/gateway/src/adapters/feishu.rs index 0ed92c93b..75cd45dd5 100644 --- a/gateway/src/adapters/feishu.rs +++ b/gateway/src/adapters/feishu.rs @@ -1449,15 +1449,15 @@ pub async fn download_feishu_image( return None; } }; - use base64::Engine; - let data = base64::engine::general_purpose::STANDARD.encode(&compressed); + let path = crate::store::store_media(&compressed).await?; let ext = if mime == "image/gif" { "gif" } else { "jpg" }; Some(crate::schema::Attachment { attachment_type: "image".into(), filename: format!("{}.{}", image_key, ext), mime_type: mime, - data, + data: String::new(), size: compressed.len() as u64, + path: Some(path), }) } @@ -1511,15 +1511,14 @@ pub async fn download_feishu_file( tracing::warn!(file_name, size = bytes.len(), "feishu file exceeds 512KB limit"); return None; } - let text = String::from_utf8_lossy(&bytes); - use base64::Engine; - let data = base64::engine::general_purpose::STANDARD.encode(text.as_bytes()); + let path = crate::store::store_media(&bytes).await?; Some(crate::schema::Attachment { attachment_type: "text_file".into(), filename: file_name.to_string(), mime_type: "text/plain".into(), - data, + data: String::new(), size: bytes.len() as u64, + path: Some(path), }) } @@ -1569,14 +1568,14 @@ pub async fn download_feishu_audio( return None; } tracing::debug!(file_key, size = bytes.len(), "feishu audio downloaded"); - use base64::Engine; - let data = base64::engine::general_purpose::STANDARD.encode(&bytes); + let path = crate::store::store_media(&bytes).await?; Some(crate::schema::Attachment { attachment_type: "audio".into(), filename: format!("{}.ogg", file_key), mime_type: content_type, - data, + data: String::new(), size: bytes.len() as u64, + path: Some(path), }) } diff --git a/gateway/src/adapters/googlechat.rs b/gateway/src/adapters/googlechat.rs index 25ddd0f39..69542abf4 100644 --- a/gateway/src/adapters/googlechat.rs +++ b/gateway/src/adapters/googlechat.rs @@ -1257,14 +1257,14 @@ pub async fn download_googlechat_image( return None; } }; - use base64::Engine; - let data = base64::engine::general_purpose::STANDARD.encode(&compressed); + let path = crate::store::store_media(&compressed).await?; Some(crate::schema::Attachment { attachment_type: "image".into(), filename: content_name.to_string(), mime_type: mime, - data, + data: String::new(), size: compressed.len() as u64, + path: Some(path), }) } @@ -1309,18 +1309,18 @@ pub async fn download_googlechat_file( warn!(content_name, size = bytes.len(), limit = max_size, "googlechat file exceeds size limit"); return None; } - use base64::Engine; - let data = base64::engine::general_purpose::STANDARD.encode(&bytes); + let path = crate::store::store_media(&bytes).await?; Some(crate::schema::Attachment { attachment_type: "text_file".into(), filename: content_name.to_string(), mime_type: "text/plain".into(), - data, + data: String::new(), size: bytes.len() as u64, + path: Some(path), }) } -/// Download an audio attachment as-is (no resize/transcode) → base64. +/// Download an audio attachment as-is (no resize/transcode) → filesystem store. /// Core's STT pipeline (when available) consumes this as `audio` attachment_type. pub async fn download_googlechat_audio( client: &reqwest::Client, @@ -1355,14 +1355,14 @@ pub async fn download_googlechat_audio( warn!(content_name, size = bytes.len(), "googlechat audio exceeds 25MB limit"); return None; } - use base64::Engine; - let data = base64::engine::general_purpose::STANDARD.encode(&bytes); + let path = crate::store::store_media(&bytes).await?; Some(crate::schema::Attachment { attachment_type: "audio".into(), filename: content_name.to_string(), mime_type: content_type.to_string(), - data, + data: String::new(), size: bytes.len() as u64, + path: Some(path), }) } @@ -2315,7 +2315,7 @@ mod tests { assert_eq!(att.attachment_type, "image"); assert_eq!(att.filename, "photo.png"); assert_eq!(att.mime_type, "image/jpeg"); // resized PNG → JPEG - assert!(!att.data.is_empty()); + assert!(att.path.is_some()); // stored to filesystem assert!(att.size > 0); } diff --git a/gateway/src/adapters/telegram.rs b/gateway/src/adapters/telegram.rs index 6ae016246..ee5d08dd1 100644 --- a/gateway/src/adapters/telegram.rs +++ b/gateway/src/adapters/telegram.rs @@ -1,4 +1,6 @@ +use crate::media::{resize_and_compress, MediaKind, AUDIO_MAX_DOWNLOAD, FILE_MAX_DOWNLOAD, IMAGE_MAX_DOWNLOAD}; use crate::schema::*; +use crate::store; use axum::extract::State; use axum::Json; use serde::Deserialize; @@ -25,8 +27,46 @@ struct TelegramMessage { chat: TelegramChat, from: Option, text: Option, + caption: Option, #[serde(default)] entities: Vec, + #[serde(default)] + caption_entities: Vec, + #[serde(default)] + photo: Vec, + document: Option, + voice: Option, + audio: Option, +} + +#[derive(Debug, Deserialize)] +struct TelegramPhoto { + file_id: String, + width: u32, + height: u32, +} + +#[derive(Debug, Deserialize)] +struct TelegramDocument { + file_id: String, + file_name: Option, + mime_type: Option, +} + +#[derive(Debug, Deserialize)] +struct TelegramVoice { + file_id: String, + #[allow(dead_code)] // TODO: use for Content-Type hint + mime_type: Option, +} + +#[derive(Debug, Deserialize)] +struct TelegramAudio { + file_id: String, + #[allow(dead_code)] // TODO: use for filename + file_name: Option, + #[allow(dead_code)] // TODO: use for Content-Type hint + mime_type: Option, } #[derive(Debug, Deserialize)] @@ -75,13 +115,48 @@ pub async fn webhook( let Some(msg) = update.message else { return axum::http::StatusCode::OK; }; - let Some(text) = msg.text.as_deref() else { - return axum::http::StatusCode::OK; - }; - if text.trim().is_empty() { + let is_photo = !msg.photo.is_empty(); + let is_document = msg.document.is_some(); + let is_voice = msg.voice.is_some(); + let is_audio = msg.audio.is_some(); + let text = msg.text.as_deref().or(msg.caption.as_deref()).unwrap_or(""); + + if text.trim().is_empty() && !is_photo && !is_document && !is_voice && !is_audio { return axum::http::StatusCode::OK; } + let mut attachments = Vec::new(); + if is_photo || is_document || is_voice || is_audio { + if let Some(ref token) = state.telegram_bot_token { + let client = &state.client; + if is_photo { + if let Some(largest) = msg.photo.iter().max_by_key(|p| p.width * p.height) { + if let Some(att) = + download_telegram_media(client, token, &largest.file_id, MediaKind::Image).await + { + attachments.push(att); + } + } + } else if let Some(doc) = msg.document { + let file_name = doc.file_name.unwrap_or_else(|| "unknown.txt".to_string()); + let mime_type = doc.mime_type.unwrap_or_else(|| "text/plain".to_string()); + if let Some(att) = + download_telegram_document(client, token, &doc.file_id, &file_name, &mime_type).await + { + attachments.push(att); + } + } else if let Some(voice) = msg.voice { + if let Some(att) = download_telegram_media(client, token, &voice.file_id, MediaKind::Audio).await { + attachments.push(att); + } + } else if let Some(audio) = msg.audio { + if let Some(att) = download_telegram_media(client, token, &audio.file_id, MediaKind::Audio).await { + attachments.push(att); + } + } + } + } + let from = msg.from.as_ref(); let sender_name = from .and_then(|u| u.username.as_deref()) @@ -100,6 +175,7 @@ pub async fn webhook( let mentions: Vec = msg .entities .iter() + .chain(msg.caption_entities.iter()) .filter(|e| e.entity_type == "mention") .filter_map(|e| { text.get(e.offset..e.offset + e.length) @@ -107,7 +183,7 @@ pub async fn webhook( }) .collect(); - let event = GatewayEvent::new( + let mut event = GatewayEvent::new( "telegram", ChannelInfo { id: msg.chat.id.to_string(), @@ -124,6 +200,12 @@ pub async fn webhook( &msg.message_id.to_string(), mentions, ); + event.content.attachments = attachments; + + // Guard: skip empty events (no text + no attachments) + if event.content.text.trim().is_empty() && event.content.attachments.is_empty() { + return axum::http::StatusCode::OK; + } let json = serde_json::to_string(&event).unwrap(); info!(chat_id = %msg.chat.id, sender = %sender_name, "telegram → gateway"); @@ -262,3 +344,142 @@ pub async fn handle_reply( .await .map_err(|e| error!("telegram send error: {e}")); } + +/// Download media from Telegram via getFile → store to filesystem (colocate mode). +async fn download_telegram_media( + client: &reqwest::Client, + bot_token: &str, + file_id: &str, + kind: MediaKind, +) -> Option { + let get_file_url = format!("{TELEGRAM_API_BASE}/bot{}/getFile", bot_token); + let resp = client.get(&get_file_url).query(&[("file_id", file_id)]).send().await.ok()?; + let body: serde_json::Value = resp.json().await.ok()?; + let file_path = body["result"]["file_path"].as_str()?; + + let download_url = format!("{TELEGRAM_API_BASE}/file/bot{}/{}", bot_token, file_path); + let resp = client.get(&download_url).send().await.ok()?; + if !resp.status().is_success() { + return None; + } + + let max_size = match kind { + MediaKind::Image => IMAGE_MAX_DOWNLOAD, + MediaKind::Audio => AUDIO_MAX_DOWNLOAD, + }; + + if let Some(cl) = resp.headers().get(reqwest::header::CONTENT_LENGTH) { + if let Ok(size) = cl.to_str().unwrap_or("0").parse::() { + if size > max_size { + warn!(file_id, size, kind = ?kind, "Telegram media Content-Length exceeds limit"); + return None; + } + } + } + + let default_mime = match kind { + MediaKind::Image => "image/jpeg", + MediaKind::Audio => "audio/ogg", + }; + let content_type = resp + .headers() + .get(reqwest::header::CONTENT_TYPE) + .and_then(|h| h.to_str().ok()) + .unwrap_or(default_mime) + .to_string(); + + let bytes = resp.bytes().await.ok()?; + if bytes.len() as u64 > max_size { + warn!(file_id, size = bytes.len(), kind = ?kind, "Telegram media exceeds limit"); + return None; + } + + let (data_bytes, mime) = match kind { + MediaKind::Image => match resize_and_compress(&bytes) { + Ok((c, m)) => (c, m), + Err(e) => { + error!(err = %e, "Telegram image processing failed"); + return None; + } + }, + MediaKind::Audio => (bytes.to_vec(), content_type), + }; + + // Store to filesystem instead of base64 encoding + let path = store::store_media(&data_bytes).await?; + let att_type = match kind { + MediaKind::Image => "image", + MediaKind::Audio => "audio", + }; + info!(file_id, size = data_bytes.len(), kind = ?kind, "Telegram media stored"); + + Some(Attachment { + attachment_type: att_type.into(), + filename: format!("{}.{}", file_id, match kind { + MediaKind::Image => "jpg", + MediaKind::Audio => crate::media::audio_extension(&mime), + }), + mime_type: mime, + data: String::new(), // No base64 — using file path + size: data_bytes.len() as u64, + path: Some(path), + }) +} + +/// Download text document from Telegram → store to filesystem. +async fn download_telegram_document( + client: &reqwest::Client, + bot_token: &str, + file_id: &str, + file_name: &str, + mime_type: &str, +) -> Option { + if !crate::media::is_text_extension(file_name) { + tracing::debug!(file_name, "skipping non-text file attachment"); + return None; + } + + let get_file_url = format!("{TELEGRAM_API_BASE}/bot{}/getFile", bot_token); + let resp = client.get(&get_file_url).query(&[("file_id", file_id)]).send().await.ok()?; + let body: serde_json::Value = resp.json().await.ok()?; + let file_path = body["result"]["file_path"].as_str()?; + + let download_url = format!("{TELEGRAM_API_BASE}/file/bot{}/{}", bot_token, file_path); + let resp = client.get(&download_url).send().await.ok()?; + if !resp.status().is_success() { + return None; + } + + if let Some(cl) = resp.headers().get(reqwest::header::CONTENT_LENGTH) { + if let Ok(size) = cl.to_str().unwrap_or("0").parse::() { + if size > FILE_MAX_DOWNLOAD { + warn!(file_id, size, "Telegram document Content-Length exceeds limit"); + return None; + } + } + } + + let bytes = resp.bytes().await.ok()?; + if bytes.len() as u64 > FILE_MAX_DOWNLOAD { + warn!(file_id, size = bytes.len(), "Telegram document exceeds limit"); + return None; + } + + // Validate UTF-8 — reject binary files + if String::from_utf8(bytes.to_vec()).is_err() { + warn!(file_id, file_name, "Telegram document is not valid UTF-8, skipping"); + return None; + } + + let path = store::store_media(&bytes).await?; + info!(file_id, file_name, size = bytes.len(), "Telegram document stored"); + + Some(Attachment { + attachment_type: "text_file".into(), + filename: file_name.to_string(), + mime_type: mime_type.to_string(), + data: String::new(), + size: bytes.len() as u64, + path: Some(path), + }) +} diff --git a/gateway/src/adapters/wecom.rs b/gateway/src/adapters/wecom.rs index a33a71e53..e3e97ff17 100644 --- a/gateway/src/adapters/wecom.rs +++ b/gateway/src/adapters/wecom.rs @@ -1147,15 +1147,15 @@ async fn download_wecom_image( return None; } }; - use base64::Engine; - let data = base64::engine::general_purpose::STANDARD.encode(&compressed); + let path = crate::store::store_media(&compressed).await?; let ext = if mime == "image/gif" { "gif" } else { "jpg" }; Some(crate::schema::Attachment { attachment_type: "image".into(), filename: format!("wecom_{}.{}", chrono::Utc::now().timestamp(), ext), mime_type: mime, - data, + data: String::new(), size: compressed.len() as u64, + path: Some(path), }) } @@ -1273,16 +1273,16 @@ async fn download_wecom_file( } }; - use base64::Engine; - let data = base64::engine::general_purpose::STANDARD.encode(text_content.as_bytes()); + let path = crate::store::store_media(text_content.as_bytes()).await?; let size = text_content.len() as u64; Some(crate::schema::Attachment { attachment_type: "text_file".into(), filename: filename.to_string(), mime_type: "text/plain".into(), - data, + data: String::new(), size, + path: Some(path), }) } diff --git a/gateway/src/main.rs b/gateway/src/main.rs index ae685a957..b7bad6666 100644 --- a/gateway/src/main.rs +++ b/gateway/src/main.rs @@ -1,5 +1,7 @@ mod adapters; +mod media; mod schema; +pub mod store; use anyhow::Result; use axum::{ @@ -60,6 +62,8 @@ pub struct AppState { /// the first client to `remove()` a token wins the free Reply API call; /// other clients for the same event naturally fall back to Push API. pub reply_token_cache: ReplyTokenCache, + /// Shared HTTP client for media downloads and API calls + pub client: reqwest::Client, } // --- WebSocket handler (OAB connects here) --- @@ -344,6 +348,11 @@ async fn main() -> Result<()> { warn!("no adapters configured — set TELEGRAM_BOT_TOKEN, LINE_CHANNEL_ACCESS_TOKEN, TEAMS_APP_ID + TEAMS_APP_SECRET, FEISHU_APP_ID + FEISHU_APP_SECRET, GOOGLE_CHAT_ENABLED=true, and/or WECOM_CORP_ID + WECOM_SECRET + WECOM_TOKEN + WECOM_ENCODING_AES_KEY + WECOM_AGENT_ID"); } + let client = reqwest::Client::builder() + .timeout(std::time::Duration::from_secs(30)) + .build() + .expect("HTTP client must build"); + let state = Arc::new(AppState { telegram_bot_token, telegram_secret_token, @@ -357,6 +366,7 @@ async fn main() -> Result<()> { ws_token, event_tx, reply_token_cache, + client, }); // Background task: sweep expired reply tokens every REPLY_TOKEN_TTL_SECS @@ -406,6 +416,9 @@ async fn main() -> Result<()> { let app = app.with_state(state.clone()); + // Background task: evict expired media files (colocate store, TTL 2 min) + tokio::spawn(store::eviction_loop()); + // Spawn feishu WebSocket long-connection if configured // feishu_shutdown_tx must remain alive for the lifetime of main() — dropping // it signals shutdown to the WS task via feishu_shutdown_rx. diff --git a/gateway/src/media.rs b/gateway/src/media.rs new file mode 100644 index 000000000..f6eb88565 --- /dev/null +++ b/gateway/src/media.rs @@ -0,0 +1,123 @@ +use image::ImageReader; +use std::io::Cursor; + +/// Media type for download functions — avoids stringly-typed branching. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum MediaKind { + Image, + Audio, +} + +pub const IMAGE_MAX_DIMENSION_PX: u32 = 1200; +pub const IMAGE_JPEG_QUALITY: u8 = 75; +pub const IMAGE_MAX_DOWNLOAD: u64 = 10 * 1024 * 1024; // 10 MB +pub const FILE_MAX_DOWNLOAD: u64 = 20 * 1024 * 1024; // 20 MB (same as store cap) +pub const AUDIO_MAX_DOWNLOAD: u64 = 20 * 1024 * 1024; // 20 MB +pub const GIF_MAX_SIZE: usize = 5 * 1024 * 1024; // 5 MB — prevents base64 bloat exceeding LLM payload limits + +/// Resize image so longest side <= 1200px, then encode as JPEG. +/// GIFs under 5MB are passed through unchanged to preserve animation. +pub fn resize_and_compress(raw: &[u8]) -> Result<(Vec, String), image::ImageError> { + let reader = ImageReader::new(Cursor::new(raw)).with_guessed_format()?; + let format = reader.format(); + if format == Some(image::ImageFormat::Gif) { + if raw.len() > GIF_MAX_SIZE { + return Err(image::ImageError::Limits( + image::error::LimitError::from_kind(image::error::LimitErrorKind::DimensionError), + )); + } + return Ok((raw.to_vec(), "image/gif".to_string())); + } + let img = reader.decode()?; + let (w, h) = (img.width(), img.height()); + let img = if w > IMAGE_MAX_DIMENSION_PX || h > IMAGE_MAX_DIMENSION_PX { + let max_side = std::cmp::max(w, h); + let ratio = f64::from(IMAGE_MAX_DIMENSION_PX) / f64::from(max_side); + let new_w = (f64::from(w) * ratio) as u32; + let new_h = (f64::from(h) * ratio) as u32; + img.resize(new_w, new_h, image::imageops::FilterType::Lanczos3) + } else { + img + }; + let mut buf = Cursor::new(Vec::new()); + let encoder = image::codecs::jpeg::JpegEncoder::new_with_quality(&mut buf, IMAGE_JPEG_QUALITY); + img.write_with_encoder(encoder)?; + Ok((buf.into_inner(), "image/jpeg".to_string())) +} + +/// Derive file extension from Content-Type for audio files. +pub fn audio_extension(content_type: &str) -> &'static str { + if content_type.contains("mpeg") || content_type.contains("mp3") { + "mp3" + } else if content_type.contains("m4a") || content_type.contains("mp4") { + "m4a" + } else { + "ogg" + } +} + +/// Check if a filename has a text-like extension suitable for reading as UTF-8. +pub fn is_text_extension(filename: &str) -> bool { + const TEXT_EXTS: &[&str] = &[ + "txt", "csv", "log", "md", "json", "jsonl", "yaml", "yml", "toml", "xml", "rs", "py", + "js", "ts", "jsx", "tsx", "go", "java", "c", "cpp", "h", "hpp", "rb", "sh", "bash", + "sql", "html", "css", "ini", "cfg", "conf", + ]; + let ext = filename.rsplit('.').next().unwrap_or("").to_lowercase(); + TEXT_EXTS.contains(&ext.as_str()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn gif_under_limit_passes_through() { + let gif = b"GIF89a\x01\x00\x01\x00\x80\x00\x00\xff\xff\xff\x00\x00\x00!\xf9\x04\x00\x00\x00\x00\x00,\x00\x00\x00\x00\x01\x00\x01\x00\x00\x02\x02D\x01\x00;"; + let result = resize_and_compress(gif); + assert!(result.is_ok()); + let (data, mime) = result.unwrap(); + assert_eq!(mime, "image/gif"); + assert_eq!(data, gif); + } + + #[test] + fn gif_over_limit_returns_error() { + let mut data = b"GIF89a\x01\x00\x01\x00\x80\x00\x00\xff\xff\xff\x00\x00\x00".to_vec(); + data.resize(GIF_MAX_SIZE + 1, 0); + let result = resize_and_compress(&data); + assert!(result.is_err()); + } + + #[test] + fn small_jpeg_not_resized() { + let img = image::RgbImage::from_pixel(2, 2, image::Rgb([255, 0, 0])); + let mut buf = std::io::Cursor::new(Vec::new()); + img.write_to(&mut buf, image::ImageFormat::Jpeg).unwrap(); + let result = resize_and_compress(&buf.into_inner()); + assert!(result.is_ok()); + assert_eq!(result.unwrap().1, "image/jpeg"); + } + + #[test] + fn large_image_gets_resized() { + let img = image::RgbImage::from_pixel(2000, 2000, image::Rgb([0, 128, 255])); + let mut buf = std::io::Cursor::new(Vec::new()); + img.write_to(&mut buf, image::ImageFormat::Png).unwrap(); + let result = resize_and_compress(&buf.into_inner()); + assert!(result.is_ok()); + let (data, mime) = result.unwrap(); + assert_eq!(mime, "image/jpeg"); + let decoded = image::load_from_memory(&data).unwrap(); + assert!(decoded.width() <= IMAGE_MAX_DIMENSION_PX); + assert!(decoded.height() <= IMAGE_MAX_DIMENSION_PX); + } + + #[test] + fn text_extension_check() { + assert!(is_text_extension("main.rs")); + assert!(is_text_extension("data.csv")); + assert!(!is_text_extension("archive.zip")); + assert!(!is_text_extension("photo.jpg")); + } +} diff --git a/gateway/src/schema.rs b/gateway/src/schema.rs index 8fffa919b..740d0fab8 100644 --- a/gateway/src/schema.rs +++ b/gateway/src/schema.rs @@ -44,11 +44,19 @@ pub struct Content { #[derive(Clone, Debug, Default, Serialize, Deserialize)] pub struct Attachment { #[serde(rename = "type")] - pub attachment_type: String, // "image", "text_file" + pub attachment_type: String, // "image", "text_file", "audio" pub filename: String, pub mime_type: String, - pub data: String, // base64 encoded - pub size: u64, // size in bytes (after compression for images) + /// Base64-encoded data (deprecated — use `path` for colocate mode). + /// Kept for backward compatibility; Core prefers `path` when present. + #[serde(default, skip_serializing_if = "String::is_empty")] + pub data: String, + pub size: u64, // size in bytes (after compression for images) + /// Local file path for colocate mode (gateway + core share filesystem). + /// When set, Core reads bytes directly from this path instead of decoding `data`. + /// Path format: ~/.openab/media/inbound/ (no extension, MIME in mime_type). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub path: Option, } // --- Reply schema (ADR openab.gateway.reply.v1) --- diff --git a/gateway/src/store.rs b/gateway/src/store.rs new file mode 100644 index 000000000..b08e69903 --- /dev/null +++ b/gateway/src/store.rs @@ -0,0 +1,132 @@ +use std::path::{Path, PathBuf}; +use tokio::fs; +use tracing::{error, info}; +use uuid::Uuid; + +/// Inbound media directory under $HOME. +/// Pattern follows OpenClaw's `~/.openclaw/media/inbound/`. +/// +/// # Security Considerations +/// +/// - **Path traversal prevention**: Filenames are always server-generated UUIDs, +/// never user-supplied. No extension, no special characters — eliminates path +/// traversal attacks (e.g. `../../etc/passwd`). +/// +/// - **No auth token leakage**: Platform media URLs (Telegram getFile, LINE Content API) +/// contain bot tokens or require auth headers. By downloading in the gateway and +/// storing locally, tokens never reach Core or the agent. +/// +/// - **TTL auto-eviction**: Files are evicted after 2 minutes. Prevents disk exhaustion +/// from accumulated media and limits the window for any leaked file to be exploited. +/// +/// - **Colocate trust boundary**: This module assumes gateway and core share the same +/// filesystem (same pod / same $HOME). The file path is passed over the internal WS +/// connection — never exposed externally. If gateway and core are separated in the +/// future, switch to HTTP media proxy with internal-only binding. +/// +/// - **Size limits enforced before write**: Callers must validate file size against +/// IMAGE_MAX_DOWNLOAD / AUDIO_MAX_DOWNLOAD / FILE_MAX_DOWNLOAD before calling +/// `store_media()`. This module does NOT re-validate — it trusts the caller. +/// +/// - **No executable content**: Stored files are raw bytes (images, audio, text). +/// Core reads them as data only — never executed. The `mime_type` in the event +/// payload determines processing path, not the file content or name. +const MEDIA_INBOUND_DIR: &str = ".openab/media/inbound"; + +/// TTL for stored media files (2 minutes) +const TTL_SECS: u64 = 120; + +/// Get the inbound media directory path, creating it if needed. +pub async fn media_dir() -> PathBuf { + let home = std::env::var("HOME").unwrap_or_else(|_| "/tmp".into()); + let dir = Path::new(&home).join(MEDIA_INBOUND_DIR); + if !dir.exists() { + let _ = fs::create_dir_all(&dir).await; + } + dir +} + +/// Maximum file size accepted by store (defense-in-depth, callers should pre-check). +const MAX_STORE_SIZE: usize = 20 * 1024 * 1024; // 20 MB (matches AUDIO_MAX_DOWNLOAD) + +/// Store media bytes to disk, return the absolute file path. +/// Filename is UUID only (no extension) — MIME type is carried in the event payload. +/// Rejects files exceeding MAX_STORE_SIZE as a defense-in-depth measure. +pub async fn store_media(bytes: &[u8]) -> Option { + if bytes.len() > MAX_STORE_SIZE { + error!(size = bytes.len(), max = MAX_STORE_SIZE, "store_media rejected: exceeds size limit"); + return None; + } + let dir = media_dir().await; + let filename = Uuid::new_v4().to_string(); + let path = dir.join(&filename); + match fs::write(&path, bytes).await { + Ok(_) => { + info!(path = %path.display(), size = bytes.len(), "media stored"); + Some(path.to_string_lossy().into_owned()) + } + Err(e) => { + error!(error = %e, "failed to store media file"); + None + } + } +} + +/// Background task: evict files older than TTL_SECS. +pub async fn eviction_loop() { + let mut interval = tokio::time::interval(std::time::Duration::from_secs(30)); + loop { + interval.tick().await; + if let Err(e) = evict_expired().await { + error!(error = %e, "media eviction error"); + } + } +} + +async fn evict_expired() -> std::io::Result<()> { + let dir = media_dir().await; + if !dir.exists() { + return Ok(()); + } + let mut entries = fs::read_dir(&dir).await?; + let now = std::time::SystemTime::now(); + while let Some(entry) = entries.next_entry().await? { + if let Ok(meta) = entry.metadata().await { + if let Ok(modified) = meta.modified() { + if let Ok(age) = now.duration_since(modified) { + if age.as_secs() > TTL_SECS { + let path = entry.path(); + let _ = fs::remove_file(&path).await; + tracing::debug!(path = %path.display(), "evicted expired media"); + } + } + } + } + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[tokio::test] + async fn store_and_read_back() { + let data = b"hello media"; + let path = store_media(data).await.unwrap(); + let read_back = fs::read(&path).await.unwrap(); + assert_eq!(read_back, data); + // Cleanup + let _ = fs::remove_file(&path).await; + } + + #[tokio::test] + async fn filename_is_uuid_no_extension() { + let path = store_media(b"test").await.unwrap(); + let filename = Path::new(&path).file_name().unwrap().to_str().unwrap(); + // UUID v4 format: 8-4-4-4-12 hex chars + assert_eq!(filename.len(), 36); + assert!(!filename.contains('.')); + let _ = fs::remove_file(&path).await; + } +} diff --git a/src/gateway.rs b/src/gateway.rs index fb035d735..bbd6da9bc 100644 --- a/src/gateway.rs +++ b/src/gateway.rs @@ -64,9 +64,13 @@ struct GwAttachment { attachment_type: String, filename: String, mime_type: String, + #[serde(default)] data: String, #[allow(dead_code)] size: u64, + /// Colocate mode: local file path (preferred over base64 `data` when present) + #[serde(default)] + path: Option, } #[derive(Serialize)] @@ -705,16 +709,36 @@ pub async fn run_gateway_adapter( // Convert gateway attachments to ContentBlocks let mut extra_blocks = Vec::new(); for att in &event.content.attachments { + // Read bytes: prefer file path (colocate), fallback to base64 + let bytes_result = if let Some(ref path) = att.path { + tokio::fs::read(path).await.map_err(|e| e.to_string()) + } else if !att.data.is_empty() { + use base64::Engine; + base64::engine::general_purpose::STANDARD + .decode(&att.data) + .map_err(|e| e.to_string()) + } else { + Err("no path or data".into()) + }; + match att.attachment_type.as_str() { "image" => { - extra_blocks.push(ContentBlock::Image { - media_type: att.mime_type.clone(), - data: att.data.clone(), - }); + match bytes_result { + Ok(bytes) => { + use base64::Engine; + let b64 = base64::engine::general_purpose::STANDARD.encode(&bytes); + extra_blocks.push(ContentBlock::Image { + media_type: att.mime_type.clone(), + data: b64, + }); + } + Err(e) => { + tracing::warn!(filename = %att.filename, error = %e, "gateway image read failed"); + } + } } "text_file" => { - use base64::Engine; - if let Ok(bytes) = base64::engine::general_purpose::STANDARD.decode(&att.data) { + if let Ok(bytes) = bytes_result { let text = String::from_utf8_lossy(&bytes); extra_blocks.push(ContentBlock::Text { text: format!("```{}\n{}\n```", att.filename, text), @@ -722,8 +746,7 @@ pub async fn run_gateway_adapter( } } "audio" if stt_config.enabled => { - use base64::Engine; - match base64::engine::general_purpose::STANDARD.decode(&att.data) { + match bytes_result { Ok(bytes) => { match crate::stt::transcribe( &crate::media::HTTP_CLIENT, @@ -749,10 +772,10 @@ pub async fn run_gateway_adapter( } } Err(e) => { - tracing::warn!(filename = %att.filename, error = %e, "gateway audio base64 decode failed"); + tracing::warn!(filename = %att.filename, error = %e, "gateway audio read failed"); extra_blocks.push(ContentBlock::Text { text: format!( - "[Voice message — decode failed for {}]", + "[Voice message — read failed for {}]", att.filename ), }); From 9f9b03316e645afbae3a57fbf0ac557360581176 Mon Sep 17 00:00:00 2001 From: chaodufashi Date: Tue, 19 May 2026 19:49:11 -0400 Subject: [PATCH 050/100] chore: rename build.yml to build-operator.yml for clarity (#861) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: 超渡法師 --- .github/workflows/{build.yml => build-operator.yml} | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) rename .github/workflows/{build.yml => build-operator.yml} (99%) diff --git a/.github/workflows/build.yml b/.github/workflows/build-operator.yml similarity index 99% rename from .github/workflows/build.yml rename to .github/workflows/build-operator.yml index 8eeb11738..ed981f022 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build-operator.yml @@ -1,4 +1,4 @@ -name: Build & Release +name: Build Operator on: push: From 0fd8926c22f9a64cd2d1aeb8274d556c515690c2 Mon Sep 17 00:00:00 2001 From: "openab-app[bot]" <274185012+openab-app[bot]@users.noreply.github.com> Date: Tue, 19 May 2026 19:49:41 -0400 Subject: [PATCH 051/100] release: gateway-v0.5.0 (#860) Co-authored-by: openab-app[bot] <274185012+openab-app[bot]@users.noreply.github.com> --- gateway/Cargo.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gateway/Cargo.toml b/gateway/Cargo.toml index 6af027b4a..94329c271 100644 --- a/gateway/Cargo.toml +++ b/gateway/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "openab-gateway" -version = "0.4.0" +version = "0.5.0" edition = "2021" [dependencies] From ed5122c4cc23a084d2be2efbe4ef2d02c083619e Mon Sep 17 00:00:00 2001 From: "openab-app[bot]" <274185012+openab-app[bot]@users.noreply.github.com> Date: Tue, 19 May 2026 19:51:08 -0400 Subject: [PATCH 052/100] release: v0.8.3-beta.12 (#862) Co-authored-by: openab-app[bot] <274185012+openab-app[bot]@users.noreply.github.com> --- charts/openab/Chart.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/charts/openab/Chart.yaml b/charts/openab/Chart.yaml index 08a548833..8c19f040c 100644 --- a/charts/openab/Chart.yaml +++ b/charts/openab/Chart.yaml @@ -2,5 +2,5 @@ apiVersion: v2 name: openab description: A lightweight, secure, cloud-native ACP harness that bridges Discord and any ACP-compatible coding CLI. type: application -version: 0.8.3-beta.11 -appVersion: "0.8.3-beta.11" +version: 0.8.3-beta.12 +appVersion: "0.8.3-beta.12" From 88b4b604ea14ce963fc5993e084564e5c84c0e20 Mon Sep 17 00:00:00 2001 From: chaodufashi Date: Tue, 19 May 2026 22:20:58 -0400 Subject: [PATCH 053/100] ci: scope each job to its own paths with dorny/paths-filter (#867) Previously, any change in src/, gateway/, or operator/ triggered all three CI jobs. Now each job only runs when its own paths are modified: - check: src/**, Cargo.toml, Cargo.lock - gateway: gateway/** - operator: operator/** Co-authored-by: openab-bot --- .github/workflows/ci.yml | 45 ++++++++++++++++++++++++---------------- 1 file changed, 27 insertions(+), 18 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a5fa031db..872ebe260 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -5,9 +5,7 @@ on: paths: - "src/**" - "gateway/**" - - "gateway/Cargo.lock" - "operator/**" - - "operator/Cargo.lock" - "Cargo.toml" - "Cargo.lock" - "Dockerfile*" @@ -16,75 +14,86 @@ env: CARGO_TERM_COLOR: always jobs: - check: + changes: runs-on: ubuntu-latest + outputs: + core: ${{ steps.filter.outputs.core }} + gateway: ${{ steps.filter.outputs.gateway }} + operator: ${{ steps.filter.outputs.operator }} steps: - uses: actions/checkout@v6 + - uses: dorny/paths-filter@v3 + id: filter + with: + filters: | + core: + - 'src/**' + - 'Cargo.toml' + - 'Cargo.lock' + gateway: + - 'gateway/**' + operator: + - 'operator/**' + check: + needs: changes + if: needs.changes.outputs.core == 'true' + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 - uses: dtolnay/rust-toolchain@stable with: components: clippy - - uses: Swatinem/rust-cache@v2 - - name: cargo check run: cargo check - - name: cargo clippy run: cargo clippy -- -D warnings - - name: cargo test run: cargo test gateway: + needs: changes + if: needs.changes.outputs.gateway == 'true' runs-on: ubuntu-latest defaults: run: working-directory: gateway steps: - uses: actions/checkout@v6 - - uses: dtolnay/rust-toolchain@stable with: components: clippy - - uses: Swatinem/rust-cache@v2 with: workspaces: gateway - - name: cargo check run: cargo check - - name: cargo clippy run: cargo clippy -- -D warnings - - name: cargo test run: cargo test operator: + needs: changes + if: needs.changes.outputs.operator == 'true' runs-on: ubuntu-latest defaults: run: working-directory: operator steps: - uses: actions/checkout@v6 - - uses: dtolnay/rust-toolchain@stable with: components: clippy - - uses: Swatinem/rust-cache@v2 with: workspaces: operator - - name: cargo check run: cargo check - - name: cargo clippy run: cargo clippy -- -D warnings - - name: cargo test run: cargo test - - name: cargo build run: cargo build --release From 6ec51b3c7941d14e82e4e64380e513d11953655a Mon Sep 17 00:00:00 2001 From: chaodufashi Date: Tue, 19 May 2026 22:45:14 -0400 Subject: [PATCH 054/100] ci: add macOS native build on operator/* branch push (#868) Co-authored-by: Pahud Hsieh --- .github/workflows/build-operator-branch.yml | 36 +++++++++++++++++++++ 1 file changed, 36 insertions(+) create mode 100644 .github/workflows/build-operator-branch.yml diff --git a/.github/workflows/build-operator-branch.yml b/.github/workflows/build-operator-branch.yml new file mode 100644 index 000000000..a4e830e21 --- /dev/null +++ b/.github/workflows/build-operator-branch.yml @@ -0,0 +1,36 @@ +name: Build Operator (Branch) + +on: + push: + branches: + - "operator/**" + workflow_dispatch: + +jobs: + build-macos: + runs-on: openab-runner-macos + permissions: + contents: read + steps: + - uses: actions/checkout@v6 + + - name: Install Rust + run: | + curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y + echo "$HOME/.cargo/bin" >> "$GITHUB_PATH" + + - name: Build (aarch64-apple-darwin) + run: cargo build --release + + - name: Package + run: | + mkdir -p dist + cp target/release/openab dist/ + cd dist && tar czf ../openab-macos-arm64.tar.gz * + + - name: Upload artifact + uses: actions/upload-artifact@v4 + with: + name: openab-macos-arm64 + path: openab-macos-arm64.tar.gz + retention-days: 7 From 804c7efedcd47fbe9453978c50c633001db1393d Mon Sep 17 00:00:00 2001 From: chaodufashi Date: Tue, 19 May 2026 22:50:15 -0400 Subject: [PATCH 055/100] fix: use standard runner labels for macOS build (#869) Co-authored-by: Pahud Hsieh --- .github/workflows/build-operator-branch.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/build-operator-branch.yml b/.github/workflows/build-operator-branch.yml index a4e830e21..efdaa535e 100644 --- a/.github/workflows/build-operator-branch.yml +++ b/.github/workflows/build-operator-branch.yml @@ -8,7 +8,7 @@ on: jobs: build-macos: - runs-on: openab-runner-macos + runs-on: [self-hosted, macOS, ARM64] permissions: contents: read steps: From ef28a5795a5ef91ef666b548138e4ba01cdc2282 Mon Sep 17 00:00:00 2001 From: chaodufashi Date: Wed, 20 May 2026 18:30:18 -0400 Subject: [PATCH 056/100] fix: publish gateway image with v-prefixed tag (#876) The openab-telegram chart references gateway.tag as v0.5.0 but the workflow only publishes 0.5.0 (without v prefix). Add v-prefixed tag so both conventions work. Co-authored-by: Pahud Hsieh --- .github/workflows/build-gateway.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/build-gateway.yml b/.github/workflows/build-gateway.yml index b6e7730ff..516e68c3c 100644 --- a/.github/workflows/build-gateway.yml +++ b/.github/workflows/build-gateway.yml @@ -115,6 +115,7 @@ jobs: cd /tmp/digests docker buildx imagetools create \ -t ${{ env.IMAGE_NAME }}:${{ steps.tag.outputs.version }} \ + -t ${{ env.IMAGE_NAME }}:v${{ steps.tag.outputs.version }} \ -t ${{ env.IMAGE_NAME }}:latest \ $(printf '${{ env.IMAGE_NAME }}@sha256:%s ' *) From 0287ff80e7935f0dca7ab65ca6489f248b843954 Mon Sep 17 00:00:00 2001 From: chaodufashi Date: Wed, 20 May 2026 18:39:14 -0400 Subject: [PATCH 057/100] feat: add :stable and :beta floating image tags (#878) * feat: add :stable and :beta floating image tags Pre-release builds now also tag :beta, stable promotions also tag :stable. This enables the openab-telegram chart to use channel-based image resolution without needing to know exact version numbers. * docs: add image tagging convention --------- Co-authored-by: Pahud Hsieh --- .github/workflows/build-operator.yml | 4 +- docs/image-tags.md | 55 ++++++++++++++++++++++++++++ 2 files changed, 58 insertions(+), 1 deletion(-) create mode 100644 docs/image-tags.md diff --git a/.github/workflows/build-operator.yml b/.github/workflows/build-operator.yml index ed981f022..7b3066aef 100644 --- a/.github/workflows/build-operator.yml +++ b/.github/workflows/build-operator.yml @@ -167,6 +167,7 @@ jobs: tags: | type=sha,prefix= type=semver,pattern={{version}},value=${{ needs.resolve-tag.outputs.tag }} + type=raw,value=beta - name: Create manifest list working-directory: /tmp/digests @@ -237,11 +238,12 @@ jobs: CHART_VERSION="${{ needs.resolve-tag.outputs.chart_version }}" MAJOR_MINOR="${CHART_VERSION%.*}" - echo "Promoting ${IMAGE}:${PRERELEASE_VERSION} → ${CHART_VERSION}, ${MAJOR_MINOR}, latest" + echo "Promoting ${IMAGE}:${PRERELEASE_VERSION} → ${CHART_VERSION}, ${MAJOR_MINOR}, latest, stable" docker buildx imagetools create \ -t "${IMAGE}:${CHART_VERSION}" \ -t "${IMAGE}:${MAJOR_MINOR}" \ -t "${IMAGE}:latest" \ + -t "${IMAGE}:stable" \ "${IMAGE}:${PRERELEASE_VERSION}" # ── Chart release (runs after either path) ─────────────────── diff --git a/docs/image-tags.md b/docs/image-tags.md new file mode 100644 index 000000000..ab533dc18 --- /dev/null +++ b/docs/image-tags.md @@ -0,0 +1,55 @@ +# Docker Image Tagging Convention + +## Core (`ghcr.io/openabdev/openab`) + +| Tag | Points to | Updated when | +|-----|-----------|--------------| +| `0.8.3-beta.12` | Exact pre-release build | Pre-release tag pushed | +| `beta` | Latest pre-release | Every pre-release build | +| `0.8.3` | Promoted stable build | Stable tag pushed | +| `0.8` | Latest patch in minor | Stable promotion | +| `stable` | Latest stable | Stable promotion | +| `latest` | Latest stable (= `stable`) | Stable promotion | + +Variant images (e.g. `-codex`, `-claude`, `-gemini`) follow the same convention with a suffix: `ghcr.io/openabdev/openab-codex:beta`. + +## Gateway (`ghcr.io/openabdev/openab-gateway`) + +| Tag | Points to | Updated when | +|-----|-----------|--------------| +| `0.5.1` | Exact release | `gateway-v*` tag pushed | +| `v0.5.1` | Same as above (v-prefixed alias) | Same | +| `latest` | Latest release | Every release | + +## Which tag to use + +| Use case | Recommended tag | +|----------|----------------| +| Production (pinned) | Exact version (`0.8.3-beta.12`) | +| Helm chart default | `stable` or `beta` (channel-based) | +| Local dev / quick test | `beta` | +| CI | Exact version or SHA | + +## Release flow + +``` +release PR merged → tag-on-merge → v0.8.3-beta.12 + │ + ▼ + build-operator.yml + │ + ┌──────────┴──────────┐ + │ is_prerelease=true │ + ▼ │ + tag: 0.8.3-beta.12 │ + tag: beta │ + │ + ┌──────────────────────┘ + │ is_prerelease=false (stable) + ▼ + promote latest beta image → + tag: 0.8.3 + tag: 0.8 + tag: stable + tag: latest +``` From 2bde3400fda6ba6d5b92443768ee5411025fe3ad Mon Sep 17 00:00:00 2001 From: "openab-app[bot]" <274185012+openab-app[bot]@users.noreply.github.com> Date: Wed, 20 May 2026 18:40:05 -0400 Subject: [PATCH 058/100] release: gateway-v0.5.1 (#877) Co-authored-by: openab-app[bot] <274185012+openab-app[bot]@users.noreply.github.com> --- gateway/Cargo.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gateway/Cargo.toml b/gateway/Cargo.toml index 94329c271..60d13a6f7 100644 --- a/gateway/Cargo.toml +++ b/gateway/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "openab-gateway" -version = "0.5.0" +version = "0.5.1" edition = "2021" [dependencies] From dcec95611c28adb84819bfb366f2804369074ee1 Mon Sep 17 00:00:00 2001 From: "openab-app[bot]" <274185012+openab-app[bot]@users.noreply.github.com> Date: Wed, 20 May 2026 19:04:38 -0400 Subject: [PATCH 059/100] release: v0.8.3-beta.13 (#880) Co-authored-by: openab-app[bot] <274185012+openab-app[bot]@users.noreply.github.com> --- charts/openab/Chart.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/charts/openab/Chart.yaml b/charts/openab/Chart.yaml index 8c19f040c..75bafdb31 100644 --- a/charts/openab/Chart.yaml +++ b/charts/openab/Chart.yaml @@ -2,5 +2,5 @@ apiVersion: v2 name: openab description: A lightweight, secure, cloud-native ACP harness that bridges Discord and any ACP-compatible coding CLI. type: application -version: 0.8.3-beta.12 -appVersion: "0.8.3-beta.12" +version: 0.8.3-beta.13 +appVersion: "0.8.3-beta.13" From a2870aa9f1a9dbb5059d4de50cf815d03f41fb21 Mon Sep 17 00:00:00 2001 From: "openab-app[bot]" <274185012+openab-app[bot]@users.noreply.github.com> Date: Wed, 20 May 2026 19:21:19 -0400 Subject: [PATCH 060/100] release: v0.8.3 (#881) Co-authored-by: openab-app[bot] <274185012+openab-app[bot]@users.noreply.github.com> --- charts/openab/Chart.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/charts/openab/Chart.yaml b/charts/openab/Chart.yaml index 75bafdb31..d820930b2 100644 --- a/charts/openab/Chart.yaml +++ b/charts/openab/Chart.yaml @@ -2,5 +2,5 @@ apiVersion: v2 name: openab description: A lightweight, secure, cloud-native ACP harness that bridges Discord and any ACP-compatible coding CLI. type: application -version: 0.8.3-beta.13 -appVersion: "0.8.3-beta.13" +version: 0.8.3 +appVersion: "0.8.3" From 4944acbeb7cdd8e2336554a2933c5fe2e3c2639f Mon Sep 17 00:00:00 2001 From: chaodufashi Date: Wed, 20 May 2026 20:06:42 -0400 Subject: [PATCH 061/100] feat: add openab-telegram chart (#873) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: add openab-telegram chart (colocated OAB + gateway + cloudflared) Single-pod Helm chart for Telegram deployments: - OAB agent, gateway, and cloudflared tunnel as colocated containers - Shared emptyDir for /tmp, PVC for agent persistence - Only 2 required --set flags: telegramBotToken, cloudflareTunnelToken - Follows the reference architecture from docs/refarch/telegram-cloudflare-tunnel.md Closes #872 * feat(openab-telegram): add release channel (beta/stable) support - channel: stable (default) strips -beta.* from appVersion for both images - channel: beta uses appVersion as-is for core, strips prerelease for gateway (gateway has no beta tags) - Explicit image.tag / gateway.tag override still takes precedence * fix(openab-telegram): pin gateway to v0.5.0, simplify helper Gateway has independent release cadence from core — no appVersion derivation. Just use the pinned tag directly. * feat(openab-telegram): add existingSecret support + credential management README - existingSecret: reference a pre-created K8s Secret (skips chart Secret creation) - README documents 3 credential options: --set, --from-literal, --from-env-file - Secrets from external managers (AWS SM) can flow to K8s without touching disk * fix(openab-telegram): address review findings - Pin cloudflared to 2026.5.0 (was 'latest') - Change agent.command default to 'openab' (generic, not kiro-specific) - Fix NOTES.txt webhook curl to respect existingSecret * fix(openab-telegram): mount shared PVC in gateway container Gateway needs write access to ~/.openab/media/inbound/ for media proxy colocate mode (PR #858). Both core and gateway now share the PVC. * docs(openab-telegram): add ASCII architecture diagram to README * docs(openab-telegram): add Prerequisites section with CLI-only tunnel setup * docs(openab-telegram): make README fully headless/CLI-only - Cloudflare tunnel setup via API token (no browser) - Ingress config via local config.yml - Webhook setup moved to Prerequisites (before helm install) - Post-install only has agent auth (device flow) - Fixed agent command to 'openab' * chore: bump gateway tag to v0.5.1 * refactor: use floating channel tags for agent image Instead of regex-stripping beta suffix from appVersion, resolve image tag directly from channel value (stable/beta). Requires PR #878 to publish the floating tags. * chore: update appVersion to 0.8.3, fix channel comments * fix: retain PVC on helm uninstall Agent auth credentials and state live in the PVC. Without this, uninstall+reinstall requires re-authentication. * docs: add tunnel ingress config step to NOTES.txt * fix: default agent command to kiro-cli acp * docs: rewrite NOTES.txt as structured AI-friendly post-install guide * feat: support cloudflare-api-token for automated ingress config Optional third key in the K8s Secret enables AI agents to configure tunnel ingress via the Cloudflare API without external credentials. NOTES.txt extracts all needed values from the secret itself. * docs: add remote-mode ingress config and AI-assisted install prompt --------- Co-authored-by: chaodu-agent Co-authored-by: Pahud Hsieh --- charts/openab-telegram/Chart.yaml | 6 + charts/openab-telegram/README.md | 239 ++++++++++++++++++ charts/openab-telegram/templates/NOTES.txt | 71 ++++++ charts/openab-telegram/templates/_helpers.tpl | 32 +++ .../openab-telegram/templates/configmap.yaml | 36 +++ .../openab-telegram/templates/deployment.yaml | 140 ++++++++++ charts/openab-telegram/templates/pvc.yaml | 19 ++ charts/openab-telegram/templates/secret.yaml | 21 ++ charts/openab-telegram/values.yaml | 109 ++++++++ 9 files changed, 673 insertions(+) create mode 100644 charts/openab-telegram/Chart.yaml create mode 100644 charts/openab-telegram/README.md create mode 100644 charts/openab-telegram/templates/NOTES.txt create mode 100644 charts/openab-telegram/templates/_helpers.tpl create mode 100644 charts/openab-telegram/templates/configmap.yaml create mode 100644 charts/openab-telegram/templates/deployment.yaml create mode 100644 charts/openab-telegram/templates/pvc.yaml create mode 100644 charts/openab-telegram/templates/secret.yaml create mode 100644 charts/openab-telegram/values.yaml diff --git a/charts/openab-telegram/Chart.yaml b/charts/openab-telegram/Chart.yaml new file mode 100644 index 000000000..f147ef0b9 --- /dev/null +++ b/charts/openab-telegram/Chart.yaml @@ -0,0 +1,6 @@ +apiVersion: v2 +name: openab-telegram +description: OpenAB + Telegram — single-pod deployment with gateway and Cloudflare Tunnel sidecar. +type: application +version: 0.1.0 +appVersion: "0.8.3" diff --git a/charts/openab-telegram/README.md b/charts/openab-telegram/README.md new file mode 100644 index 000000000..2ef4a7690 --- /dev/null +++ b/charts/openab-telegram/README.md @@ -0,0 +1,239 @@ +# openab-telegram + +OpenAB + Telegram in a single pod — OAB agent, gateway, and Cloudflare Tunnel colocated. + +## Architecture + +``` +┌─────────────────────────────────────────────────────────────┐ +│ Pod: openab-telegram │ +│ │ +│ ┌───────────┐ ws://localhost:8080/ws ┌───────────┐ │ +│ │ openab │◄────────────────────────────►│ gateway │ │ +│ │ (agent) │ │ :8080 │ │ +│ └─────┬─────┘ └─────┬─────┘ │ +│ │ │ │ +│ │ /etc/openab/config.toml │ │ +│ │ /home/agent (PVC) │ │ +│ │ │ │ +│ ┌─────┴──────────────────────────────────────────┴─────┐ │ +│ │ localhost │ │ +│ └──────────────────────────┬───────────────────────────┘ │ +│ │ │ +│ ┌────────┴────────┐ │ +│ │ cloudflared │ │ +│ │ (tunnel) │ │ +│ └────────┬────────┘ │ +│ │ │ +└─────────────────────────────┼───────────────────────────────┘ + │ Cloudflare Tunnel + ▼ + ┌────────────────────────┐ + │ Cloudflare Edge │ + │ (bot.example.com) │ + └────────────┬───────────┘ + │ HTTPS + ▼ + ┌────────────────────────┐ + │ Telegram API │ + │ (webhook delivery) │ + └────────────────────────┘ +``` + +## Prerequisites + +Run these on your **local machine** (or CI) — one-time setup, no browser required. + +### 1. Create a Telegram bot + +```bash +# Use the Telegram Bot API directly (no app needed): +curl "https://api.telegram.org/bot/sendMessage" \ + -d "chat_id=@BotFather" -d "text=/newbot" + +# Or message @BotFather in Telegram and save the token it returns. +# The token looks like: 123456789:ABCdefGHIjklMNOpqrsTUVwxyz +``` + +### 2. Create a Cloudflare Tunnel (fully headless) + +```bash +# Install cloudflared +# macOS: brew install cloudflared +# Linux: curl -fsSL https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-linux-amd64 -o /usr/local/bin/cloudflared && chmod +x /usr/local/bin/cloudflared + +# Authenticate with API token (no browser — create token at https://dash.cloudflare.com/profile/api-tokens or via Terraform) +# Required permissions: Account:Cloudflare Tunnel:Edit, Zone:DNS:Edit +export CLOUDFLARE_API_TOKEN="your-api-token" + +# Or use service token auth: +cloudflared tunnel login # only option if no API token; opens browser once + +# Create the tunnel +cloudflared tunnel create my-telegram-bot + +# Route DNS (creates CNAME: bot.example.com → .cfargotunnel.com) +cloudflared tunnel route dns my-telegram-bot bot.example.com + +# Configure ingress (what the tunnel serves) +mkdir -p ~/.cloudflared +cat > ~/.cloudflared/config.yml < **Expected Secret keys:** `telegram-bot-token`, `cloudflare-tunnel-token` + +## Post-Install + +### Configure tunnel ingress (required for remote mode) + +The chart runs cloudflared in **remote mode** (token-based). Ingress rules must be configured via the Cloudflare API or dashboard — local config files are ignored. + +**Option A — API (recommended for AI-assisted installs):** + +Add `cloudflare-api-token` to your K8s Secret, then the helm NOTES provide a ready-to-run command. The AI can extract all credentials from the secret and configure ingress automatically. + +```bash +# Add API token to secret (required permissions: Account:Cloudflare Tunnel:Edit) +kubectl create secret generic my-bot-creds -n openab \ + --from-literal=telegram-bot-token="123:ABC" \ + --from-literal=cloudflare-tunnel-token="eyJ..." \ + --from-literal=cloudflare-api-token="cfut_..." + +# Extract IDs and configure +ACCOUNT_ID=$(kubectl get secret my-bot-creds -n openab -o jsonpath='{.data.cloudflare-tunnel-token}' | base64 -d | base64 -d | jq -r .a) +TUNNEL_ID=$(kubectl get secret my-bot-creds -n openab -o jsonpath='{.data.cloudflare-tunnel-token}' | base64 -d | base64 -d | jq -r .t) +CF_API_TOKEN=$(kubectl get secret my-bot-creds -n openab -o jsonpath='{.data.cloudflare-api-token}' | base64 -d) + +curl -X PUT "https://api.cloudflare.com/client/v4/accounts/${ACCOUNT_ID}/cfd_tunnel/${TUNNEL_ID}/configurations" \ + -H "Authorization: Bearer ${CF_API_TOKEN}" \ + -H "Content-Type: application/json" \ + -d '{"config":{"ingress":[{"hostname":"bot.example.com","service":"http://localhost:8080"},{"service":"http_status:404"}]}}' + +# Restart to pick up ingress +kubectl rollout restart deployment/my-bot -n openab +``` + +**Option B — Dashboard:** + +Go to https://one.dash.cloudflare.com/ → Networks → Tunnels → your tunnel → Public Hostname → Add: +- Hostname: `bot.example.com` +- Type: HTTP +- URL: `localhost:8080` + +### Authenticate the agent + +Kiro CLI requires a one-time OAuth login. The PVC persists tokens across restarts. + +```bash +kubectl exec -it deployment/my-bot -n openab -c openab -- kiro-cli login --use-device-flow +kubectl rollout restart deployment/my-bot -n openab +``` + +## AI-Assisted Install + +To have an AI agent handle the full install, prompt it with: + +> Follow the openab-telegram chart README at https://github.com/openabdev/openab/blob/main/charts/openab-telegram/README.md to deploy a Telegram bot on my Kubernetes cluster. +> +> I already have: +> - A Telegram bot token: `` +> - A Cloudflare account with `cloudflared` authenticated +> - A domain: `bot.example.com` +> - kubectl access to my cluster +> +> Create the tunnel, install the chart, and complete all post-install steps from the helm NOTES output (including configuring tunnel ingress via the API and setting the webhook). Store the cloudflare-api-token in the K8s secret so ingress can be configured programmatically. + +## Values + +| Key | Required | Default | Description | +|-----|----------|---------|-------------| +| `telegramBotToken` | Yes* | `""` | Telegram bot token | +| `cloudflareTunnelToken` | Yes* | `""` | Cloudflare Tunnel token | +| `existingSecret` | No | `""` | Pre-existing Secret name (skips token fields) | +| `webhookDomain` | No | `""` | Shown in post-install notes | +| `image.repository` | No | `ghcr.io/openabdev/openab` | Agent image | +| `image.tag` | No | `appVersion` | Agent image tag | +| `gateway.tag` | No | `v0.5.0` | Gateway image tag | +| `agent.command` | No | `kiro-cli` | Agent command | +| `platform.allowAllUsers` | No | `true` | Allow any Telegram user | +| `platform.allowedUsers` | No | `[]` | Allowed Telegram user IDs | +| `persistence.enabled` | No | `true` | Enable PVC for agent state | +| `persistence.size` | No | `1Gi` | PVC size | + +*Required unless `existingSecret` is set. diff --git a/charts/openab-telegram/templates/NOTES.txt b/charts/openab-telegram/templates/NOTES.txt new file mode 100644 index 000000000..73afd02aa --- /dev/null +++ b/charts/openab-telegram/templates/NOTES.txt @@ -0,0 +1,71 @@ +🎉 OpenAB Telegram bot deployed! + +Pod: {{ include "openab-telegram.fullname" . }} (3 containers: openab, gateway, cloudflared) + +## Post-Install Steps (complete in order) + +### Step 1: Configure Cloudflare Tunnel ingress + +The tunnel is running but has no ingress rules yet — webhook will return 503 until configured. + +Option A — Via Cloudflare API (AI-friendly): + + Extract credentials from the K8s secret: + ACCOUNT_ID=$(kubectl get secret {{ .Values.existingSecret | default (include "openab-telegram.fullname" .) }} -n {{ .Release.Namespace }} -o jsonpath='{.data.cloudflare-tunnel-token}' | base64 -d | base64 -d | jq -r .a) + TUNNEL_ID=$(kubectl get secret {{ .Values.existingSecret | default (include "openab-telegram.fullname" .) }} -n {{ .Release.Namespace }} -o jsonpath='{.data.cloudflare-tunnel-token}' | base64 -d | base64 -d | jq -r .t) + CF_API_TOKEN=$(kubectl get secret {{ .Values.existingSecret | default (include "openab-telegram.fullname" .) }} -n {{ .Release.Namespace }} -o jsonpath='{.data.cloudflare-api-token}' | base64 -d) + + Then configure ingress: + curl -X PUT "https://api.cloudflare.com/client/v4/accounts/${ACCOUNT_ID}/cfd_tunnel/${TUNNEL_ID}/configurations" \ + -H "Authorization: Bearer ${CF_API_TOKEN}" \ + -H "Content-Type: application/json" \ + -d '{"config":{"ingress":[{"hostname":"{{ .Values.webhookDomain | default "bot.example.com" }}","service":"http://localhost:8080"},{"service":"http_status:404"}]}}' + + After configuring, restart the pod to pick up the new ingress: + kubectl rollout restart deployment/{{ include "openab-telegram.fullname" . }} -n {{ .Release.Namespace }} + + Note: cloudflare-api-token is optional in the Secret. If not present, use Option B. + +Option B — Via Cloudflare Dashboard: + https://one.dash.cloudflare.com/ → Networks → Tunnels → your tunnel → Public Hostname → Add: + Hostname: {{ .Values.webhookDomain | default "bot.example.com" }} + Type: HTTP + URL: localhost:8080 + +{{- if .Values.webhookDomain }} + +### Step 2: Set the Telegram webhook + +Run this command to point Telegram at your tunnel: + + curl "https://api.telegram.org/bot$(kubectl get secret {{ .Values.existingSecret | default (include "openab-telegram.fullname" .) }} -n {{ .Release.Namespace }} -o jsonpath='{.data.telegram-bot-token}' | base64 -d)/setWebhook" \ + -d "url=https://{{ .Values.webhookDomain }}/webhook/telegram" + +{{- else }} + +### Step 2: Set the Telegram webhook + + curl "https://api.telegram.org/bot/setWebhook" \ + -d "url=https://YOUR_DOMAIN/webhook/telegram" + + Tip: pass --set webhookDomain=bot.example.com to get a ready-to-run command here. + +{{- end }} + +### Step 3: Authenticate the agent + +The agent needs a one-time OAuth login. Run the device flow: + + kubectl exec -it deployment/{{ include "openab-telegram.fullname" . }} -n {{ .Release.Namespace }} -c openab -- {{ .Values.agent.command }} login --use-device-flow + +Then restart to pick up credentials: + + kubectl rollout restart deployment/{{ include "openab-telegram.fullname" . }} -n {{ .Release.Namespace }} + +## Verify + +Send a message to your bot on Telegram. Check logs if no response: + + kubectl logs -n {{ .Release.Namespace }} deployment/{{ include "openab-telegram.fullname" . }} -c openab --tail=20 + kubectl logs -n {{ .Release.Namespace }} deployment/{{ include "openab-telegram.fullname" . }} -c gateway --tail=20 + kubectl logs -n {{ .Release.Namespace }} deployment/{{ include "openab-telegram.fullname" . }} -c cloudflared --tail=20 diff --git a/charts/openab-telegram/templates/_helpers.tpl b/charts/openab-telegram/templates/_helpers.tpl new file mode 100644 index 000000000..2bde55e1d --- /dev/null +++ b/charts/openab-telegram/templates/_helpers.tpl @@ -0,0 +1,32 @@ +{{- define "openab-telegram.fullname" -}} +{{- .Release.Name | trunc 63 | trimSuffix "-" }} +{{- end }} + +{{- define "openab-telegram.labels" -}} +helm.sh/chart: {{ printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" | trunc 63 }} +app.kubernetes.io/name: {{ .Chart.Name }} +app.kubernetes.io/instance: {{ .Release.Name }} +app.kubernetes.io/version: {{ .Chart.AppVersion | quote }} +app.kubernetes.io/managed-by: {{ .Release.Service }} +{{- end }} + +{{- define "openab-telegram.selectorLabels" -}} +app.kubernetes.io/name: {{ .Chart.Name }} +app.kubernetes.io/instance: {{ .Release.Name }} +{{- end }} + +{{- define "openab-telegram.agentImage" -}} +{{- $tag := .Values.image.tag -}} +{{- if not $tag -}} + {{- $tag = .Values.channel | default "stable" -}} +{{- end -}} +{{- printf "%s:%s" .Values.image.repository $tag -}} +{{- end }} + +{{- define "openab-telegram.gatewayImage" -}} +{{- printf "%s:%s" .Values.gateway.image .Values.gateway.tag -}} +{{- end }} + +{{- define "openab-telegram.secretName" -}} +{{- .Values.existingSecret | default (include "openab-telegram.fullname" .) -}} +{{- end }} \ No newline at end of file diff --git a/charts/openab-telegram/templates/configmap.yaml b/charts/openab-telegram/templates/configmap.yaml new file mode 100644 index 000000000..57527fe53 --- /dev/null +++ b/charts/openab-telegram/templates/configmap.yaml @@ -0,0 +1,36 @@ +apiVersion: v1 +kind: ConfigMap +metadata: + name: {{ include "openab-telegram.fullname" . }} + labels: + {{- include "openab-telegram.labels" . | nindent 4 }} +data: + config.toml: | + [agent] + command = {{ .Values.agent.command | toJson }} + args = {{ .Values.agent.args | default list | toJson }} + working_dir = {{ .Values.agent.workingDir | default "/home/agent" | toJson }} + {{- if .Values.agent.env }} + env = { {{ $first := true }}{{ range $k, $v := .Values.agent.env }}{{ if not $first }}, {{ end }}{{ $k }} = {{ $v | toJson }}{{ $first = false }}{{ end }} } + {{- end }} + {{- $secretEnvKeys := list }} + {{- range .Values.agent.secretEnv }}{{ $secretEnvKeys = append $secretEnvKeys .name }}{{ end }} + {{- if $secretEnvKeys }} + inherit_env = {{ $secretEnvKeys | toJson }} + {{- end }} + + [pool] + max_sessions = {{ .Values.agent.pool.maxSessions | default 10 }} + session_ttl_hours = {{ .Values.agent.pool.sessionTtlHours | default 24 }} + + [reactions] + enabled = {{ .Values.agent.reactions.enabled | default true }} + remove_after_reply = {{ .Values.agent.reactions.removeAfterReply | default false }} + + [gateway] + url = "ws://localhost:8080/ws" + platform = "telegram" + allow_all_channels = {{ if hasKey .Values.platform "allowAllChannels" }}{{ .Values.platform.allowAllChannels }}{{ else }}true{{ end }} + allowed_channels = {{ .Values.platform.allowedChannels | default list | toJson }} + allow_all_users = {{ if hasKey .Values.platform "allowAllUsers" }}{{ .Values.platform.allowAllUsers }}{{ else }}true{{ end }} + allowed_users = {{ .Values.platform.allowedUsers | default list | toJson }} diff --git a/charts/openab-telegram/templates/deployment.yaml b/charts/openab-telegram/templates/deployment.yaml new file mode 100644 index 000000000..852ee61a0 --- /dev/null +++ b/charts/openab-telegram/templates/deployment.yaml @@ -0,0 +1,140 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: {{ include "openab-telegram.fullname" . }} + labels: + {{- include "openab-telegram.labels" . | nindent 4 }} +spec: + replicas: 1 + strategy: + type: Recreate + selector: + matchLabels: + {{- include "openab-telegram.selectorLabels" . | nindent 6 }} + template: + metadata: + annotations: + checksum/config: {{ include (print .Template.BasePath "/configmap.yaml") . | sha256sum }} + checksum/secret: {{ include (print .Template.BasePath "/secret.yaml") . | sha256sum }} + labels: + {{- include "openab-telegram.selectorLabels" . | nindent 8 }} + spec: + {{- with .Values.podSecurityContext }} + securityContext: + {{- toYaml . | nindent 8 }} + {{- end }} + containers: + # --- OAB agent (main) --- + - name: openab + image: {{ include "openab-telegram.agentImage" . | quote }} + imagePullPolicy: {{ .Values.image.pullPolicy }} + {{- with .Values.containerSecurityContext }} + securityContext: + {{- toYaml . | nindent 12 }} + {{- end }} + env: + - name: HOME + value: {{ .Values.agent.workingDir | default "/home/agent" }} + {{- range $k, $v := .Values.agent.env }} + - name: {{ $k }} + value: {{ $v | quote }} + {{- end }} + {{- range .Values.agent.secretEnv }} + - name: {{ .name }} + valueFrom: + secretKeyRef: + name: {{ .secretName }} + key: {{ .secretKey }} + {{- end }} + {{- with .Values.resources }} + resources: + {{- toYaml . | nindent 12 }} + {{- end }} + volumeMounts: + - name: config + mountPath: /etc/openab + readOnly: true + {{- if .Values.persistence.enabled }} + - name: data + mountPath: {{ .Values.agent.workingDir | default "/home/agent" }} + {{- end }} + - name: tmp + mountPath: /tmp + + # --- Gateway (sidecar) --- + - name: gateway + image: {{ include "openab-telegram.gatewayImage" . | quote }} + imagePullPolicy: {{ .Values.image.pullPolicy }} + {{- with .Values.containerSecurityContext }} + securityContext: + {{- toYaml . | nindent 12 }} + {{- end }} + ports: + - name: http + containerPort: 8080 + protocol: TCP + env: + - name: HOME + value: {{ .Values.agent.workingDir | default "/home/agent" }} + - name: TELEGRAM_BOT_TOKEN + valueFrom: + secretKeyRef: + name: {{ include "openab-telegram.secretName" . }} + key: telegram-bot-token + volumeMounts: + {{- if .Values.persistence.enabled }} + - name: data + mountPath: {{ .Values.agent.workingDir | default "/home/agent" }} + {{- end }} + - name: tmp + mountPath: /tmp + + # --- Cloudflared tunnel (sidecar) --- + - name: cloudflared + image: {{ printf "%s:%s" .Values.cloudflared.image .Values.cloudflared.tag }} + imagePullPolicy: IfNotPresent + securityContext: + allowPrivilegeEscalation: false + readOnlyRootFilesystem: true + capabilities: + drop: + - ALL + args: + - tunnel + - --no-autoupdate + - run + - --token + - $(TUNNEL_TOKEN) + env: + - name: TUNNEL_TOKEN + valueFrom: + secretKeyRef: + name: {{ include "openab-telegram.secretName" . }} + key: cloudflare-tunnel-token + volumeMounts: + - name: tmp + mountPath: /tmp + + {{- with .Values.nodeSelector }} + nodeSelector: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.affinity }} + affinity: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.tolerations }} + tolerations: + {{- toYaml . | nindent 8 }} + {{- end }} + volumes: + - name: config + configMap: + name: {{ include "openab-telegram.fullname" . }} + {{- if .Values.persistence.enabled }} + - name: data + persistentVolumeClaim: + claimName: {{ .Values.persistence.existingClaim | default (include "openab-telegram.fullname" .) }} + {{- end }} + - name: tmp + emptyDir: {} diff --git a/charts/openab-telegram/templates/pvc.yaml b/charts/openab-telegram/templates/pvc.yaml new file mode 100644 index 000000000..d332e9596 --- /dev/null +++ b/charts/openab-telegram/templates/pvc.yaml @@ -0,0 +1,19 @@ +{{- if and .Values.persistence.enabled (not .Values.persistence.existingClaim) }} +apiVersion: v1 +kind: PersistentVolumeClaim +metadata: + name: {{ include "openab-telegram.fullname" . }} + annotations: + "helm.sh/resource-policy": keep + labels: + {{- include "openab-telegram.labels" . | nindent 4 }} +spec: + accessModes: + - ReadWriteOnce + {{- if .Values.persistence.storageClass }} + storageClassName: {{ .Values.persistence.storageClass | quote }} + {{- end }} + resources: + requests: + storage: {{ .Values.persistence.size | default "1Gi" }} +{{- end }} diff --git a/charts/openab-telegram/templates/secret.yaml b/charts/openab-telegram/templates/secret.yaml new file mode 100644 index 000000000..b50f50445 --- /dev/null +++ b/charts/openab-telegram/templates/secret.yaml @@ -0,0 +1,21 @@ +{{- if not .Values.existingSecret }} +{{- if not .Values.telegramBotToken }} +{{- fail "telegramBotToken is required when existingSecret is not set (--set telegramBotToken=YOUR_TOKEN)" }} +{{- end }} +{{- if not .Values.cloudflareTunnelToken }} +{{- fail "cloudflareTunnelToken is required when existingSecret is not set (--set cloudflareTunnelToken=YOUR_TOKEN)" }} +{{- end }} +apiVersion: v1 +kind: Secret +metadata: + name: {{ include "openab-telegram.fullname" . }} + labels: + {{- include "openab-telegram.labels" . | nindent 4 }} +type: Opaque +stringData: + telegram-bot-token: {{ .Values.telegramBotToken | quote }} + cloudflare-tunnel-token: {{ .Values.cloudflareTunnelToken | quote }} + {{- if .Values.cloudflareApiToken }} + cloudflare-api-token: {{ .Values.cloudflareApiToken | quote }} + {{- end }} +{{- end }} diff --git a/charts/openab-telegram/values.yaml b/charts/openab-telegram/values.yaml new file mode 100644 index 000000000..31641a2bd --- /dev/null +++ b/charts/openab-telegram/values.yaml @@ -0,0 +1,109 @@ +# openab-telegram values +# +# Install: +# helm install my-bot ./charts/openab-telegram \ +# --set telegramBotToken="123:ABC" \ +# --set cloudflareTunnelToken="eyJ..." \ +# --namespace openab --create-namespace +# +# Required: +# telegramBotToken -- Telegram bot token from @BotFather +# cloudflareTunnelToken -- Cloudflare Tunnel token +# +# Optional: +# webhookDomain -- your tunnel domain (for setWebhook reminder in NOTES.txt) + +# -- (required unless existingSecret is set) Telegram bot token +telegramBotToken: "" + +# -- (required unless existingSecret is set) Cloudflare Tunnel token +cloudflareTunnelToken: "" + +# -- (optional) Cloudflare API token for automated ingress configuration. +# Required permissions: Account:Cloudflare Tunnel:Edit +# If set, post-install NOTES provide a ready-to-run curl command for AI agents. +cloudflareApiToken: "" + +# -- Use a pre-existing K8s Secret instead of creating one from --set values. +# The Secret must contain keys: telegram-bot-token, cloudflare-tunnel-token +# See README.md for credential management options. +existingSecret: "" + +# -- Tunnel domain (shown in post-install notes for setWebhook) +webhookDomain: "" + +# -- Release channel: "stable" or "beta" +# Resolves to the floating image tag of the same name. +# stable = ghcr.io/openabdev/openab:stable (latest stable release) +# beta = ghcr.io/openabdev/openab:beta (latest pre-release) +channel: stable + +# -- OAB agent image +image: + repository: ghcr.io/openabdev/openab + tag: "" # defaults to appVersion + pullPolicy: IfNotPresent + +# -- Gateway image +gateway: + image: ghcr.io/openabdev/openab-gateway + tag: "v0.5.1" + +# -- Cloudflared image +cloudflared: + image: cloudflare/cloudflared + tag: "2026.5.0" + +# -- Agent configuration +agent: + command: kiro-cli + args: + - acp + - --trust-all-tools + workingDir: /home/agent + env: {} + secretEnv: [] + # -- Pool settings + pool: + maxSessions: 10 + sessionTtlHours: 24 + # -- Reaction settings + reactions: + enabled: true + removeAfterReply: false + +# -- Gateway platform settings +platform: + # allowAllUsers: true = any Telegram user can talk to the bot + allowAllUsers: true + allowedUsers: [] + allowAllChannels: true + allowedChannels: [] + +# -- Persistence for agent working directory +persistence: + enabled: true + existingClaim: "" + storageClass: "" + size: 1Gi + +# -- Pod-level settings +resources: {} +nodeSelector: {} +tolerations: [] +affinity: {} + +podSecurityContext: + runAsNonRoot: true + runAsUser: 1000 + runAsGroup: 1000 + fsGroup: 1000 + seccompProfile: + type: RuntimeDefault + +containerSecurityContext: + allowPrivilegeEscalation: false + readOnlyRootFilesystem: true + capabilities: + drop: + - ALL From ef9adeec675c4ef96bd935d0cb62506cb4e042be Mon Sep 17 00:00:00 2001 From: chaodufashi Date: Wed, 20 May 2026 22:00:25 -0400 Subject: [PATCH 062/100] fix(openab-telegram): default allowAllUsers to false (#882) * fix(openab-telegram): default allowAllUsers to false Security improvement: the chart now defaults to denying all users, requiring deployers to explicitly specify their Telegram user ID(s) via platform.allowedUsers. Users can find their ID by messaging @userinfobot on Telegram. This prevents accidental wide-open bots when using the default values. * docs: use OCI registry path instead of local chart path Users shouldn't need to git clone the repo to install. Use oci://ghcr.io/openabdev/charts/openab-telegram instead. --------- Co-authored-by: chaodu-agent --- charts/openab-telegram/README.md | 14 ++++++++------ charts/openab-telegram/values.yaml | 13 ++++++++++--- 2 files changed, 18 insertions(+), 9 deletions(-) diff --git a/charts/openab-telegram/README.md b/charts/openab-telegram/README.md index 2ef4a7690..acd29bd73 100644 --- a/charts/openab-telegram/README.md +++ b/charts/openab-telegram/README.md @@ -101,10 +101,12 @@ curl -s "https://api.telegram.org/bot${BOT_TOKEN}/setWebhook" \ ## Quick Start ```bash -helm install my-bot ./charts/openab-telegram \ +# Find your Telegram user ID by messaging @userinfobot on Telegram. +helm install my-bot oci://ghcr.io/openabdev/charts/openab-telegram \ --set telegramBotToken="" \ --set cloudflareTunnelToken="$(cloudflared tunnel token my-telegram-bot)" \ --set webhookDomain=bot.example.com \ + --set platform.allowedUsers="{}" \ --namespace openab --create-namespace ``` @@ -115,7 +117,7 @@ Three options, from simplest to most secure: ### Option 1: `--set` (simple, least secure) ```bash -helm install my-bot ./charts/openab-telegram \ +helm install my-bot oci://ghcr.io/openabdev/charts/openab-telegram \ --set telegramBotToken="123:ABC" \ --set cloudflareTunnelToken="eyJ..." \ --namespace openab --create-namespace @@ -132,7 +134,7 @@ kubectl create secret generic my-bot-creds -n openab \ --from-literal=telegram-bot-token="123:ABC" \ --from-literal=cloudflare-tunnel-token="eyJ..." -helm install my-bot ./charts/openab-telegram \ +helm install my-bot oci://ghcr.io/openabdev/charts/openab-telegram \ --set existingSecret=my-bot-creds \ --namespace openab ``` @@ -149,7 +151,7 @@ kubectl create secret generic my-bot-creds -n openab \ --secret-id oab --query SecretString --output text | \ jq -r '{"telegram-bot-token": .telegramBotToken, "cloudflare-tunnel-token": .cloudflareTunnelToken} | to_entries[] | "\(.key)=\(.value)"') -helm install my-bot ./charts/openab-telegram \ +helm install my-bot oci://ghcr.io/openabdev/charts/openab-telegram \ --set existingSecret=my-bot-creds \ --namespace openab ``` @@ -231,8 +233,8 @@ To have an AI agent handle the full install, prompt it with: | `image.tag` | No | `appVersion` | Agent image tag | | `gateway.tag` | No | `v0.5.0` | Gateway image tag | | `agent.command` | No | `kiro-cli` | Agent command | -| `platform.allowAllUsers` | No | `true` | Allow any Telegram user | -| `platform.allowedUsers` | No | `[]` | Allowed Telegram user IDs | +| `platform.allowAllUsers` | No | `false` | Allow any Telegram user (opt-in) | +| `platform.allowedUsers` | No | `[]` | Allowed Telegram user IDs (get yours from [@userinfobot](https://t.me/userinfobot)) | | `persistence.enabled` | No | `true` | Enable PVC for agent state | | `persistence.size` | No | `1Gi` | PVC size | diff --git a/charts/openab-telegram/values.yaml b/charts/openab-telegram/values.yaml index 31641a2bd..2e4399de1 100644 --- a/charts/openab-telegram/values.yaml +++ b/charts/openab-telegram/values.yaml @@ -1,15 +1,19 @@ # openab-telegram values # # Install: -# helm install my-bot ./charts/openab-telegram \ +# helm install my-bot oci://ghcr.io/openabdev/charts/openab-telegram \ # --set telegramBotToken="123:ABC" \ # --set cloudflareTunnelToken="eyJ..." \ +# --set platform.allowedUsers="{YOUR_TG_USER_ID}" \ # --namespace openab --create-namespace # # Required: # telegramBotToken -- Telegram bot token from @BotFather # cloudflareTunnelToken -- Cloudflare Tunnel token # +# Recommended: +# platform.allowedUsers -- Your Telegram user ID (message @userinfobot to find it) +# # Optional: # webhookDomain -- your tunnel domain (for setWebhook reminder in NOTES.txt) @@ -74,8 +78,11 @@ agent: # -- Gateway platform settings platform: - # allowAllUsers: true = any Telegram user can talk to the bot - allowAllUsers: true + # allowAllUsers: false = only users listed in allowedUsers can talk to the bot + # Set to true to allow any Telegram user (not recommended for production) + allowAllUsers: false + # Telegram user IDs allowed to interact with the bot. + # Find your ID by messaging @userinfobot on Telegram. allowedUsers: [] allowAllChannels: true allowedChannels: [] From 28c212edc0d055a48a7435d70a9c4e07f29a2d6a Mon Sep 17 00:00:00 2001 From: chaodufashi Date: Thu, 21 May 2026 15:20:35 -0400 Subject: [PATCH 063/100] chore: bump all coding CLI versions to latest (#888) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - kiro-cli: 2.2.0 → 2.4.0 - claude-code: 2.1.124 → 2.1.146 (+ add bubblewrap/socat for sandbox) - codex: 0.128.0 → 0.133.0 - copilot: 1.0.40 → 1.0.51 - cursor: 2026.04.30-4edb302 → 2026.05.20-2b5dd59 - gemini-cli: 0.40.1 → 0.42.0 - opencode-ai: 1.14.31 → 1.15.7 Claude Code v2.1.144+ requires bubblewrap and socat for its sandbox on Linux. Without them the CLI silently exits (anthropics/claude-code#61094). Added both packages to Dockerfile.claude apt-get layer. Co-authored-by: 張飛 (Zhang Fei) --- Dockerfile | 2 +- Dockerfile.claude | 4 ++-- Dockerfile.codex | 2 +- Dockerfile.copilot | 2 +- Dockerfile.cursor | 2 +- Dockerfile.gemini | 2 +- Dockerfile.opencode | 2 +- 7 files changed, 8 insertions(+), 8 deletions(-) diff --git a/Dockerfile b/Dockerfile index 1f64ab523..c4e2cb063 100644 --- a/Dockerfile +++ b/Dockerfile @@ -11,7 +11,7 @@ FROM debian:bookworm-slim RUN apt-get update && apt-get install -y --no-install-recommends ca-certificates curl procps ripgrep tini unzip && rm -rf /var/lib/apt/lists/* # Install kiro-cli (auto-detect arch, copy binary directly) -ARG KIRO_CLI_VERSION=2.2.0 +ARG KIRO_CLI_VERSION=2.4.0 RUN ARCH=$(dpkg --print-architecture) && \ if [ "$ARCH" = "arm64" ]; then URL="https://prod.download.cli.kiro.dev/stable/${KIRO_CLI_VERSION}/kirocli-aarch64-linux.zip"; \ else URL="https://prod.download.cli.kiro.dev/stable/${KIRO_CLI_VERSION}/kirocli-x86_64-linux.zip"; fi && \ diff --git a/Dockerfile.claude b/Dockerfile.claude index e1a1de6a4..03ed1c3e3 100644 --- a/Dockerfile.claude +++ b/Dockerfile.claude @@ -8,13 +8,13 @@ RUN touch src/main.rs && cargo build --release # --- Runtime stage --- FROM node:22-bookworm-slim -RUN apt-get update && apt-get install -y --no-install-recommends ca-certificates curl procps ripgrep tini && rm -rf /var/lib/apt/lists/* +RUN apt-get update && apt-get install -y --no-install-recommends ca-certificates curl procps ripgrep tini bubblewrap socat && rm -rf /var/lib/apt/lists/* # Install claude-agent-acp adapter and Claude Code CLI. # Without CLAUDE_CODE_EXECUTABLE the adapter uses its own bundled SDK cli.js, # ignoring the globally installed claude-code binary (see #418). ARG CLAUDE_AGENT_ACP_VERSION=0.29.2 -ARG CLAUDE_CODE_VERSION=2.1.124 +ARG CLAUDE_CODE_VERSION=2.1.146 RUN npm install -g @agentclientprotocol/claude-agent-acp@${CLAUDE_AGENT_ACP_VERSION} @anthropic-ai/claude-code@${CLAUDE_CODE_VERSION} --retry 3 ENV CLAUDE_CODE_EXECUTABLE=/usr/local/bin/claude diff --git a/Dockerfile.codex b/Dockerfile.codex index 6f1ad7dbc..2faa49ebc 100644 --- a/Dockerfile.codex +++ b/Dockerfile.codex @@ -12,7 +12,7 @@ RUN apt-get update && apt-get install -y --no-install-recommends ca-certificates # Pre-install codex-acp and codex CLI globally ARG CODEX_ACP_VERSION=0.10.0 -ARG CODEX_VERSION=0.128.0 +ARG CODEX_VERSION=0.133.0 RUN npm install -g @zed-industries/codex-acp@${CODEX_ACP_VERSION} @openai/codex@${CODEX_VERSION} --retry 3 # Install gh CLI diff --git a/Dockerfile.copilot b/Dockerfile.copilot index c2c75f395..99b81696d 100644 --- a/Dockerfile.copilot +++ b/Dockerfile.copilot @@ -11,7 +11,7 @@ FROM node:22-bookworm-slim RUN apt-get update && apt-get install -y --no-install-recommends ca-certificates curl procps ripgrep tini && rm -rf /var/lib/apt/lists/* # Install GitHub Copilot CLI via npm (pinned version) -ARG COPILOT_VERSION=1.0.40 +ARG COPILOT_VERSION=1.0.51 RUN npm install -g @github/copilot@${COPILOT_VERSION} --retry 3 # Install gh CLI (for auth and token management) diff --git a/Dockerfile.cursor b/Dockerfile.cursor index 6bb7035c8..eb37d2600 100644 --- a/Dockerfile.cursor +++ b/Dockerfile.cursor @@ -15,7 +15,7 @@ RUN apt-get update && apt-get install -y --no-install-recommends ca-certificates # URL scheme scraped from Cursor's official downloads page — no apt/yum package exists. # If Cursor changes this pattern, the build fails with curl 404. Monitor # https://cursor.com/cli or https://docs.cursor.com/cli for version/URL updates. -ARG CURSOR_VERSION=2026.04.30-4edb302 +ARG CURSOR_VERSION=2026.05.20-2b5dd59 RUN ARCH=$(dpkg --print-architecture) && \ if [ "$ARCH" = "arm64" ]; then ARCH=arm64; else ARCH=x64; fi && \ curl -fSL "https://downloads.cursor.com/lab/${CURSOR_VERSION}/linux/${ARCH}/agent-cli-package.tar.gz" \ diff --git a/Dockerfile.gemini b/Dockerfile.gemini index 506dac690..5fdd3c378 100644 --- a/Dockerfile.gemini +++ b/Dockerfile.gemini @@ -11,7 +11,7 @@ FROM node:22-bookworm-slim RUN apt-get update && apt-get install -y --no-install-recommends ca-certificates curl procps ripgrep tini && rm -rf /var/lib/apt/lists/* # Install Gemini CLI (native ACP support via --acp) -ARG GEMINI_CLI_VERSION=0.40.1 +ARG GEMINI_CLI_VERSION=0.42.0 RUN npm install -g @google/gemini-cli@${GEMINI_CLI_VERSION} --retry 3 # Install gh CLI diff --git a/Dockerfile.opencode b/Dockerfile.opencode index 7845a72c3..9c12a4970 100644 --- a/Dockerfile.opencode +++ b/Dockerfile.opencode @@ -26,7 +26,7 @@ FROM node:22-bookworm-slim RUN apt-get update && apt-get install -y --no-install-recommends ca-certificates curl procps ripgrep tini && rm -rf /var/lib/apt/lists/* # Install opencode -ARG OPENCODE_VERSION=1.14.31 +ARG OPENCODE_VERSION=1.15.7 RUN npm install -g opencode-ai@${OPENCODE_VERSION} --retry 3 # Install gh CLI (matches Dockerfile.claude / Dockerfile.gemini / Dockerfile.codex) From 192f32f60d909a4a82c0dce6b784380d88049979 Mon Sep 17 00:00:00 2001 From: chaodufashi Date: Thu, 21 May 2026 15:25:35 -0400 Subject: [PATCH 064/100] fix: capture ACP agent stderr and extract error.data from JSON-RPC errors (#885) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: capture ACP agent stderr and extract error.data from JSON-RPC errors Closes #854. - Pipe agent stderr and log each line at WARN level (scoped to agent command name) so operators see the real cause in kubectl logs. - Add optional `data` field to `JsonRpcError` struct to capture the JSON-RPC error.data payload that agents like codex-acp include. - Surface `error.data.message` in the Discord-facing error display (as a blockquote below the coded error) so users get actionable detail instead of only the opaque "-32603 Internal error". - Deduplicate: if data.message already appears in the top-level message, it is not repeated. * fix: sanitize control chars from agent stderr before logging Strip control characters (except tab) from stderr lines before emitting to tracing::warn, preventing log injection or terminal escape sequences from reaching kubectl logs. Addresses review feedback from 普渡法師. * fix: store stderr task handle for abort on drop + doc comment on data_message convention - Store stderr reader JoinHandle in AcpConnection struct; abort it on drop to prevent lingering tasks in long-running pods. - Add doc comment to data_message() clarifying that the "message" key is a convention (codex-acp, JSON-RPC practice), not an ACP spec requirement — extend if other agents use different keys. Addresses NIT feedback from 司馬懿. --------- Co-authored-by: chaodu-agent --- src/acp/connection.rs | 38 +++++++++++++++++++++++++++++++++++++- src/acp/protocol.rs | 23 ++++++++++++++++++++++- src/adapter.rs | 2 +- src/error_display.rs | 40 +++++++++++++++++++++++++++++++--------- 4 files changed, 91 insertions(+), 12 deletions(-) diff --git a/src/acp/connection.rs b/src/acp/connection.rs index 90c0eae24..8df3451f4 100644 --- a/src/acp/connection.rs +++ b/src/acp/connection.rs @@ -121,6 +121,7 @@ pub struct AcpConnection { pub last_active: Instant, pub session_reset: bool, _reader_handle: JoinHandle<()>, + _stderr_handle: Option>, } /// Build the final set of env vars for the agent subprocess. @@ -246,6 +247,7 @@ pub(crate) async fn run_reader_loop( error: Some(crate::acp::protocol::JsonRpcError { code: -1, message: "connection closed".into(), + data: None, }), params: None, }); @@ -269,7 +271,7 @@ impl AcpConnection { cmd.args(args) .stdin(std::process::Stdio::piped()) .stdout(std::process::Stdio::piped()) - .stderr(std::process::Stdio::null()) + .stderr(std::process::Stdio::piped()) .current_dir(working_dir); // Create a new process group so we can kill the entire tree. // SAFETY: setpgid is async-signal-safe (POSIX.1-2008) and called @@ -355,6 +357,36 @@ impl AcpConnection { let stdin = proc.stdin.take().ok_or_else(|| anyhow!("no stdin"))?; let stdin = Arc::new(Mutex::new(stdin)); + // Capture agent stderr and log it (ACP spec: agents MAY write to stderr + // for logging; clients MAY capture or ignore it). + let stderr_handle = if let Some(stderr) = proc.stderr.take() { + let cmd_name = command.to_string(); + Some(tokio::spawn(async move { + let mut reader = BufReader::new(stderr); + let mut line = String::new(); + loop { + line.clear(); + match reader.read_line(&mut line).await { + Ok(0) => break, + Ok(_) => { + let trimmed = line.trim(); + if !trimmed.is_empty() { + let sanitized: String = trimmed.chars() + .filter(|c| !c.is_control() || *c == '\t') + .collect(); + if !sanitized.is_empty() { + tracing::warn!(agent = %cmd_name, "{sanitized}"); + } + } + } + Err(_) => break, + } + } + })) + } else { + None + }; + let pending: Arc>>> = Arc::new(Mutex::new(HashMap::new())); let notify_tx: Arc>>> = @@ -380,6 +412,7 @@ impl AcpConnection { last_active: Instant::now(), session_reset: false, _reader_handle: reader_handle, + _stderr_handle: stderr_handle, }) } @@ -657,6 +690,9 @@ impl AcpConnection { impl Drop for AcpConnection { fn drop(&mut self) { + if let Some(handle) = self._stderr_handle.take() { + handle.abort(); + } self.kill_process_group(); } } diff --git a/src/acp/protocol.rs b/src/acp/protocol.rs index 40dfdf070..099d98b71 100644 --- a/src/acp/protocol.rs +++ b/src/acp/protocol.rs @@ -55,11 +55,32 @@ pub struct JsonRpcMessage { pub struct JsonRpcError { pub code: i64, pub message: String, + /// Optional structured data from the agent (JSON-RPC `error.data`). + /// Agents like codex-acp include `{"message": "...", "codex_error_info": "..."}`. + pub data: Option, +} + +impl JsonRpcError { + /// Extract a human-readable detail from `error.data.message` if present. + /// + /// The `"message"` key is a convention used by codex-acp and aligns with + /// common JSON-RPC practice, but is NOT mandated by the ACP spec. + /// Other agents may use `"detail"`, `"reason"`, etc. — extend here if needed. + pub fn data_message(&self) -> Option<&str> { + self.data + .as_ref() + .and_then(|d| d.get("message")) + .and_then(|m| m.as_str()) + } } impl std::fmt::Display for JsonRpcError { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - write!(f, "JSON-RPC error {}: {}", self.code, self.message) + write!(f, "JSON-RPC error {}: {}", self.code, self.message)?; + if let Some(detail) = self.data_message() { + write!(f, " — {detail}")?; + } + Ok(()) } } diff --git a/src/adapter.rs b/src/adapter.rs index c8a2be450..baaf2c783 100644 --- a/src/adapter.rs +++ b/src/adapter.rs @@ -564,7 +564,7 @@ impl AdapterRouter { continue; } if let Some(ref err) = notification.error { - response_error = Some(format_coded_error(err.code, &err.message)); + response_error = Some(format_coded_error(err.code, &err.message, err.data_message())); } break; } diff --git a/src/error_display.rs b/src/error_display.rs index b4e3a8509..e822f503f 100644 --- a/src/error_display.rs +++ b/src/error_display.rs @@ -50,8 +50,9 @@ pub fn format_user_error(message: &str) -> String { /// Format coded error from ACP agent for display in Discord. /// Used for response errors that have a JSON-RPC or HTTP status code. +/// `data_message` is the optional detail extracted from `error.data.message`. /// Public for reuse by other adapters (e.g. Slack). -pub fn format_coded_error(code: i64, message: &str) -> String { +pub fn format_coded_error(code: i64, message: &str, data_message: Option<&str>) -> String { let prefix = match code { 400 => "**Bad Request**", 401 => "**Unauthorized**", @@ -70,11 +71,18 @@ pub fn format_coded_error(code: i64, message: &str) -> String { -32099..=-32000 => "**Server Error**", _ => "**Error**", }; - if message.is_empty() { + let mut out = if message.is_empty() { format!("{} (code: {})", prefix, code) } else { format!("{} (code: {})\n{}", prefix, code, message) + }; + if let Some(detail) = data_message { + if !detail.is_empty() && !message.contains(detail) { + out.push_str("\n> "); + out.push_str(detail); + } } + out } #[cfg(test)] @@ -166,7 +174,7 @@ mod tests { #[test] fn test_format_coded_error_401() { - let result = format_coded_error(401, "invalid token"); + let result = format_coded_error(401, "invalid token", None); assert!(result.contains("Unauthorized")); assert!(result.contains("401")); assert!(result.contains("invalid token")); @@ -174,7 +182,7 @@ mod tests { #[test] fn test_format_coded_error_429() { - let result = format_coded_error(429, ""); + let result = format_coded_error(429, "", None); assert!(result.contains("Rate Limited")); assert!(result.contains("429")); assert!(!result.contains("\n")); // no message, no newline @@ -182,7 +190,7 @@ mod tests { #[test] fn test_format_coded_error_503() { - let result = format_coded_error(503, "service unavailable"); + let result = format_coded_error(503, "service unavailable", None); assert!(result.contains("Service Unavailable")); assert!(result.contains("503")); assert!(result.contains("service unavailable")); @@ -190,30 +198,44 @@ mod tests { #[test] fn test_format_coded_error_json_rpc() { - let result = format_coded_error(-32602, "missing required parameter"); + let result = format_coded_error(-32602, "missing required parameter", None); assert!(result.contains("Invalid Params")); assert!(result.contains("-32602")); } #[test] fn test_format_coded_error_server_error_range() { - let result = format_coded_error(-32050, "internal failure"); + let result = format_coded_error(-32050, "internal failure", None); assert!(result.contains("Server Error")); assert!(result.contains("-32050")); } #[test] fn test_format_coded_error_connection_error() { - let result = format_coded_error(-32000, "connection refused"); + let result = format_coded_error(-32000, "connection refused", None); assert!(result.contains("Server Error")); // -32000 falls in -32099..=-32000 range assert!(result.contains("-32000")); } #[test] fn test_format_coded_error_unknown_code() { - let result = format_coded_error(999, "something happened"); + let result = format_coded_error(999, "something happened", None); assert!(result.contains("Error")); assert!(result.contains("999")); assert!(result.contains("something happened")); } + + #[test] + fn test_format_coded_error_with_data_message() { + let result = format_coded_error(-32603, "Internal error", Some("model not supported")); + assert!(result.contains("Internal Error")); + assert!(result.contains("model not supported")); + } + + #[test] + fn test_format_coded_error_data_message_not_duplicated() { + // If data_message is already in message, don't repeat it + let result = format_coded_error(-32603, "model not supported", Some("model not supported")); + assert_eq!(result.matches("model not supported").count(), 1); + } } From 459d9f5ed8b3a5b7a2821cb34a111307efde803c Mon Sep 17 00:00:00 2001 From: chaodufashi Date: Thu, 21 May 2026 15:33:11 -0400 Subject: [PATCH 065/100] chore: remove pending-screening workflow (#890) This labeling behavior is no longer needed. Co-authored-by: chaodu-agent --- .github/workflows/pending-screening.yml | 48 ------------------------- 1 file changed, 48 deletions(-) delete mode 100644 .github/workflows/pending-screening.yml diff --git a/.github/workflows/pending-screening.yml b/.github/workflows/pending-screening.yml deleted file mode 100644 index a83f657d4..000000000 --- a/.github/workflows/pending-screening.yml +++ /dev/null @@ -1,48 +0,0 @@ -name: PR Pending Screening -on: - pull_request_target: - types: [opened, reopened] - workflow_dispatch: - inputs: - pr_number: - description: "PR number to add pending-screening label" - required: true - type: number - -permissions: {} - -jobs: - add-label: - if: github.event.pull_request.draft == false || github.event_name == 'workflow_dispatch' - runs-on: ubuntu-latest - permissions: - pull-requests: write - steps: - - uses: actions/github-script@v7 - with: - script: | - const prNumber = context.payload.pull_request?.number || ${{ github.event.inputs.pr_number || 0 }}; - if (!prNumber) { - core.setFailed('No PR number found'); - return; - } - - // Ensure label exists - try { - await github.rest.issues.createLabel({ - ...context.repo, - name: 'pending-screening', - color: 'fbca04', - description: 'PR awaiting automated screening' - }); - core.info('Created pending-screening label'); - } catch (e) { - if (e.status !== 422) throw e; // 422 = already exists - } - - await github.rest.issues.addLabels({ - ...context.repo, - issue_number: prNumber, - labels: ['pending-screening'] - }); - core.info(`Added pending-screening to #${prNumber}`); From 31ec34b9f33e17f6f90a59c20ad297a54615de04 Mon Sep 17 00:00:00 2001 From: howie <2318485+howie@users.noreply.github.com> Date: Fri, 22 May 2026 03:50:06 +0800 Subject: [PATCH 066/100] chore: ignore .claude/ and .claude.local.md (#837) Co-authored-by: Claude Sonnet 4.6 --- .gitignore | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index 26834d0d4..fe7eedff9 100644 --- a/.gitignore +++ b/.gitignore @@ -1,8 +1,12 @@ /target +gateway/target/ config.toml *.swp .DS_Store .env .kiro/ + +# Claude Code (https://claude.ai/code) CLAUDE.md -gateway/target/ +.claude/ +.claude.local.md From f60b7f4e0c773aad82299228abbb9c8987cd4c0d Mon Sep 17 00:00:00 2001 From: chaodufashi Date: Thu, 21 May 2026 15:56:53 -0400 Subject: [PATCH 067/100] docs(config): fix claude examples to use claude-agent-acp (#892) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * docs(config): fix claude examples to use claude-agent-acp Both config.toml.example and docs/config-reference.md used `command = "claude"` with `args = ["--acp"]`, but the Claude Code CLI has no --acp flag. ACP support is provided by the separate @agentclientprotocol/claude-agent-acp npm package. Update both files to match docs/claude-code.md: - command = "claude-agent-acp" - args = [] - env = { CLAUDE_CODE_OAUTH_TOKEN } (OAuth-first, per project recommendation) Fixes #632 * docs(config): use neutral env example * docs(config): address review feedback on claude auth examples - Fix install hint to include both required packages: @anthropic-ai/claude-code and @agentclientprotocol/claude-agent-acp - Remove CLAUDE_CODE_OAUTH_TOKEN env var example; replace with claude auth login + PVC comment per docs/claude-code.md - Remove misleading 'or set CLAUDE_CODE_OAUTH_TOKEN' phrasing - Update command field example: claude -> claude-agent-acp Fixes findings from chaodu-agent, howie, canyugs review. * docs(config): remove npm install hint — adapter is baked into container image --------- Co-authored-by: feiyun968-agent Co-authored-by: shaun-agent Co-authored-by: chaodu-agent --- config.toml.example | 8 +++++--- docs/config-reference.md | 11 ++++++----- 2 files changed, 11 insertions(+), 8 deletions(-) diff --git a/config.toml.example b/config.toml.example index 3e3c39d48..17f305a67 100644 --- a/config.toml.example +++ b/config.toml.example @@ -53,14 +53,16 @@ args = ["acp", "--trust-all-tools"] working_dir = "/home/agent" # [agent] -# command = "claude" -# args = ["--acp"] +# command = "claude-agent-acp" +# args = [] # working_dir = "/home/node" +# # Auth: kubectl exec -it deploy/openab-claude -- claude auth login +# # (credentials persist in HOME PVC across restarts; see docs/claude-code.md) # ⚠️ SECURITY WARNING: Any env var listed here is accessible to the agent. # A user could trick the agent into leaking these values via prompt injection. # All supported backends support OAuth login — prefer that over env var API keys. # Note: env vars here can override baseline vars (HOME, PATH, USER) if needed. -# env = { ANTHROPIC_API_KEY = "${ANTHROPIC_API_KEY}" } +# env = {} # # By default, the agent subprocess only inherits these baseline vars: # Linux/macOS: HOME, PATH, USER diff --git a/docs/config-reference.md b/docs/config-reference.md index a9be58708..0fd58f420 100644 --- a/docs/config-reference.md +++ b/docs/config-reference.md @@ -95,10 +95,10 @@ The AI agent subprocess that OpenAB spawns to handle messages via ACP. | Key | Type | Default | Description | |-----|------|---------|-------------| -| `command` | string | *required* | Agent binary (e.g. `kiro-cli`, `claude`, `codex`, `gemini`, `copilot`, `opencode`, `cursor-agent`). | +| `command` | string | *required* | Agent binary (e.g. `kiro-cli`, `claude-agent-acp`, `codex`, `gemini`, `copilot`, `opencode`, `cursor-agent`). | | `args` | string[] | `[]` | CLI arguments passed to the agent. | | `working_dir` | string | `"/tmp"` | Working directory for the agent process. | -| `env` | map | `{}` | Extra environment variables (e.g. `{ ANTHROPIC_API_KEY = "${ANTHROPIC_API_KEY}" }`). | +| `env` | map | `{}` | Extra environment variables (e.g. `{ OPENAI_API_KEY = "${OPENAI_API_KEY}" }`). | | `inherit_env` | string[] | `[]` | Env var names to inherit from the OAB process (e.g. vars injected via K8s `envFrom`). Keys in `env` take precedence. | > **Default inherited vars:** After `env_clear()`, the agent always receives `HOME`, `PATH`, and `USER` (on Windows: `USERPROFILE`, `USERNAME`, `PATH`, `SystemRoot`, `SystemDrive`). Use `inherit_env` to pass additional vars beyond this baseline. @@ -114,10 +114,11 @@ working_dir = "/home/agent" # Claude Code [agent] -command = "claude" -args = ["--acp"] +command = "claude-agent-acp" +args = [] working_dir = "/home/node" -env = { ANTHROPIC_API_KEY = "${ANTHROPIC_API_KEY}" } +# Auth: kubectl exec -it deploy/openab-claude -- claude auth login +# Credentials persist in HOME PVC across restarts. See docs/claude-code.md. # Codex [agent] From 38e3744f611211aec075342c9adb16f5521c114e Mon Sep 17 00:00:00 2001 From: howie <2318485+howie@users.noreply.github.com> Date: Fri, 22 May 2026 04:11:17 +0800 Subject: [PATCH 068/100] fix(media): validate Content-Type and magic bytes before sending to model (#793) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(media): validate Content-Type and magic bytes before sending to model Fixes #776. When a Slack bot token lacks the `files:read` OAuth scope, Slack serves the workspace login HTML page (~55 KB) at HTTP 200 with a `text/html` Content-Type instead of the requested file binary. `download_and_encode_image` previously accepted this response because: 1. It never inspected the HTTP response `Content-Type` header. 2. On `resize_and_compress` failure for a body ≤ 1 MB it fell back to forwarding the raw bytes under the Slack-reported MIME (`image/png`), bypassing any format check. The result: a `ContentBlock::Image { media_type: "image/png", data: }` flowed through to Anthropic, which 400'd with "Could not process image". Because claude-agent-acp persists the user message into the session JSONL before the API reply, the bad block replayed on every subsequent turn in that Slack thread until an operator manually deleted the JSONL inside the pod. Changes: - Add `MediaFetchError` enum to `src/media.rs` so callers can distinguish "not an image, skip silently" (`NotAnImage`) from "claimed image, got unexpected bytes" (`UnsupportedResponseType`, `InvalidImageBody`). - Add `validate_image_response(content_type, body)` pure helper that: - Rejects any HTTP response whose Content-Type (stripped of params, lowercased) is not in `{image/png, image/jpeg, image/gif, image/webp}`. - Sniffs magic bytes via `image::ImageReader::with_guessed_format()` (no new dependencies) and rejects anything that doesn't decode as one of the four supported formats. - Change `download_and_encode_image` signature from `-> Option` to `-> Result`, capturing the Content-Type header before consuming the response with `.bytes()`. - Remove the ≤ 1 MB resize-error fallback that was the direct bug path. - Update `src/slack.rs` call site: on validation failure, collect filenames and post one aggregated user-visible warning to the Slack thread: ":warning: I couldn't access the file(s) you shared (``). This often means the bot is missing the `files:read` OAuth scope. Please ask an admin to reinstall the app with that scope." - Update `src/discord.rs` call site: `warn!` log on failure (Discord URLs are signed-public so the Slack scope hint is not applicable there). Preserve the existing `is_video_file` fallback for `Err(NotAnImage)`. - Add 12 unit tests for `validate_image_response` including the exact bug repro case (HTML body labeled `image/png`, first 8 bytes `3c21444f43545950`). Out of scope / follow-up issues: - Secondary defense: deferring claude-agent-acp JSONL persistence until after model returns 200 (requires changes in the claude-agent-acp Node project). - Startup preflight calling Slack `auth.test` to warn loudly on missing scopes. - Same Content-Type/magic-byte hardening for `download_and_transcribe` and `download_and_read_text_file`. Co-Authored-By: Claude Opus 4.7 * refactor(media): simplify per /simplify review - Remove dead hinted field from UnsupportedResponseType (always None) - Eliminate double reader.format() call with fmt@ binding - Deduplicate hex_prefix() in resize error path (compute once, reuse) - Promote strip_mime_params to media::strip_mime_params (pub crate), slack.rs delegates to it -- single source of truth for MIME stripping Co-Authored-By: Claude Opus 4.7 * fix(media): address cross-model review findings Critical: change Content-Type check from allow-list to block-list (Codex finding). The allow-list rejected application/octet-stream before magic-byte check ran, silently dropping valid images from CDNs. Only text/* is now rejected early; everything else falls through to magic-byte verification. Also: - Soften Slack warning message: no longer attributes all failures to files:read scope; now mentions format support as a second cause - Add SizeExceeded to Slack user notification (was silent) - Log failures from send_message() instead of using let _ = - Log discarded io::Error from with_guessed_format - Fix doc comments: download_and_encode_image (SizeExceeded fires pre-HTTP), validate_image_response (Content-Type check short-circuits, not sequential) - Replace inline "Validate Content-Type..." comment with WHY explanation - Restore doc comment on strip_mime_params wrapper in slack.rs - Add tests: octet-stream acceptance (Codex regression fix), JSON body rejection by magic bytes, missing Content-Type + invalid body Co-Authored-By: Claude Opus 4.7 * fix(slack): sanitize filenames in warning message (mrkdwn injection) Codex adversarial review found that user-controlled filenames embedded in the mrkdwn warning message could inject Slack markup (backtick break-out, mentions, <@uid> pings). Replace backticks and angle brackets with safe ASCII equivalents before embedding in the message. Co-Authored-By: Claude Opus 4.7 * fix(media): validate GIF bodies before pass-through * style(discord): apply rustfmt * fix(media): address chaodu-agent review findings (F1-F4) F1: validate_gif_body now decodes only the first frame instead of collect_frames() — avoids full in-memory decode of large animated GIFs. F2: remove duplicate validate_gif_body call from resize_and_compress; download_and_encode_image already runs validate_image_response before calling resize, so the second call was redundant. F3: add MediaFetchError::ProcessingFailed(image::ImageError) for the case where body passed validation but resize/compress failed — previously returned the misleading InvalidImageBody variant for a validated image. F4: extend Slack warning message to mention "file is too large" so the message is accurate when SizeExceeded failures are included. Co-Authored-By: Claude Sonnet 4.6 * fix(media): address mob review findings (R1+R2 all 8 items) Behavior: - slack: add explicit ProcessingFailed arm -> push to failed_image_files and log "post-processing failed" (not "download failed") - slack: extract sanitize_slack_filename() pub(crate) fn; add 4 unit tests for backtick/angle-bracket injection prevention API: - validate_image_response: change return type Result -> Result<()> (sole caller only checked Ok/Err; format detection ran twice) Docs: - validate_image_response: add block-list vs allow-list design rationale - validate_gif_body: add doc comment explaining first-frame-only and cursor independence; log original error via debug! before mapping to InvalidImageBody - ProcessingFailed variant: expand doc to clarify semantic difference from InvalidImageBody and expected caller behavior - download_and_encode_image: add ProcessingFailed to error listing Tests: - validate_rejects_mixed_case_text_content_type: pin .to_lowercase() normalization Co-Authored-By: Claude Sonnet 4.6 * fix(media): restore small-file fallback and notify user on HTTP 4xx - Restore ≤1MB raw-byte fallback when resize_and_compress fails after validate_image_response passes. The body is confirmed valid by magic-byte check, so forwarding original bytes is safe. Prevents regression for formats the image crate can detect but not fully decode (e.g. animated WebP). - Add HttpStatus 4xx match arm in Slack handler to push filename into failed_image_files. HTTP 4xx (401/403) indicates a persistent permission problem (similar root cause to #776) and the user should be notified. 5xx and Network errors remain log-only (transient). * fix(media): remove <=1MB fallback and fix sanitize_slack_filename & escape Address two blocking review findings from PR #793: 1. Remove the <=1MB raw-byte fallback in download_and_encode_image. validate_image_response only sniffs magic bytes; resize_and_compress does the full decode. The fallback forwarded raw bytes under Slack's claimed MIME when resize failed on a corrupt/truncated body, reopening the same JSONL poisoning class as #776. Now always returns ProcessingFailed on resize failure. 2. Add & -> & escape to sanitize_slack_filename before < and > replacements. Slack mrkdwn decodes HTML entities before markup parsing, so <@here> would bypass the angle-bracket replacement and render as a mention ping. Add regression tests for both fixes. Co-Authored-By: Claude Sonnet 4.6 * refactor(media): simplify per /simplify review Replace mut vec + extend_from_slice with single immutable slice literal in truncated_png test; remove WHAT-restatement inline comments (assertion messages already carry the constraint reason). Co-Authored-By: Claude Sonnet 4.6 * fix(media,slack,discord): address mob review findings (C1+C2+I1-I5+NITs) C1 (slack.rs): SizeExceeded now pushes bare filename to failed_image_files, removing the compound annotation that violated CLAUDE.md security convention. C2 (discord.rs): collect failed image filenames and send a user-facing warning after thread_channel is resolved; was silently logged only. I1 (slack.rs): catch-all error arm also pushes to failed_image_files so 5xx server errors surface to the user instead of being silently dropped. I2 (media.rs): download_and_encode_image doc comment now mentions that SizeExceeded can also fire post-download, not only pre-request. I3 (slack.rs): sanitize_slack_filename doc explains the & -> & escape and the HTML entity round-trip attack vector. I4 (media.rs): ProcessingFailed doc updated to describe the Slack/Discord asymmetry instead of claiming callers should always notify the user. I5 (media.rs): replace .ok()? silent drops in download_and_transcribe and download_and_read_text_file with explicit error logging. NITs: text-typed wording, GIF debug->warn, UnsupportedResponseType None display test, WebP magic-byte acceptance test, tracing::warn import. Co-Authored-By: Claude Sonnet 4.6 * fix(media): add STT post-download size check and fix ProcessingFailed doc Add post-download size verification in download_and_transcribe — the pre-request size check trusts platform-reported metadata which can be 0 or understated. Now also checks bytes.len() after reading, consistent with the image and text-file download paths. Update ProcessingFailed doc comment: both Slack and Discord adapters now surface user-facing warnings, so the previous asymmetry note was stale. Co-Authored-By: Claude Sonnet 4.6 --------- Co-authored-by: Claude Opus 4.7 Co-authored-by: shaun-agent Co-authored-by: chc-agent --- src/discord.rs | 93 +++++++--- src/media.rs | 466 ++++++++++++++++++++++++++++++++++++++++++++++--- src/slack.rs | 141 +++++++++++++-- 3 files changed, 636 insertions(+), 64 deletions(-) diff --git a/src/discord.rs b/src/discord.rs index 837e8048e..8902c2e71 100644 --- a/src/discord.rs +++ b/src/discord.rs @@ -94,7 +94,10 @@ impl ChatAdapter for DiscordAdapter { let builder = serenity::builder::CreateMessage::new() .content(content) .reference_message((ChannelId::new(ch_id), MessageId::new(msg_id))); - match ChannelId::new(ch_id).send_message(&self.http, builder).await { + match ChannelId::new(ch_id) + .send_message(&self.http, builder) + .await + { Ok(msg) => Ok(MessageRef { channel: channel.clone(), message_id: msg.id.to_string(), @@ -110,7 +113,9 @@ impl ChatAdapter for DiscordAdapter { async fn delete_message(&self, msg: &MessageRef) -> anyhow::Result<()> { let ch_id: u64 = Self::resolve_channel(&msg.channel).parse()?; let msg_id: u64 = msg.message_id.parse()?; - self.http.delete_message(ChannelId::new(ch_id), MessageId::new(msg_id), None).await?; + self.http + .delete_message(ChannelId::new(ch_id), MessageId::new(msg_id), None) + .await?; Ok(()) } @@ -424,10 +429,13 @@ impl EventHandler for Handler { let in_allowed_channel = self.allow_all_channels || self.allowed_channels.contains(&channel_id); - let is_mentioned = - msg.mentions_user_id(bot_id) || msg.content.contains(&format!("<@{}>", bot_id)) + let is_mentioned = msg.mentions_user_id(bot_id) + || msg.content.contains(&format!("<@{}>", bot_id)) || (!self.allowed_role_ids.is_empty() - && msg.mention_roles.iter().any(|r| self.allowed_role_ids.contains(&r.get()))); + && msg + .mention_roles + .iter() + .any(|r| self.allowed_role_ids.contains(&r.get()))); // Bot message gating (from upstream #321) if msg.author.bot { @@ -646,6 +654,7 @@ impl EventHandler for Handler { // image -> encode, video -> URL for agent-side inspection). let mut extra_blocks = Vec::new(); let mut echo_entries: Vec = Vec::new(); + let mut failed_image_files: Vec = Vec::new(); let mut text_file_bytes: u64 = 0; let mut text_file_count: u32 = 0; const TEXT_TOTAL_CAP: u64 = 1024 * 1024; // 1 MB total for all text file attachments @@ -711,25 +720,44 @@ impl EventHandler for Handler { debug!(filename = %attachment.filename, "adding text file attachment"); extra_blocks.push(block); } - } else if let Some(block) = media::download_and_encode_image( - &attachment.url, - attachment.content_type.as_deref(), - &attachment.filename, - u64::from(attachment.size), - None, - ) - .await - { - debug!(url = %attachment.url, filename = %attachment.filename, "adding image attachment"); - extra_blocks.push(block); - } else if media::is_video_file(&attachment.filename, attachment.content_type.as_deref()) { - debug!(url = %attachment.url, filename = %attachment.filename, "adding video attachment link"); - extra_blocks.push(video_attachment_block( - &attachment.filename, + } else { + match media::download_and_encode_image( + &attachment.url, attachment.content_type.as_deref(), + &attachment.filename, u64::from(attachment.size), - &attachment.url, - )); + None, + ) + .await + { + Ok(block) => { + debug!(url = %attachment.url, filename = %attachment.filename, "adding image attachment"); + extra_blocks.push(block); + } + Err(media::MediaFetchError::NotAnImage) => { + if media::is_video_file( + &attachment.filename, + attachment.content_type.as_deref(), + ) { + debug!(url = %attachment.url, filename = %attachment.filename, "adding video attachment link"); + extra_blocks.push(video_attachment_block( + &attachment.filename, + attachment.content_type.as_deref(), + u64::from(attachment.size), + &attachment.url, + )); + } + } + Err(e) => { + tracing::warn!( + url = %attachment.url, + filename = %attachment.filename, + error = %e, + "image attachment failed" + ); + failed_image_files.push(attachment.filename.clone()); + } + } } } @@ -759,6 +787,23 @@ impl EventHandler for Handler { } }; + // Notify user if any images couldn't be processed. + if !failed_image_files.is_empty() { + let file_list = failed_image_files + .iter() + .map(|n| format!("`{}`", n.replace('`', "'"))) + .collect::>() + .join(", "); + let warn_msg = format!( + ":warning: I couldn't process the image(s) you shared ({}). \ + The files may be inaccessible or in an unsupported format (PNG/JPEG/GIF/WebP only).", + file_list + ); + if let Err(e) = adapter.send_message(&thread_channel, &warn_msg).await { + tracing::warn!(error = %e, "failed to send image warning to user"); + } + } + let trigger_msg = discord_msg_ref(&msg); // Per-thread streaming: check if another bot is present in this thread @@ -1991,7 +2036,9 @@ fn resolve_mentions(content: &str, bot_id: UserId, allowed_role_ids: &HashSet", id), "")) + allowed_role_ids + .iter() + .fold(out, |s, id| s.replace(&format!("<@&{}>", id), "")) }; // 3. Other user mentions: keep <@UID> as-is so the LLM can mention back // 4. Fallback: replace remaining role mentions only (user mentions are preserved) diff --git a/src/media.rs b/src/media.rs index b42cfa6e2..33ea59010 100644 --- a/src/media.rs +++ b/src/media.rs @@ -2,10 +2,11 @@ use crate::acp::ContentBlock; use crate::config::SttConfig; use base64::engine::general_purpose::STANDARD as BASE64; use base64::Engine; -use image::ImageReader; +use image::codecs::gif::GifDecoder; +use image::{AnimationDecoder, ImageReader}; use std::io::Cursor; use std::sync::LazyLock; -use tracing::{debug, error}; +use tracing::{debug, error, warn}; /// Reusable HTTP client for downloading attachments (shared across adapters). pub static HTTP_CLIENT: LazyLock = LazyLock::new(|| { @@ -21,7 +22,151 @@ const IMAGE_MAX_DIMENSION_PX: u32 = 1200; /// JPEG quality for compressed output. const IMAGE_JPEG_QUALITY: u8 = 75; +/// Error variants for `download_and_encode_image`. +#[derive(Debug)] +pub enum MediaFetchError { + /// URL empty or MIME/filename doesn't indicate an image; skip silently. + NotAnImage, + /// HTTP response Content-Type is not a supported image format. + UnsupportedResponseType { actual: Option }, + /// Response body magic bytes don't match a supported image format. + InvalidImageBody { magic_prefix_hex: String }, + /// File exceeds the configured size limit. + SizeExceeded { actual: u64, limit: u64 }, + /// Network-level error (send or body-read). + Network(reqwest::Error), + /// Server returned a non-success HTTP status. + HttpStatus(reqwest::StatusCode), + /// Body was a valid image but post-processing (resize/compress) failed. + /// Unlike `InvalidImageBody`, the bytes decoded successfully — this is an + /// unexpected processing error, not a content validation failure. Both the + /// Slack and Discord adapters surface this as a user-facing warning alongside + /// other image-validation failures. + ProcessingFailed(image::ImageError), +} + +impl std::fmt::Display for MediaFetchError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::NotAnImage => write!(f, "not an image attachment"), + Self::UnsupportedResponseType { actual } => write!( + f, + "server returned unexpected content type (actual: {})", + actual.as_deref().unwrap_or("none"), + ), + Self::InvalidImageBody { magic_prefix_hex } => write!( + f, + "response body is not a valid image (first 8 bytes: {magic_prefix_hex})" + ), + Self::SizeExceeded { actual, limit } => { + write!(f, "file size {actual} exceeds limit {limit}") + } + Self::Network(e) => write!(f, "network error: {e}"), + Self::HttpStatus(s) => write!(f, "HTTP {s}"), + Self::ProcessingFailed(e) => write!(f, "image processing failed: {e}"), + } + } +} + +/// Strip MIME parameters and trim whitespace. `"image/png; charset=binary"` → `"image/png"`. +pub(crate) fn strip_mime_params(mime: &str) -> &str { + mime.split(';').next().unwrap_or(mime).trim() +} + +/// Format the first 8 bytes of a buffer as lowercase hex (no separator). +fn hex_prefix(body: &[u8]) -> String { + body.iter() + .take(8) + .map(|b| format!("{b:02x}")) + .collect::>() + .concat() +} + +/// Validate the HTTP response Content-Type and body magic bytes. +/// +/// If Content-Type is present and explicitly text-typed (e.g. `text/html` from +/// Slack's auth redirect when `files:read` scope is missing), rejects immediately. +/// Generic types such as `application/octet-stream` and absent headers pass through +/// to the magic-byte check, which is the authoritative gate for image validity. +/// +/// Content-Type is filtered with a block-list (`text/*`) rather than an allow-list +/// (`image/*`) because CDNs commonly serve any file type as `application/octet-stream`; +/// rejecting that header would silently break real downloads. The magic-byte check +/// examines the actual bytes regardless of what the server claims. +fn validate_image_response( + content_type: Option<&str>, + body: &[u8], +) -> Result<(), MediaFetchError> { + // Reject explicitly-text responses early (e.g. Slack HTML login page at HTTP 200). + // application/octet-stream and other generic types pass through to magic-byte check. + if let Some(ct) = content_type { + let base = strip_mime_params(ct).to_lowercase(); + if base.starts_with("text/") { + return Err(MediaFetchError::UnsupportedResponseType { actual: Some(base) }); + } + } + + let reader = match ImageReader::new(Cursor::new(body)).with_guessed_format() { + Ok(r) => r, + Err(e) => { + error!(error = %e, "image format detection I/O error"); + return Err(MediaFetchError::InvalidImageBody { + magic_prefix_hex: hex_prefix(body), + }); + } + }; + + match reader.format() { + Some(image::ImageFormat::Png | image::ImageFormat::Jpeg | image::ImageFormat::WebP) => { + Ok(()) + } + Some(image::ImageFormat::Gif) => { + validate_gif_body(body).map_err(|e| { + warn!(error = %e, "GIF validation failed"); + MediaFetchError::InvalidImageBody { + magic_prefix_hex: hex_prefix(body), + } + })?; + Ok(()) + } + _ => Err(MediaFetchError::InvalidImageBody { + magic_prefix_hex: hex_prefix(body), + }), + } +} + +/// Validate a GIF body by attempting to decode exactly one frame. +/// +/// Decoding only the first frame is intentional: the GIF header and colour tables +/// must be valid before the first frame can be decoded, so this catches truncated +/// or corrupt payloads without the CPU/memory cost of decoding a large animated GIF +/// in full. +/// +/// Creates its own `Cursor` over `raw`; the caller can independently re-read the +/// same slice for resizing. +fn validate_gif_body(raw: &[u8]) -> image::ImageResult<()> { + let decoder = GifDecoder::new(Cursor::new(raw))?; + let mut frames = decoder.into_frames(); + frames.next().ok_or_else(|| { + image::ImageError::Decoding(image::error::DecodingError::new( + image::error::ImageFormatHint::Exact(image::ImageFormat::Gif), + "GIF has no frames", + )) + })??; + Ok(()) +} + /// Download an image from a URL, resize/compress it, and return as a ContentBlock. +/// +/// Returns `Err(MediaFetchError::NotAnImage)` when the URL or MIME hint don't +/// indicate an image — callers should skip silently. Returns +/// `Err(MediaFetchError::SizeExceeded)` when the declared `size` exceeds the limit +/// before any request is made, or when the downloaded body exceeds the limit. Returns +/// other `Err` variants (`Network`, `HttpStatus`, `UnsupportedResponseType`, +/// `InvalidImageBody`) after a request attempt — callers should surface these to the user. Returns +/// `Err(MediaFetchError::ProcessingFailed)` when the body is a valid image but +/// resize/compression fails — callers should warn the user and skip. +/// /// Pass `auth_token` for platforms that require authentication (e.g. Slack private files). pub async fn download_and_encode_image( url: &str, @@ -29,11 +174,11 @@ pub async fn download_and_encode_image( filename: &str, size: u64, auth_token: Option<&str>, -) -> Option { +) -> Result { const MAX_SIZE: u64 = 10 * 1024 * 1024; // 10 MB if url.is_empty() { - return None; + return Err(MediaFetchError::NotAnImage); } let mime = mime_hint.or_else(|| { @@ -51,17 +196,20 @@ pub async fn download_and_encode_image( let Some(mime) = mime else { debug!(filename, "skipping non-image attachment"); - return None; + return Err(MediaFetchError::NotAnImage); }; let mime = mime.split(';').next().unwrap_or(mime).trim(); if !mime.starts_with("image/") { debug!(filename, mime, "skipping non-image attachment"); - return None; + return Err(MediaFetchError::NotAnImage); } if size > MAX_SIZE { error!(filename, size, "image exceeds 10MB limit"); - return None; + return Err(MediaFetchError::SizeExceeded { + actual: size, + limit: MAX_SIZE, + }); } let mut req = HTTP_CLIENT.get(url); @@ -73,18 +221,26 @@ pub async fn download_and_encode_image( Ok(resp) => resp, Err(e) => { error!(url, error = %e, "download failed"); - return None; + return Err(MediaFetchError::Network(e)); } }; if !response.status().is_success() { error!(url, status = %response.status(), "HTTP error downloading image"); - return None; + return Err(MediaFetchError::HttpStatus(response.status())); } + + // Capture Content-Type BEFORE .bytes() consumes the response. + let content_type = response + .headers() + .get(reqwest::header::CONTENT_TYPE) + .and_then(|v| v.to_str().ok()) + .map(str::to_string); + let bytes = match response.bytes().await { Ok(b) => b, Err(e) => { error!(url, error = %e, "read failed"); - return None; + return Err(MediaFetchError::Network(e)); } }; @@ -94,18 +250,36 @@ pub async fn download_and_encode_image( size = bytes.len(), "downloaded image exceeds limit" ); - return None; + return Err(MediaFetchError::SizeExceeded { + actual: bytes.len() as u64, + limit: MAX_SIZE, + }); + } + + // Guard against HTTP 200 responses that are error pages (e.g. Slack auth redirect + // when files:read scope is missing), and against corrupted or mislabeled bodies. + if let Err(e) = validate_image_response(content_type.as_deref(), &bytes) { + error!( + filename, + mime_hint = mime, + content_type = content_type.as_deref().unwrap_or("none"), + magic = hex_prefix(&bytes), + error = %e, + "image validation failed — body is not a supported image" + ); + return Err(e); } let (output_bytes, output_mime) = match resize_and_compress(&bytes) { Ok(result) => result, Err(e) => { - if bytes.len() > 1024 * 1024 { - error!(filename, error = %e, size = bytes.len(), "resize failed and original too large, skipping"); - return None; - } - debug!(filename, error = %e, "resize failed, using original"); - (bytes.to_vec(), mime.to_string()) + error!( + filename, + error = %e, + size = bytes.len(), + "resize failed after successful validation" + ); + return Err(MediaFetchError::ProcessingFailed(e)); } }; @@ -117,7 +291,7 @@ pub async fn download_and_encode_image( ); let encoded = BASE64.encode(&output_bytes); - Some(ContentBlock::Image { + Ok(ContentBlock::Image { media_type: output_mime, data: encoded, }) @@ -145,12 +319,29 @@ pub async fn download_and_transcribe( req = req.header("Authorization", format!("Bearer {token}")); } - let resp = req.send().await.ok()?; + let resp = match req.send().await { + Ok(r) => r, + Err(e) => { + error!(url, error = %e, "audio download request failed"); + return None; + } + }; if !resp.status().is_success() { error!(url, status = %resp.status(), "audio download failed"); return None; } - let bytes = resp.bytes().await.ok()?.to_vec(); + let bytes = match resp.bytes().await { + Ok(b) => b.to_vec(), + Err(e) => { + error!(url, error = %e, "audio body read failed"); + return None; + } + }; + + if bytes.len() as u64 > MAX_SIZE { + error!(filename, size = bytes.len(), "downloaded audio exceeds 25MB limit"); + return None; + } crate::stt::transcribe( &HTTP_CLIENT, @@ -306,7 +497,13 @@ pub async fn download_and_read_text_file( tracing::warn!(url, status = %resp.status(), "text file download failed"); return None; } - let bytes = resp.bytes().await.ok()?; + let bytes = match resp.bytes().await { + Ok(b) => b, + Err(e) => { + tracing::warn!(url, error = %e, "text file body read failed"); + return None; + } + }; let actual_size = bytes.len() as u64; // Defense-in-depth: verify actual download size @@ -348,6 +545,21 @@ mod tests { buf.into_inner() } + fn make_jpeg(width: u32, height: u32) -> Vec { + let img = image::RgbImage::new(width, height); + let mut buf = Cursor::new(Vec::new()); + img.write_to(&mut buf, image::ImageFormat::Jpeg).unwrap(); + buf.into_inner() + } + + fn make_gif() -> Vec { + vec![ + 0x47, 0x49, 0x46, 0x38, 0x39, 0x61, 0x01, 0x00, 0x01, 0x00, 0x80, 0x00, 0x00, 0x00, + 0x00, 0x00, 0xff, 0xff, 0xff, 0x2C, 0x00, 0x00, 0x00, 0x00, 0x01, 0x00, 0x01, 0x00, + 0x00, 0x02, 0x02, 0x44, 0x01, 0x00, 0x3B, + ] + } + #[test] fn large_image_resized_to_max_dimension() { let png = make_png(3000, 2000); @@ -405,11 +617,7 @@ mod tests { #[test] fn gif_passes_through_unchanged() { - let gif: Vec = vec![ - 0x47, 0x49, 0x46, 0x38, 0x39, 0x61, 0x01, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0x2C, - 0x00, 0x00, 0x00, 0x00, 0x01, 0x00, 0x01, 0x00, 0x00, 0x02, 0x02, 0x44, 0x01, 0x00, - 0x3B, - ]; + let gif = make_gif(); let (output, mime) = resize_and_compress(&gif).unwrap(); assert_eq!(mime, "image/gif"); @@ -429,4 +637,210 @@ mod tests { assert!(is_video_file("clip.MOV", None)); assert!(!is_video_file("notes.txt", Some("text/plain"))); } + + // --- validate_image_response tests --- + + #[test] + fn validate_accepts_png_with_matching_content_type() { + let png = make_png(1, 1); + assert!(validate_image_response(Some("image/png"), &png).is_ok()); + } + + #[test] + fn validate_accepts_jpeg_with_matching_content_type() { + let jpeg = make_jpeg(1, 1); + assert!(validate_image_response(Some("image/jpeg"), &jpeg).is_ok()); + } + + #[test] + fn validate_accepts_gif_with_matching_content_type() { + let gif = make_gif(); + assert!(validate_image_response(Some("image/gif"), &gif).is_ok()); + } + + #[test] + fn validate_rejects_corrupt_gif_body() { + let corrupt_gif = b"GIF89a\x01\x00\x01\x00\x00\x00\x00"; + let result = validate_image_response(Some("image/gif"), corrupt_gif); + assert!(matches!( + result, + Err(MediaFetchError::InvalidImageBody { .. }) + )); + } + + #[test] + fn validate_accepts_missing_content_type_with_valid_png() { + // When Content-Type header is absent, fall back to magic-byte detection. + let png = make_png(1, 1); + assert!(validate_image_response(None, &png).is_ok()); + } + + #[test] + fn validate_content_type_strips_params() { + // "image/png; charset=binary" is a real header value — must be accepted. + let png = make_png(1, 1); + assert!(validate_image_response(Some("image/png; charset=binary"), &png).is_ok()); + } + + /// Exact reproduction of issue #776: Slack serves the workspace login HTML + /// page at HTTP 200 when the bot token lacks the `files:read` scope. + /// The Slack file metadata says `mimetype: image/png`; the response body + /// magic bytes are `Slack login"; + let result = validate_image_response(Some("image/png"), html_body); + match result { + Err(MediaFetchError::InvalidImageBody { magic_prefix_hex }) => { + assert_eq!(magic_prefix_hex, "3c21444f43545950"); + } + other => panic!("expected InvalidImageBody, got {other:?}"), + } + } + + #[test] + fn validate_rejects_text_html_content_type() { + // Even if the body were a valid image, a text/html Content-Type must be rejected. + let png = make_png(1, 1); + let result = validate_image_response(Some("text/html; charset=utf-8"), &png); + assert!(matches!( + result, + Err(MediaFetchError::UnsupportedResponseType { .. }) + )); + } + + #[test] + fn validate_rejects_mixed_case_text_content_type() { + // Mixed-case Content-Type must be normalised before rejection. + let png = make_png(1, 1); + let result = validate_image_response(Some("Text/HTML; Charset=utf-8"), &png); + assert!(matches!( + result, + Err(MediaFetchError::UnsupportedResponseType { .. }) + )); + } + + /// Regression test for the application/octet-stream fix: CDNs and generic + /// file download endpoints commonly serve any file with this Content-Type. + /// The old allow-list incorrectly rejected it before magic-byte check. + #[test] + fn validate_accepts_octet_stream_with_valid_png() { + let png = make_png(1, 1); + assert!( + validate_image_response(Some("application/octet-stream"), &png).is_ok(), + "application/octet-stream must pass through to magic-byte check" + ); + } + + /// application/json body is rejected by magic bytes, not by Content-Type. + #[test] + fn validate_rejects_json_body_by_magic_bytes() { + let json_body = b"{\"error\":\"invalid_auth\",\"ok\":false}"; + let result = validate_image_response(Some("application/json"), json_body); + assert!(matches!( + result, + Err(MediaFetchError::InvalidImageBody { .. }) + )); + } + + /// Missing Content-Type with invalid body: CDN stripping the header should + /// still be caught by magic-byte detection. + #[test] + fn validate_rejects_html_body_with_missing_content_type() { + let html_body = b"error page"; + let result = validate_image_response(None, html_body); + assert!(matches!( + result, + Err(MediaFetchError::InvalidImageBody { .. }) + )); + } + + #[test] + fn validate_rejects_empty_body() { + let result = validate_image_response(Some("image/png"), &[]); + assert!(matches!( + result, + Err(MediaFetchError::InvalidImageBody { .. }) + )); + } + + #[test] + fn validate_rejects_truncated_png_header() { + // PNG magic is 8 bytes; 4 bytes is not enough to identify the format. + let truncated = [0x89u8, 0x50, 0x4e, 0x47]; + let result = validate_image_response(Some("image/png"), &truncated); + assert!(matches!( + result, + Err(MediaFetchError::InvalidImageBody { .. }) + )); + } + + #[test] + fn truncated_png_body_must_not_produce_content_block() { + // Valid PNG magic bytes (8 bytes) + partial IHDR -- body is too short to decode. + // Previously: the <=1MB fallback in download_and_encode_image forwarded raw bytes + // after resize_and_compress failed, reproducing the #776 poisoning class. + // After removing the fallback, resize_and_compress failure must propagate as Err. + let truncated: &[u8] = &[ + 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, // PNG magic + 0x00, 0x00, 0x00, 0x0d, 0x49, 0x48, 0x44, 0x52, // partial IHDR + ]; + assert!( + validate_image_response(Some("image/png"), truncated).is_ok(), + "magic-byte check still passes for truncated body" + ); + assert!( + resize_and_compress(truncated).is_err(), + "truncated PNG must fail at decode -- no raw-byte fallback allowed" + ); + } + + #[test] + fn media_fetch_error_display_renders() { + let _ = MediaFetchError::NotAnImage.to_string(); + let _ = MediaFetchError::UnsupportedResponseType { + actual: Some("text/html".into()), + } + .to_string(); + let s = MediaFetchError::UnsupportedResponseType { actual: None }.to_string(); + assert!(s.contains("none"), "None branch should render as 'none'"); + let _ = MediaFetchError::InvalidImageBody { + magic_prefix_hex: "3c21444f43545950".into(), + } + .to_string(); + let _ = MediaFetchError::SizeExceeded { + actual: 11_000_000, + limit: 10_000_000, + } + .to_string(); + let _ = MediaFetchError::HttpStatus(reqwest::StatusCode::UNAUTHORIZED).to_string(); + let _ = MediaFetchError::ProcessingFailed(image::ImageError::Unsupported( + image::error::UnsupportedError::from_format_and_kind( + image::error::ImageFormatHint::Unknown, + image::error::UnsupportedErrorKind::Color(image::ExtendedColorType::Rgba16), + ), + )) + .to_string(); + } + + #[test] + fn validate_accepts_webp_by_magic_bytes() { + let img = image::RgbImage::new(1, 1); + let mut buf = std::io::Cursor::new(Vec::new()); + img.write_to(&mut buf, image::ImageFormat::WebP).unwrap(); + let webp_body = buf.into_inner(); + assert!(validate_image_response(Some("image/webp"), &webp_body).is_ok()); + } + + #[test] + fn hex_prefix_formats_first_8_bytes() { + let bytes = b""; + assert_eq!(hex_prefix(bytes), "3c21444f43545950"); + } + + #[test] + fn hex_prefix_handles_short_buffer() { + let bytes = [0xffu8, 0xd8]; + assert_eq!(hex_prefix(&bytes), "ffd8"); + } } diff --git a/src/slack.rs b/src/slack.rs index 47d4c42d1..ff3452ba7 100644 --- a/src/slack.rs +++ b/src/slack.rs @@ -960,6 +960,7 @@ async fn handle_message( let mut echo_entries: Vec = Vec::new(); let mut text_file_bytes: u64 = 0; let mut text_file_count: u32 = 0; + let mut failed_image_files: Vec = Vec::new(); if let Some(files) = files { for file in files { @@ -1058,21 +1059,79 @@ async fn handle_message( debug!(filename, "adding text file attachment"); extra_blocks.push(block); } - } else if let Some(block) = media::download_and_encode_image( - url, - Some(mimetype), - filename, - size, - Some(bot_token), - ) - .await - { - debug!(filename, "adding image attachment"); - extra_blocks.push(block); + } else { + match media::download_and_encode_image( + url, + Some(mimetype), + filename, + size, + Some(bot_token), + ) + .await + { + Ok(block) => { + debug!(filename, "adding image attachment"); + extra_blocks.push(block); + } + Err(media::MediaFetchError::NotAnImage) => {} + Err(media::MediaFetchError::SizeExceeded { actual, limit }) => { + warn!(filename, actual, limit, "image exceeds size limit"); + failed_image_files.push(filename.to_string()); + } + Err( + media::MediaFetchError::UnsupportedResponseType { .. } + | media::MediaFetchError::InvalidImageBody { .. }, + ) => { + warn!( + filename, + "image validation failed; server may have returned non-image content" + ); + failed_image_files.push(filename.to_string()); + } + Err(media::MediaFetchError::ProcessingFailed(ref e)) => { + warn!(filename, error = %e, "image post-processing failed"); + failed_image_files.push(filename.to_string()); + } + Err(media::MediaFetchError::HttpStatus(status)) + if status.is_client_error() => + { + warn!(filename, %status, "image download denied"); + failed_image_files.push(filename.to_string()); + } + Err(e) => { + warn!(filename, error = %e, "image download failed"); + failed_image_files.push(filename.to_string()); + } + } } } } + // Notify user if any images couldn't be processed. + if !failed_image_files.is_empty() { + let warn_channel = ChannelRef { + platform: "slack".into(), + channel_id: channel_id.clone(), + thread_id: thread_ts.clone().or_else(|| Some(ts.clone())), + parent_id: None, + origin_event_id: None, + }; + let file_list = failed_image_files + .iter() + .map(|n| sanitize_slack_filename(n)) + .collect::>() + .join("`, `"); + let msg = format!( + ":warning: I couldn't process the file(s) you shared (`{file_list}`). \ + This can happen when the bot lacks the `files:read` OAuth scope, \ + the file format isn't supported (PNG/JPEG/GIF/WebP only), \ + or the file is too large." + ); + if let Err(e) = adapter.send_message(&warn_channel, &msg).await { + warn!(error = %e, "failed to send image validation warning to user"); + } + } + // Resolve Slack display name (best-effort, fallback to user_id) let display_name = adapter .resolve_user_name(&user_id) @@ -1216,11 +1275,24 @@ fn slack_file_download_url(file: &serde_json::Value) -> &str { .unwrap_or("") } -/// Strip MIME parameters like `; charset=utf-8` so type-detection helpers see -/// the bare media type. Slack occasionally sends mimetypes like -/// `text/plain; charset=utf-8`; `media::is_text_file` expects the bare form. +/// Strip MIME parameters so type-detection helpers see the bare media type. +/// Delegates to media::strip_mime_params (single source of truth). +/// Needed because Slack occasionally sends `text/plain; charset=utf-8` and +/// `media::is_text_file` expects the bare form. fn strip_mime_params(mimetype: &str) -> &str { - mimetype.split(';').next().unwrap_or(mimetype).trim() + media::strip_mime_params(mimetype) +} + +/// Sanitize a filename for safe embedding in a Slack mrkdwn message. +/// +/// Ampersands (`&`), backticks (`` ` ``), and angle brackets (`<`, `>`) are escaped. +/// `&` is encoded as `&` first because Slack decodes HTML entities before parsing +/// mrkdwn — a filename like `<@here>` would otherwise round-trip back to +/// `<@here>` and trigger a mention ping. Backticks and angle brackets are Slack +/// mrkdwn delimiters; without escaping, `` or `` `<@U123>` `` would render +/// as mentions or @-here pings. +pub(crate) fn sanitize_slack_filename(s: &str) -> String { + s.replace('&', "&").replace('`', "'").replace('<', "(").replace('>', ")") } /// Returns `true` if `text` contains a Slack user mention for `uid`. @@ -1498,6 +1570,45 @@ mod tests { assert_eq!(slack_file_download_url(&file), ""); } + // --- sanitize_slack_filename tests --- + + #[test] + fn sanitize_leaves_normal_filename_unchanged() { + assert_eq!(sanitize_slack_filename("photo.png"), "photo.png"); + assert_eq!(sanitize_slack_filename("my file (1).jpg"), "my file (1).jpg"); + } + + #[test] + fn sanitize_replaces_backtick() { + assert_eq!(sanitize_slack_filename("file`name.png"), "file'name.png"); + } + + #[test] + fn sanitize_replaces_angle_brackets() { + // Angle brackets are Slack mrkdwn delimiters; they must not pass through. + assert_eq!(sanitize_slack_filename("<@U123>"), "(@U123)"); + assert_eq!(sanitize_slack_filename(""), "(!here)"); + } + + #[test] + fn sanitize_combined_injection_attempt() { + // A filename constructed to inject a Slack @here ping. + assert_eq!( + sanitize_slack_filename("``"), + "'(!here)'" + ); + } + + #[test] + fn sanitize_escapes_ampersand_before_angle_brackets() { + // Slack mrkdwn decodes HTML entities before markup parsing. + // "<@here>" would round-trip back to "<@here>" and trigger a mention + // ping if & is not escaped. The & must be escaped first so downstream + // Slack entity decoding cannot reconstruct a mrkdwn delimiter. + assert_eq!(sanitize_slack_filename("<@here>"), "&lt;@here&gt;"); + assert_eq!(sanitize_slack_filename("file&name.png"), "file&name.png"); + } + // --- strip_mime_params tests --- /// MIME with charset parameter strips to bare media type. From 63dd3b87cdb1f4951348cf1ee5e97bad3905e436 Mon Sep 17 00:00:00 2001 From: chaodufashi Date: Thu, 21 May 2026 17:19:01 -0400 Subject: [PATCH 069/100] =?UTF-8?q?fix:=20bump=20codex-acp=200.10.0=20?= =?UTF-8?q?=E2=86=92=200.14.0=20(#889)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: bump codex-acp 0.10.0 → 0.14.0 Fixes #887 — codex-acp 0.10.0 uses an older codex core that doesn't request the model.request OAuth scope, causing 401 on all API calls. * fix: smoke test hangs when agent doesn't exit on EOF Use head -1 inside the container to capture the first JSON response and exit immediately, rather than waiting for the agent process to terminate. Reduces timeout from 30s to 15s. --------- Co-authored-by: Pahud Hsieh --- .github/workflows/docker-smoke-test.yml | 17 ++++++++--------- Dockerfile.codex | 2 +- 2 files changed, 9 insertions(+), 10 deletions(-) diff --git a/.github/workflows/docker-smoke-test.yml b/.github/workflows/docker-smoke-test.yml index 2b8f7131e..28e5faa90 100644 --- a/.github/workflows/docker-smoke-test.yml +++ b/.github/workflows/docker-smoke-test.yml @@ -43,17 +43,16 @@ jobs: - name: Verify agent responds run: | - INIT='{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":1,"clientCapabilities":{},"clientInfo":{"name":"ci-test","version":"0.0.1"}}}' + INIT='{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-03-26","clientCapabilities":{},"clientInfo":{"name":"ci-test","version":"0.0.1"}}}' - RAW=$(echo "$INIT" | timeout 30 docker run --rm -i \ - --entrypoint ${{ matrix.variant.agent }} \ - openab-test${{ matrix.variant.suffix }} \ - ${{ matrix.variant.agent_args }} 2>/dev/null || true) + # Start agent in background, send init, capture output with timeout + CID=$(docker run -d -i --entrypoint sh openab-test${{ matrix.variant.suffix }} -c 'exec ${{ matrix.variant.agent }} ${{ matrix.variant.agent_args }} 2>/dev/null') + echo "$INIT" | docker attach --no-stdin=false "$CID" & + sleep 5 + RESPONSE=$(docker logs "$CID" 2>/dev/null | grep -m1 '^{' || true) + docker rm -f "$CID" >/dev/null 2>&1 - echo "Raw output:" - echo "$RAW" - - RESPONSE=$(echo "$RAW" | grep -m1 '^{' || true) + echo "Response: $RESPONSE" if [ -n "$RESPONSE" ] && echo "$RESPONSE" | jq -e '.result.agentInfo.name' > /dev/null 2>&1; then AGENT_NAME=$(echo "$RESPONSE" | jq -r '.result.agentInfo.name') diff --git a/Dockerfile.codex b/Dockerfile.codex index 2faa49ebc..cb621ef2e 100644 --- a/Dockerfile.codex +++ b/Dockerfile.codex @@ -11,7 +11,7 @@ FROM node:22-bookworm-slim RUN apt-get update && apt-get install -y --no-install-recommends ca-certificates curl procps ripgrep tini && rm -rf /var/lib/apt/lists/* # Pre-install codex-acp and codex CLI globally -ARG CODEX_ACP_VERSION=0.10.0 +ARG CODEX_ACP_VERSION=0.14.0 ARG CODEX_VERSION=0.133.0 RUN npm install -g @zed-industries/codex-acp@${CODEX_ACP_VERSION} @openai/codex@${CODEX_VERSION} --retry 3 From d81442d31122a382e85bb2c65e6782769ddfb5e3 Mon Sep 17 00:00:00 2001 From: chaodufashi Date: Thu, 21 May 2026 17:38:13 -0400 Subject: [PATCH 070/100] feat(cron): goal-driven auto-disable usercron jobs (implements #816) (#818) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(cron): auto-disable usercron jobs on success * fix(cron): atomic write, kill child on timeout, add timeout test - update_usercron_job: write to .toml.tmp then rename (atomic on POSIX) - check_disable_on_success: use spawn() + wait_with_output() to retain child handle; explicitly kill on timeout to prevent orphan processes - Add disable_on_success_kills_child_on_timeout test (sleep 999 + 1s timeout) * fix(cron): remove unused timeout import * fix(cron): serialize usercron writebacks * docs: add re-enable instructions for goal-driven cronjobs --------- Co-authored-by: chaodufashi Co-authored-by: 超渡法師 --- Cargo.lock | 1 + Cargo.toml | 1 + docs/config-reference.md | 27 ++ docs/cronjob.md | 41 ++- src/config.rs | 16 ++ src/cron.rs | 532 +++++++++++++++++++++++++++++++++++++-- 6 files changed, 599 insertions(+), 19 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 14dddd655..ad9a99c7f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1071,6 +1071,7 @@ dependencies = [ "tokio", "tokio-tungstenite", "toml", + "toml_edit", "tracing", "tracing-subscriber", "unicode-width", diff --git a/Cargo.toml b/Cargo.toml index 8c19bcf5d..b6f16b099 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -9,6 +9,7 @@ tokio = { version = "1", features = ["full"] } serde = { version = "1", features = ["derive"] } serde_json = "1" toml = "0.8" +toml_edit = "0.22" tracing = "0.1" tracing-subscriber = { version = "0.3", features = ["json", "env-filter"] } serenity = { version = "0.12", default-features = false, features = ["client", "gateway", "model", "rustls_backend", "cache"] } diff --git a/docs/config-reference.md b/docs/config-reference.md index 0fd58f420..520d69987 100644 --- a/docs/config-reference.md +++ b/docs/config-reference.md @@ -272,6 +272,33 @@ timezone = "UTC" The external `cronjob.toml` uses `[[jobs]]` (same fields). See [Usercron docs](cronjob.md#usercron--hot-reload-with-cronjobtoml) for details. +### Usercron-only `[[jobs]]` fields + +These fields are valid only in the external usercron file, for example `$HOME/.openab/cronjob.toml`. They are rejected in baseline `[[cron.jobs]]` because OpenAB only writes state back to the user-managed cron file. + +| Key | Type | Default | Description | +|-----|------|---------|-------------| +| `id` | string | *required with `disable_on_success`* | Stable job ID used when the scheduler writes `enabled = false` or `thread_id` back to `cronjob.toml`. | +| `disable_on_success` | string | — | Command to run before sending the scheduled prompt. | +| `disable_on_success_match` | string | *required with `disable_on_success`* | Marker that must appear in stdout or stderr, in addition to exit code `0`, before the job is considered complete. | +| `disable_on_success_timeout_secs` | integer | `60` | Timeout for the completion check command. | +| `disable_on_success_working_dir` | string | — | Working directory for the completion check command. | + +Example: + +```toml +[[jobs]] +id = "fix-unit-tests" +enabled = true +schedule = "*/10 * * * *" +channel = "123456789" +message = "Unit tests are still failing. Continue fixing them." +disable_on_success = "npm test && echo OPENAB_GOAL_SUCCESS" +disable_on_success_match = "OPENAB_GOAL_SUCCESS" +disable_on_success_timeout_secs = 120 +disable_on_success_working_dir = "/workspace/my-project" +``` + **Cron expression format:** ``` diff --git a/docs/cronjob.md b/docs/cronjob.md index e754177ed..4591af29c 100644 --- a/docs/cronjob.md +++ b/docs/cronjob.md @@ -256,6 +256,44 @@ Agent: ✅ Written to cronjob.toml, takes effect within 1 minute This enables mobile-friendly schedule management — talk to your agent from your phone, and it updates the cron file for you. +### Goal-Driven Auto-Disable + +Usercron jobs can stop themselves once a goal is complete. Add `disable_on_success` to run a command before the scheduled prompt is sent. The job is considered complete only when the command exits `0` **and** stdout or stderr contains `disable_on_success_match`. + +```toml +[[jobs]] +id = "fix-unit-tests" # required for scheduler writeback +enabled = true +schedule = "*/10 * * * *" +channel = "1490282656913559673" +message = "Unit tests are still failing. Continue fixing them and report progress." + +disable_on_success = "npm test && echo OPENAB_GOAL_SUCCESS" +disable_on_success_match = "OPENAB_GOAL_SUCCESS" +disable_on_success_timeout_secs = 120 +disable_on_success_working_dir = "/workspace/my-project" +``` + +Execution flow: + +1. The schedule matches. +2. The scheduler runs `disable_on_success`. +3. If the command exits `0` and output contains `disable_on_success_match`, OpenAB posts `✅ Goal achieved`, writes `enabled = false` back to `$HOME/.openab/cronjob.toml`, and skips the regular prompt. +4. Otherwise, OpenAB sends the regular `message` and the agent continues working. + +`disable_on_success` is supported only in usercron `[[jobs]]`, not baseline `[[cron.jobs]]`. This keeps scheduler writeback limited to the user-managed cron file. + +### Re-enabling a Disabled Job + +Once a goal is achieved and the job is disabled, re-enable it by editing `$HOME/.openab/cronjob.toml`: + +```toml +# Flip back to true to restart the job +enabled = true +``` + +This can be done manually, or by asking the AI agent (e.g. "re-enable the fix-unit-tests cron job"). + ### Kubernetes Deployment Mount `cronjob.toml` on a PVC so it persists across pod restarts, and set `usercron_path` in your config.toml: @@ -273,7 +311,7 @@ usercron_path = "cronjob.toml" - **Minute-aligned**: The scheduler aligns to minute boundaries (`:00`), so `0 9 * * *` fires at exactly 09:00:00, not at whatever second the process started. - **Overlap protection**: If a previous execution of the same job is still running, the next tick is skipped. - **Isolation**: Cron failures are logged but never block interactive chat traffic. -- **Stateless**: No persistence needed. Schedules are re-evaluated from config on restart. +- **Usercron persistence**: For usercron jobs, the scheduler may write `thread_id` and `enabled = false` back to `cronjob.toml`. - **Graceful shutdown**: In-flight cron tasks are waited on (up to 30 seconds) during shutdown. ## Sender Identity @@ -320,3 +358,4 @@ See [Kubernetes CronJob Reference Architecture](cronjob_k8s_refarch.md) for the | Channel not found | Bot not in channel | Invite the bot to the target channel | | Usercron not reloading | File not saved / wrong path | Check logs for `usercron file changed, reloading` | | Usercron parse error | Invalid TOML syntax | Check logs for `failed to parse usercron file` | +| Goal job does not auto-disable | Command did not exit `0` or output did not include `disable_on_success_match` | Run the command manually and confirm both conditions | diff --git a/src/config.rs b/src/config.rs index 2187f0d7f..77aad4345 100644 --- a/src/config.rs +++ b/src/config.rs @@ -336,6 +336,8 @@ pub struct PoolConfig { #[derive(Debug, Clone, Deserialize)] pub struct CronJobConfig { + /// Stable ID for usercron jobs that need scheduler writeback. + pub id: Option, /// Whether this cronjob is active (default: true) #[serde(default = "default_true")] pub enabled: bool, @@ -356,6 +358,17 @@ pub struct CronJobConfig { /// Timezone (default: "UTC") #[serde(default = "default_cron_timezone")] pub timezone: String, + /// Usercron-only: command to run before firing. Exit 0 plus a matching + /// `disable_on_success_match` means the goal is complete and the scheduler + /// disables the job in the usercron file. + pub disable_on_success: Option, + /// Usercron-only: required output marker for `disable_on_success`. + pub disable_on_success_match: Option, + /// Usercron-only: timeout for `disable_on_success`. + #[serde(default = "default_disable_on_success_timeout_secs")] + pub disable_on_success_timeout_secs: u64, + /// Usercron-only: working directory for `disable_on_success`. + pub disable_on_success_working_dir: Option, } fn default_cron_platform() -> String { @@ -367,6 +380,9 @@ fn default_cron_sender() -> String { fn default_cron_timezone() -> String { "UTC".into() } +fn default_disable_on_success_timeout_secs() -> u64 { + 60 +} /// Controls how tool calls are rendered in chat messages. /// diff --git a/src/cron.rs b/src/cron.rs index a570e96e1..db5828b22 100644 --- a/src/cron.rs +++ b/src/cron.rs @@ -9,7 +9,9 @@ use std::path::{Path, PathBuf}; use std::str::FromStr; use std::sync::Arc; use std::time::SystemTime; +use tokio::process::Command; use tokio::sync::Mutex; +use toml_edit::{value, DocumentMut}; use tracing::{debug, error, info, warn}; /// Parse a 5-field POSIX cron expression into a `Schedule`. @@ -269,6 +271,11 @@ pub fn validate_cronjobs( job.platform ); } + if job.disable_on_success.is_some() { + anyhow::bail!( + "cronjobs[{i}]: disable_on_success is only supported in usercron [[jobs]], not baseline [[cron.jobs]]" + ); + } } Ok(()) } @@ -321,6 +328,20 @@ pub fn load_usercron_file(path: &Path, configured_platforms: &[&str]) -> Vec, } /// Parse a list of CronJobConfig into ParsedJob, filtering out disabled/invalid entries. -fn parse_job_list(configs: &[CronJobConfig], source: &str) -> Vec { +fn parse_job_list( + configs: &[CronJobConfig], + source: &str, + usercron_path: Option<&Path>, +) -> Vec { configs.iter().filter(|job| { if !job.enabled { info!(schedule = %job.schedule, channel = %job.channel, source, "cronjob disabled, skipping"); @@ -365,7 +391,12 @@ fn parse_job_list(configs: &[CronJobConfig], source: &str) -> Vec { message = %job.message, source, "cronjob registered" ); - Some(ParsedJob { schedule, tz, config: job.clone() }) + Some(ParsedJob { + schedule, + tz, + config: job.clone(), + usercron_path: usercron_path.map(Path::to_path_buf), + }) }).collect() } @@ -382,7 +413,7 @@ pub async fn run_scheduler( let platform_refs: Vec<&str> = configured_platforms.iter().map(|s| s.as_str()).collect(); // Parse baseline jobs from config.toml - let baseline_jobs = parse_job_list(&cronjobs, "config.toml"); + let baseline_jobs = parse_job_list(&cronjobs, "config.toml", None); // Load initial usercron jobs let mut usercron_jobs = if let Some(ref path) = usercron_path { @@ -390,7 +421,7 @@ pub async fn run_scheduler( if !configs.is_empty() { info!(count = configs.len(), path = %path.display(), "loaded usercron jobs"); } - parse_job_list(&configs, "cronjob.toml") + parse_job_list(&configs, "cronjob.toml", Some(path.as_path())) } else { vec![] }; @@ -416,6 +447,9 @@ pub async fn run_scheduler( ); let in_flight: Arc>> = Arc::new(Mutex::new(HashSet::new())); + // Serialize usercron read-modify-write updates so concurrent jobs do not + // overwrite each other's enabled/thread_id changes. + let usercron_write_lock: Arc> = Arc::new(Mutex::new(())); // Align to next minute boundary let now = Utc::now(); @@ -443,18 +477,12 @@ pub async fn run_scheduler( if current_mtime != last_usercron_mtime { let configs = load_usercron_file(path, &platform_refs); info!(count = configs.len(), path = %path.display(), "usercron file changed, reloading"); - // Clear in-flight tracking for usercron jobs (indices shift on reload). - // Design note: if a still-running old usercron task's InFlightGuard - // drops after this point, the remove is a no-op (index already cleared). - // A new job at the same index *could* fire concurrently in this tick — - // probability is negligible (reload + fire on same tick + same index) - // and acceptable for a hot-reload feature. - { - let mut running = in_flight.lock().await; - let baseline_len = baseline_jobs.len(); - running.retain(|idx| *idx < baseline_len); - } - usercron_jobs = parse_job_list(&configs, "cronjob.toml"); + // Keep in-flight indices across reload. A scheduler writeback + // (thread_id or enabled=false) changes mtime deterministically; + // clearing usercron indices here would allow the same job to + // overlap on the next tick while its previous run is still active. + usercron_jobs = + parse_job_list(&configs, "cronjob.toml", Some(path.as_path())); last_usercron_mtime = current_mtime; } } @@ -483,11 +511,22 @@ pub async fn run_scheduler( in_flight.lock().await.insert(idx); let config = job.config.clone(); + let usercron_path = job.usercron_path.clone(); let router = router.clone(); let adapters = adapters.clone(); let in_flight = in_flight.clone(); + let usercron_write_lock = usercron_write_lock.clone(); tasks.spawn(async move { - fire_cronjob(idx, &config, &router, &adapters, in_flight).await; + fire_cronjob( + idx, + &config, + usercron_path, + &router, + &adapters, + in_flight, + usercron_write_lock, + ) + .await; }); } while tasks.try_join_next().is_some() {} @@ -523,9 +562,11 @@ impl Drop for InFlightGuard { async fn fire_cronjob( idx: usize, job: &CronJobConfig, + usercron_path: Option, router: &Arc, adapters: &HashMap>, in_flight: Arc>>, + usercron_write_lock: Arc>, ) { let _guard = InFlightGuard { idx, @@ -540,6 +581,63 @@ async fn fire_cronjob( } }; + if let Some(command) = non_empty_opt(job.disable_on_success.as_deref()) { + let marker = match non_empty_opt(job.disable_on_success_match.as_deref()) { + Some(marker) => marker, + None => { + warn!( + id = job.id.as_deref().unwrap_or(""), + "disable_on_success configured without disable_on_success_match, treating as not achieved" + ); + "" + } + }; + if !marker.is_empty() { + match check_disable_on_success(job, command, marker).await { + DisableOnSuccessResult::Achieved => { + let channel = ChannelRef { + platform: job.platform.clone(), + channel_id: job.channel.clone(), + thread_id: job.thread_id.clone(), + parent_id: None, + origin_event_id: None, + }; + if let Err(e) = adapter + .send_message( + &channel, + &format!( + "✅ Goal achieved: `{}` matched `{}`. Disabling cronjob.", + command, marker + ), + ) + .await + { + error!(channel = %job.channel, error = %e, "failed to send goal achieved message"); + } + + if let (Some(path), Some(id)) = + (usercron_path.as_deref(), non_empty_opt(job.id.as_deref())) + { + let _write_guard = usercron_write_lock.lock().await; + if let Err(e) = update_usercron_job(path, id, Some(false), None) { + error!(path = %path.display(), id, error = %e, "failed to disable completed usercron job"); + } + } else { + warn!("completed disable_on_success job has no usercron path or id, cannot write enabled=false"); + } + return; + } + DisableOnSuccessResult::NotAchieved(reason) => { + info!( + id = job.id.as_deref().unwrap_or(""), + reason, + "disable_on_success not achieved, firing cronjob normally" + ); + } + } + } + } + let thread_channel = ChannelRef { platform: job.platform.clone(), channel_id: job.channel.clone(), @@ -570,7 +668,19 @@ async fn fire_cronjob( .create_thread(&thread_channel, &trigger_msg, &thread_name) .await { - Ok(ch) => ch, + Ok(ch) => { + if let (Some(path), Some(id), Some(thread_id)) = ( + usercron_path.as_deref(), + non_empty_opt(job.id.as_deref()), + ch.thread_id.as_deref().or(Some(ch.channel_id.as_str())), + ) { + let _write_guard = usercron_write_lock.lock().await; + if let Err(e) = update_usercron_job(path, id, None, Some(thread_id)) { + warn!(path = %path.display(), id, error = %e, "failed to persist usercron thread_id"); + } + } + ch + } Err(e) => { error!(channel = %job.channel, error = %e, "failed to create cron thread"); let _ = adapter @@ -633,6 +743,171 @@ async fn fire_cronjob( } } +enum DisableOnSuccessResult { + Achieved, + NotAchieved(&'static str), +} + +fn non_empty_opt(value: Option<&str>) -> Option<&str> { + value.and_then(|s| { + let trimmed = s.trim(); + if trimmed.is_empty() { + None + } else { + Some(trimmed) + } + }) +} + +async fn check_disable_on_success( + job: &CronJobConfig, + command: &str, + marker: &str, +) -> DisableOnSuccessResult { + let timeout_secs = job.disable_on_success_timeout_secs.max(1); + let mut cmd = shell_command(command); + if let Some(dir) = non_empty_opt(job.disable_on_success_working_dir.as_deref()) { + cmd.current_dir(dir); + } + cmd.stdout(std::process::Stdio::piped()); + cmd.stderr(std::process::Stdio::piped()); + + let mut child = match cmd.spawn() { + Ok(child) => child, + Err(e) => { + warn!( + id = job.id.as_deref().unwrap_or(""), + command, + error = %e, + "disable_on_success command failed to start" + ); + return DisableOnSuccessResult::NotAchieved("command failed to start"); + } + }; + + // Take stdout/stderr handles and drain them concurrently to prevent pipe buffer deadlock. + let stdout_handle = child.stdout.take(); + let stderr_handle = child.stderr.take(); + + let stdout_task = tokio::spawn(async move { + let mut buf = Vec::new(); + if let Some(mut out) = stdout_handle { + let _ = tokio::io::AsyncReadExt::read_to_end(&mut out, &mut buf).await; + } + buf + }); + let stderr_task = tokio::spawn(async move { + let mut buf = Vec::new(); + if let Some(mut err) = stderr_handle { + let _ = tokio::io::AsyncReadExt::read_to_end(&mut err, &mut buf).await; + } + buf + }); + + let deadline = tokio::time::sleep(std::time::Duration::from_secs(timeout_secs)); + tokio::pin!(deadline); + + tokio::select! { + status = child.wait() => { + let status = match status { + Ok(s) => s, + Err(e) => { + warn!( + id = job.id.as_deref().unwrap_or(""), + command, + error = %e, + "disable_on_success command wait failed" + ); + stdout_task.abort(); + stderr_task.abort(); + return DisableOnSuccessResult::NotAchieved("command wait failed"); + } + }; + if !status.success() { + stdout_task.abort(); + stderr_task.abort(); + return DisableOnSuccessResult::NotAchieved("command exited non-zero"); + } + let stdout_buf = stdout_task.await.unwrap_or_default(); + let stderr_buf = stderr_task.await.unwrap_or_default(); + let stdout = String::from_utf8_lossy(&stdout_buf); + let stderr = String::from_utf8_lossy(&stderr_buf); + if stdout.contains(marker) || stderr.contains(marker) { + DisableOnSuccessResult::Achieved + } else { + DisableOnSuccessResult::NotAchieved("success marker not found") + } + } + _ = &mut deadline => { + // Timeout — kill the child to avoid orphan processes. + let _ = child.kill().await; + stdout_task.abort(); + stderr_task.abort(); + warn!( + id = job.id.as_deref().unwrap_or(""), + command, + timeout_secs, + "disable_on_success command timed out" + ); + DisableOnSuccessResult::NotAchieved("command timed out") + } + } +} + +fn shell_command(command: &str) -> Command { + #[cfg(windows)] + { + let mut child = Command::new("cmd"); + child.arg("/C").arg(command); + child + } + #[cfg(not(windows))] + { + let mut child = Command::new("sh"); + child.arg("-c").arg(command); + child + } +} + +fn update_usercron_job( + path: &Path, + id: &str, + enabled: Option, + thread_id: Option<&str>, +) -> anyhow::Result<()> { + let content = std::fs::read_to_string(path)?; + let mut doc = content.parse::()?; + let jobs = doc + .get_mut("jobs") + .and_then(|item| item.as_array_of_tables_mut()) + .ok_or_else(|| anyhow::anyhow!("usercron file has no [[jobs]] array"))?; + + let mut found = false; + for table in jobs.iter_mut() { + if table.get("id").and_then(|item| item.as_str()) != Some(id) { + continue; + } + if let Some(enabled) = enabled { + table["enabled"] = value(enabled); + } + if let Some(thread_id) = thread_id { + table["thread_id"] = value(thread_id); + } + found = true; + break; + } + + if !found { + anyhow::bail!("usercron job id {:?} not found", id); + } + + // Atomic write: write to temp file then rename to avoid corruption on crash. + let tmp = path.with_extension("toml.tmp"); + std::fs::write(&tmp, doc.to_string())?; + std::fs::rename(&tmp, path)?; + Ok(()) +} + #[cfg(test)] mod tests { use super::*; @@ -966,6 +1241,11 @@ message = "hello" assert_eq!(job.sender_name, "openab-cron"); assert_eq!(job.timezone, "UTC"); assert!(job.thread_id.is_none()); + assert!(job.id.is_none()); + assert!(job.disable_on_success.is_none()); + assert!(job.disable_on_success_match.is_none()); + assert_eq!(job.disable_on_success_timeout_secs, 60); + assert!(job.disable_on_success_working_dir.is_none()); } #[test] @@ -992,6 +1272,11 @@ platform = "slack" sender_name = "DailyOps" timezone = "Asia/Taipei" thread_id = "789" +id = "daily-report" +disable_on_success = "npm test" +disable_on_success_match = "SUCCESS" +disable_on_success_timeout_secs = 30 +disable_on_success_working_dir = "/tmp/project" "#; let cfg: UsercronFile = toml::from_str(toml_str).unwrap(); let job = &cfg.jobs[0]; @@ -999,6 +1284,14 @@ thread_id = "789" assert_eq!(job.sender_name, "DailyOps"); assert_eq!(job.timezone, "Asia/Taipei"); assert_eq!(job.thread_id.as_deref(), Some("789")); + assert_eq!(job.id.as_deref(), Some("daily-report")); + assert_eq!(job.disable_on_success.as_deref(), Some("npm test")); + assert_eq!(job.disable_on_success_match.as_deref(), Some("SUCCESS")); + assert_eq!(job.disable_on_success_timeout_secs, 30); + assert_eq!( + job.disable_on_success_working_dir.as_deref(), + Some("/tmp/project") + ); } #[test] @@ -1085,11 +1378,180 @@ platform = "slack" assert_eq!(jobs[0].message, "discord job"); } + #[test] + fn load_usercron_skips_disable_on_success_without_id() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("cronjob.toml"); + std::fs::write( + &path, + r#" +[[jobs]] +schedule = "* * * * *" +channel = "123" +message = "missing id" +disable_on_success = "echo SUCCESS" +disable_on_success_match = "SUCCESS" +"#, + ) + .unwrap(); + let jobs = load_usercron_file(&path, &["discord"]); + assert!(jobs.is_empty()); + } + + #[test] + fn load_usercron_skips_disable_on_success_without_match() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("cronjob.toml"); + std::fs::write( + &path, + r#" +[[jobs]] +id = "goal" +schedule = "* * * * *" +channel = "123" +message = "missing marker" +disable_on_success = "echo SUCCESS" +"#, + ) + .unwrap(); + let jobs = load_usercron_file(&path, &["discord"]); + assert!(jobs.is_empty()); + } + + #[test] + fn validate_cronjobs_rejects_baseline_disable_on_success() { + let jobs = vec![CronJobConfig { + id: Some("baseline-goal".into()), + enabled: true, + schedule: "* * * * *".into(), + channel: "123".into(), + message: "hi".into(), + platform: "discord".into(), + sender_name: "test".into(), + thread_id: None, + timezone: "UTC".into(), + disable_on_success: Some("echo SUCCESS".into()), + disable_on_success_match: Some("SUCCESS".into()), + disable_on_success_timeout_secs: 60, + disable_on_success_working_dir: None, + }]; + let err = validate_cronjobs(&jobs, &["discord"]).unwrap_err(); + assert!(err.to_string().contains("only supported in usercron")); + } + + #[test] + fn update_usercron_job_sets_enabled_and_thread_id_by_id() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("cronjob.toml"); + std::fs::write( + &path, + r#" +[[jobs]] +id = "goal-a" +enabled = true +schedule = "* * * * *" +channel = "123" +message = "a" + +[[jobs]] +id = "goal-b" +enabled = true +schedule = "* * * * *" +channel = "456" +message = "b" +"#, + ) + .unwrap(); + + update_usercron_job(&path, "goal-b", Some(false), Some("thread-456")).unwrap(); + + let updated = std::fs::read_to_string(&path).unwrap(); + let doc = updated.parse::().unwrap(); + let jobs = doc["jobs"].as_array_of_tables().unwrap(); + let job_a = jobs.iter().next().unwrap(); + let job_b = jobs.iter().nth(1).unwrap(); + assert_eq!(job_a["id"].as_str(), Some("goal-a")); + assert_eq!(job_a["enabled"].as_bool(), Some(true)); + assert!(job_a.get("thread_id").is_none()); + assert_eq!(job_b["id"].as_str(), Some("goal-b")); + assert_eq!(job_b["enabled"].as_bool(), Some(false)); + assert_eq!(job_b["thread_id"].as_str(), Some("thread-456")); + } + + #[test] + fn update_usercron_job_errors_for_missing_id() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("cronjob.toml"); + std::fs::write( + &path, + r#" +[[jobs]] +id = "goal-a" +schedule = "* * * * *" +channel = "123" +message = "a" +"#, + ) + .unwrap(); + let err = update_usercron_job(&path, "missing", Some(false), None).unwrap_err(); + assert!(err.to_string().contains("not found")); + } + + #[tokio::test] + async fn disable_on_success_requires_exit_zero_and_marker() { + let mut job = test_cron_job(); + job.disable_on_success_timeout_secs = 5; + + assert!(matches!( + check_disable_on_success(&job, "printf SUCCESS", "SUCCESS").await, + DisableOnSuccessResult::Achieved + )); + assert!(matches!( + check_disable_on_success(&job, "printf DONE", "SUCCESS").await, + DisableOnSuccessResult::NotAchieved("success marker not found") + )); + assert!(matches!( + check_disable_on_success(&job, "printf SUCCESS; exit 1", "SUCCESS").await, + DisableOnSuccessResult::NotAchieved("command exited non-zero") + )); + } + + #[tokio::test] + async fn disable_on_success_kills_child_on_timeout() { + let mut job = test_cron_job(); + job.disable_on_success_timeout_secs = 1; + + let result = check_disable_on_success(&job, "sleep 999", "SUCCESS").await; + assert!(matches!( + result, + DisableOnSuccessResult::NotAchieved("command timed out") + )); + } + + fn test_cron_job() -> CronJobConfig { + CronJobConfig { + id: Some("goal".into()), + enabled: true, + schedule: "* * * * *".into(), + channel: "123".into(), + message: "hi".into(), + platform: "discord".into(), + sender_name: "test".into(), + thread_id: None, + timezone: "UTC".into(), + disable_on_success: Some("echo SUCCESS".into()), + disable_on_success_match: Some("SUCCESS".into()), + disable_on_success_timeout_secs: 60, + disable_on_success_working_dir: None, + } + } + // --- validate_cronjobs tests --- #[test] fn validate_cronjobs_valid_passes() { let jobs = vec![CronJobConfig { + id: None, enabled: true, schedule: "0 9 * * 1-5".into(), channel: "123".into(), @@ -1098,6 +1560,10 @@ platform = "slack" sender_name: "test".into(), thread_id: None, timezone: "UTC".into(), + disable_on_success: None, + disable_on_success_match: None, + disable_on_success_timeout_secs: 60, + disable_on_success_working_dir: None, }]; assert!(validate_cronjobs(&jobs, &["discord"]).is_ok()); } @@ -1105,6 +1571,7 @@ platform = "slack" #[test] fn validate_cronjobs_invalid_cron_fails() { let jobs = vec![CronJobConfig { + id: None, enabled: true, schedule: "bad".into(), channel: "123".into(), @@ -1113,6 +1580,10 @@ platform = "slack" sender_name: "test".into(), thread_id: None, timezone: "UTC".into(), + disable_on_success: None, + disable_on_success_match: None, + disable_on_success_timeout_secs: 60, + disable_on_success_working_dir: None, }]; let err = validate_cronjobs(&jobs, &["discord"]).unwrap_err(); assert!(err.to_string().contains("invalid cron expression")); @@ -1121,6 +1592,7 @@ platform = "slack" #[test] fn validate_cronjobs_invalid_timezone_fails() { let jobs = vec![CronJobConfig { + id: None, enabled: true, schedule: "* * * * *".into(), channel: "123".into(), @@ -1129,6 +1601,10 @@ platform = "slack" sender_name: "test".into(), thread_id: None, timezone: "Mars/Olympus".into(), + disable_on_success: None, + disable_on_success_match: None, + disable_on_success_timeout_secs: 60, + disable_on_success_working_dir: None, }]; let err = validate_cronjobs(&jobs, &["discord"]).unwrap_err(); assert!(err.to_string().contains("invalid timezone")); @@ -1137,6 +1613,7 @@ platform = "slack" #[test] fn validate_cronjobs_unknown_platform_fails() { let jobs = vec![CronJobConfig { + id: None, enabled: true, schedule: "* * * * *".into(), channel: "123".into(), @@ -1145,6 +1622,10 @@ platform = "slack" sender_name: "test".into(), thread_id: None, timezone: "UTC".into(), + disable_on_success: None, + disable_on_success_match: None, + disable_on_success_timeout_secs: 60, + disable_on_success_working_dir: None, }]; let err = validate_cronjobs(&jobs, &["discord"]).unwrap_err(); assert!(err.to_string().contains("unknown platform")); @@ -1153,6 +1634,7 @@ platform = "slack" #[test] fn validate_cronjobs_unconfigured_platform_fails() { let jobs = vec![CronJobConfig { + id: None, enabled: true, schedule: "* * * * *".into(), channel: "123".into(), @@ -1161,6 +1643,10 @@ platform = "slack" sender_name: "test".into(), thread_id: None, timezone: "UTC".into(), + disable_on_success: None, + disable_on_success_match: None, + disable_on_success_timeout_secs: 60, + disable_on_success_working_dir: None, }]; let err = validate_cronjobs(&jobs, &["discord"]).unwrap_err(); assert!(err.to_string().contains("not configured")); @@ -1169,6 +1655,7 @@ platform = "slack" #[test] fn validate_cronjobs_disabled_with_invalid_cron_passes() { let jobs = vec![CronJobConfig { + id: None, enabled: false, schedule: "bad".into(), channel: "123".into(), @@ -1177,6 +1664,10 @@ platform = "slack" sender_name: "test".into(), thread_id: None, timezone: "UTC".into(), + disable_on_success: None, + disable_on_success_match: None, + disable_on_success_timeout_secs: 60, + disable_on_success_working_dir: None, }]; assert!(validate_cronjobs(&jobs, &["discord"]).is_ok()); } @@ -1184,6 +1675,7 @@ platform = "slack" #[test] fn validate_cronjobs_enabled_with_invalid_cron_still_fails() { let jobs = vec![CronJobConfig { + id: None, enabled: true, schedule: "bad".into(), channel: "123".into(), @@ -1192,6 +1684,10 @@ platform = "slack" sender_name: "test".into(), thread_id: None, timezone: "UTC".into(), + disable_on_success: None, + disable_on_success_match: None, + disable_on_success_timeout_secs: 60, + disable_on_success_working_dir: None, }]; assert!(validate_cronjobs(&jobs, &["discord"]).is_err()); } From db76abb9a43b51b7e7edf80d029e1b2dc9db17db Mon Sep 17 00:00:00 2001 From: "openab-app[bot]" <274185012+openab-app[bot]@users.noreply.github.com> Date: Thu, 21 May 2026 17:49:39 -0400 Subject: [PATCH 071/100] release: v0.8.4-beta.1 (#893) Co-authored-by: openab-app[bot] <274185012+openab-app[bot]@users.noreply.github.com> --- Cargo.toml | 2 +- charts/openab/Chart.yaml | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index b6f16b099..9b64a3735 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "openab" -version = "0.8.3" +version = "0.8.4" edition = "2021" license = "MIT" diff --git a/charts/openab/Chart.yaml b/charts/openab/Chart.yaml index d820930b2..e71861ba1 100644 --- a/charts/openab/Chart.yaml +++ b/charts/openab/Chart.yaml @@ -2,5 +2,5 @@ apiVersion: v2 name: openab description: A lightweight, secure, cloud-native ACP harness that bridges Discord and any ACP-compatible coding CLI. type: application -version: 0.8.3 -appVersion: "0.8.3" +version: 0.8.4-beta.1 +appVersion: "0.8.4-beta.1" From b3bcefb83632ff9de953db0e2a70c5d9961e446c Mon Sep 17 00:00:00 2001 From: chaodufashi Date: Thu, 21 May 2026 21:26:22 -0400 Subject: [PATCH 072/100] docs(opencode): add xAI Grok auth example with device-code flow (#894) Co-authored-by: chaodu-agent --- docs/opencode.md | 53 ++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 53 insertions(+) diff --git a/docs/opencode.md b/docs/opencode.md index 97c4117c6..2b65bd826 100644 --- a/docs/opencode.md +++ b/docs/opencode.md @@ -116,6 +116,59 @@ kubectl logs deployment/openab-opencode --tail=5 `@mention` the bot in your Discord channel to start chatting. +## Example: xAI Grok with SuperGrok OAuth + +### 1. Create the auth directory + +OpenCode stores credentials at `~/.local/share/opencode/auth.json`. The directory must exist before login: + +```bash +kubectl exec deployment/openab-opencode -- mkdir -p /home/node/.local/share/opencode +``` + +### 2. Authenticate xAI (device-code flow) + +```bash +kubectl exec -it deployment/openab-opencode -- opencode auth login -p xai +``` + +Select **"xAI Grok OAuth (Headless / Remote / VPS)"**. The CLI prints a URL and a short code: + +``` +Open https://x.ai/device on any device and enter code: ABCD-1234 +``` + +Open the URL on any device with a browser, enter the code, and approve. + +### 3. Verify auth file was created + +```bash +kubectl exec deployment/openab-opencode -- cat /home/node/.local/share/opencode/auth.json +``` + +You should see a JSON object with `xai` credentials. + +### 4. Set default model + +Create `opencode.json` in the working directory (`/home/node`): + +```bash +kubectl exec -it deployment/openab-opencode -- bash -c 'cat > /home/node/opencode.json << "EOF" +{ + "$schema": "https://opencode.ai/config.json", + "model": "xai/grok-4.3" +} +EOF' +``` + +### 5. Restart to pick up config + +```bash +kubectl rollout restart deployment/openab-opencode +``` + +> **Important:** Do NOT set a custom `baseURL` or provider override for xAI. The built-in xAI provider handles routing correctly. A stale `~/.config/opencode/opencode.json` with `baseURL: "http://localhost:9090/v1"` (from xai-proxy setups) will break xAI — delete it if present. + ## Notes - **Tool authorization**: OpenCode handles tool authorization internally and never emits `session/request_permission` — all tools run without user confirmation, equivalent to `--trust-all-tools` on other backends. From 1f1640000ba71bff08149ba745906c39da3060e3 Mon Sep 17 00:00:00 2001 From: chaodufashi Date: Thu, 21 May 2026 23:51:34 -0400 Subject: [PATCH 073/100] feat: add agy-acp adapter for Google Antigravity CLI (#896) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a thin Rust ACP stdio adapter (agy-acp) that translates openab's JSON-RPC protocol into `agy -p` invocations, enabling Antigravity CLI as an agent backend. - agy-acp/: standalone Rust crate, reads ACP JSON-RPC on stdin, spawns `agy -p "prompt"` for each session/prompt, uses --continue for multi-turn session continuity - Dockerfile.antigravity: multi-stage build bundling openab + agy-acp + agy binary from Google Storage - docs/antigravity.md: configuration and usage guide Tested end-to-end locally with Discord (周嘟嘟 bot) — ~5s response time. Closes #863 Co-authored-by: Pahud Hsieh --- .github/workflows/pr-preview.yml | 1 + Dockerfile.antigravity | 55 +++ agy-acp/.gitignore | 1 + agy-acp/Cargo.lock | 654 +++++++++++++++++++++++++++++++ agy-acp/Cargo.toml | 12 + agy-acp/src/main.rs | 200 ++++++++++ docs/antigravity.md | 79 ++++ 7 files changed, 1002 insertions(+) create mode 100644 Dockerfile.antigravity create mode 100644 agy-acp/.gitignore create mode 100644 agy-acp/Cargo.lock create mode 100644 agy-acp/Cargo.toml create mode 100644 agy-acp/src/main.rs create mode 100644 docs/antigravity.md diff --git a/.github/workflows/pr-preview.yml b/.github/workflows/pr-preview.yml index a5cac4419..2dbf2a69c 100644 --- a/.github/workflows/pr-preview.yml +++ b/.github/workflows/pr-preview.yml @@ -13,6 +13,7 @@ on: type: choice options: - default + - antigravity - claude - codex - copilot diff --git a/Dockerfile.antigravity b/Dockerfile.antigravity new file mode 100644 index 000000000..a73fd1f13 --- /dev/null +++ b/Dockerfile.antigravity @@ -0,0 +1,55 @@ +# --- Build openab --- +FROM rust:1-bookworm AS builder +WORKDIR /build +COPY Cargo.toml Cargo.lock ./ +RUN mkdir src && echo 'fn main() {}' > src/main.rs && cargo build --release && rm -rf src +COPY src/ src/ +RUN touch src/main.rs && cargo build --release + +# --- Build agy-acp adapter --- +FROM rust:1-bookworm AS adapter-builder +WORKDIR /build +COPY agy-acp/Cargo.toml ./ +RUN mkdir src && echo 'fn main() {}' > src/main.rs && cargo build --release && rm -rf src +COPY agy-acp/src/ src/ +RUN touch src/main.rs && cargo build --release + +# --- Runtime stage --- +FROM debian:bookworm-slim +RUN apt-get update && apt-get install -y --no-install-recommends ca-certificates curl procps tini && rm -rf /var/lib/apt/lists/* + +# Install agy (Google Antigravity CLI) +RUN ARCH=$(dpkg --print-architecture) && \ + case "$ARCH" in \ + amd64) PLATFORM="linux_amd64" ;; \ + arm64) PLATFORM="linux_arm64" ;; \ + *) echo "unsupported arch: $ARCH" && exit 1 ;; \ + esac && \ + MANIFEST_URL="https://antigravity-cli-auto-updater-974169037036.us-central1.run.app/manifests/${PLATFORM}.json" && \ + DOWNLOAD_URL=$(curl -fsSL "$MANIFEST_URL" | grep -o '"url": *"[^"]*"' | cut -d'"' -f4) && \ + curl -fsSL "$DOWNLOAD_URL" | tar -xz -C /usr/local/bin && \ + mv /usr/local/bin/antigravity /usr/local/bin/agy && \ + chmod +x /usr/local/bin/agy + +# Install gh CLI +RUN curl -fsSL https://cli.github.com/packages/githubcli-archive-keyring.gpg \ + -o /usr/share/keyrings/githubcli-archive-keyring.gpg && \ + echo "deb [arch=$(dpkg --print-architecture) signed-by=/usr/share/keyrings/githubcli-archive-keyring.gpg] https://cli.github.com/packages stable main" \ + > /etc/apt/sources.list.d/github-cli.list && \ + apt-get update && apt-get install -y --no-install-recommends gh && \ + rm -rf /var/lib/apt/lists/* + +RUN useradd -m -s /bin/bash agent +ENV HOME=/home/agent +WORKDIR /home/agent + +COPY --from=builder --chown=agent:agent /build/target/release/openab /usr/local/bin/openab +COPY --from=adapter-builder --chown=agent:agent /build/target/release/agy-acp /usr/local/bin/agy-acp + +RUN mkdir -p /home/agent/.gemini && chown -R agent:agent /home/agent/.gemini + +USER agent +HEALTHCHECK --interval=30s --timeout=5s --retries=3 \ + CMD pgrep -x openab || exit 1 +ENTRYPOINT ["tini", "--"] +CMD ["openab", "run", "-c", "/etc/openab/config.toml"] diff --git a/agy-acp/.gitignore b/agy-acp/.gitignore new file mode 100644 index 000000000..eb5a316cb --- /dev/null +++ b/agy-acp/.gitignore @@ -0,0 +1 @@ +target diff --git a/agy-acp/Cargo.lock b/agy-acp/Cargo.lock new file mode 100644 index 000000000..a40584813 --- /dev/null +++ b/agy-acp/Cargo.lock @@ -0,0 +1,654 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "agy-acp" +version = "0.1.0" +dependencies = [ + "serde", + "serde_json", + "tokio", + "uuid", +] + +[[package]] +name = "anyhow" +version = "1.0.102" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" + +[[package]] +name = "bitflags" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3" + +[[package]] +name = "bumpalo" +version = "3.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" + +[[package]] +name = "bytes" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys", +] + +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + +[[package]] +name = "futures-core" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" + +[[package]] +name = "futures-task" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" + +[[package]] +name = "futures-util" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +dependencies = [ + "futures-core", + "futures-task", + "pin-project-lite", + "slab", +] + +[[package]] +name = "getrandom" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" +dependencies = [ + "cfg-if", + "libc", + "r-efi", + "wasip2", + "wasip3", +] + +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "foldhash", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "id-arena" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" + +[[package]] +name = "indexmap" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown 0.17.1", + "serde", + "serde_core", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "js-sys" +version = "0.3.98" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67df7112613f8bfd9150013a0314e196f4800d3201ae742489d999db2f979f08" +dependencies = [ + "cfg-if", + "futures-util", + "once_cell", + "wasm-bindgen", +] + +[[package]] +name = "leb128fmt" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" + +[[package]] +name = "libc" +version = "0.2.186" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" + +[[package]] +name = "memchr" +version = "2.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" + +[[package]] +name = "mio" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1" +dependencies = [ + "libc", + "wasi", + "windows-sys", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn", +] + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags", +] + +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.150" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" + +[[package]] +name = "socket2" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e" +dependencies = [ + "libc", + "windows-sys", +] + +[[package]] +name = "syn" +version = "2.0.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "tokio" +version = "1.52.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe" +dependencies = [ + "bytes", + "libc", + "mio", + "parking_lot", + "pin-project-lite", + "signal-hook-registry", + "socket2", + "tokio-macros", + "windows-sys", +] + +[[package]] +name = "tokio-macros" +version = "2.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + +[[package]] +name = "uuid" +version = "1.23.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd74a9687298c6858e9b88ec8935ec45d22e8fd5e6394fa1bd4e99a87789c76" +dependencies = [ + "getrandom", + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.3+wasi-0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20064672db26d7cdc89c7798c48a0fdfac8213434a1186e5ef29fd560ae223d6" +dependencies = [ + "wit-bindgen 0.57.1", +] + +[[package]] +name = "wasip3" +version = "0.4.0+wasi-0.3.0-rc-2026-01-06" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" +dependencies = [ + "wit-bindgen 0.51.0", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.121" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49ace1d07c165b0864824eee619580c4689389afa9dc9ed3a4c75040d82e6790" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.121" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e68e6f4afd367a562002c05637acb8578ff2dea1943df76afb9e83d177c8578" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.121" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d95a9ec35c64b2a7cb35d3fead40c4238d0940c86d107136999567a4703259f2" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.121" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4e0100b01e9f0d03189a92b96772a1fb998639d981193d7dbab487302513441" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "wasm-encoder" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" +dependencies = [ + "leb128fmt", + "wasmparser", +] + +[[package]] +name = "wasm-metadata" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" +dependencies = [ + "anyhow", + "indexmap", + "wasm-encoder", + "wasmparser", +] + +[[package]] +name = "wasmparser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" +dependencies = [ + "bitflags", + "hashbrown 0.15.5", + "indexmap", + "semver", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "wit-bindgen" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" +dependencies = [ + "wit-bindgen-rust-macro", +] + +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + +[[package]] +name = "wit-bindgen-core" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" +dependencies = [ + "anyhow", + "heck", + "wit-parser", +] + +[[package]] +name = "wit-bindgen-rust" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" +dependencies = [ + "anyhow", + "heck", + "indexmap", + "prettyplease", + "syn", + "wasm-metadata", + "wit-bindgen-core", + "wit-component", +] + +[[package]] +name = "wit-bindgen-rust-macro" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a" +dependencies = [ + "anyhow", + "prettyplease", + "proc-macro2", + "quote", + "syn", + "wit-bindgen-core", + "wit-bindgen-rust", +] + +[[package]] +name = "wit-component" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" +dependencies = [ + "anyhow", + "bitflags", + "indexmap", + "log", + "serde", + "serde_derive", + "serde_json", + "wasm-encoder", + "wasm-metadata", + "wasmparser", + "wit-parser", +] + +[[package]] +name = "wit-parser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" +dependencies = [ + "anyhow", + "id-arena", + "indexmap", + "log", + "semver", + "serde", + "serde_derive", + "serde_json", + "unicode-xid", + "wasmparser", +] + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" diff --git a/agy-acp/Cargo.toml b/agy-acp/Cargo.toml new file mode 100644 index 000000000..681898924 --- /dev/null +++ b/agy-acp/Cargo.toml @@ -0,0 +1,12 @@ +[package] +name = "agy-acp" +version = "0.1.0" +edition = "2021" +license = "MIT" +description = "ACP stdio adapter for Google Antigravity CLI (agy)" + +[dependencies] +tokio = { version = "1", features = ["full"] } +serde = { version = "1", features = ["derive"] } +serde_json = "1" +uuid = { version = "1", features = ["v4"] } diff --git a/agy-acp/src/main.rs b/agy-acp/src/main.rs new file mode 100644 index 000000000..8cb2d92b7 --- /dev/null +++ b/agy-acp/src/main.rs @@ -0,0 +1,200 @@ +use serde::{Deserialize, Serialize}; +use serde_json::{json, Value}; +use std::collections::HashMap; +use std::io::{self, BufRead, Write}; +use tokio::process::Command; +use tokio::sync::mpsc; +use uuid::Uuid; + +#[derive(Debug, Deserialize)] +struct JsonRpcRequest { + id: Option, + method: Option, + params: Option, +} + +#[derive(Debug, Serialize)] +struct JsonRpcResponse { + jsonrpc: &'static str, + id: u64, + #[serde(skip_serializing_if = "Option::is_none")] + result: Option, + #[serde(skip_serializing_if = "Option::is_none")] + error: Option, +} + +#[derive(Debug, Serialize)] +struct JsonRpcNotification { + jsonrpc: &'static str, + method: String, + params: Value, +} + +struct Session { + has_history: bool, +} + +struct Adapter { + sessions: HashMap, + working_dir: String, +} + +impl Adapter { + fn new() -> Self { + Self { + sessions: HashMap::new(), + working_dir: std::env::var("AGY_WORKING_DIR") + .unwrap_or_else(|_| "/tmp".to_string()), + } + } + + fn handle_initialize(&self, id: u64) -> JsonRpcResponse { + JsonRpcResponse { + jsonrpc: "2.0", + id, + result: Some(json!({ + "protocolVersion": 1, + "agentInfo": { "name": "agy", "version": env!("CARGO_PKG_VERSION") }, + "agentCapabilities": { "streaming": true, "loadSession": false }, + })), + error: None, + } + } + + fn handle_session_new(&mut self, id: u64) -> JsonRpcResponse { + let session_id = Uuid::new_v4().to_string(); + self.sessions.insert(session_id.clone(), Session { has_history: false }); + JsonRpcResponse { + jsonrpc: "2.0", + id, + result: Some(json!({ "sessionId": session_id })), + error: None, + } + } + + async fn handle_session_prompt(&mut self, id: u64, params: &Value) -> Vec { + let session_id = params.get("sessionId").and_then(|v| v.as_str()).unwrap_or(""); + let prompt_text = params + .get("prompt") + .and_then(|v| v.as_array()) + .map(|arr| { + arr.iter() + .filter_map(|b| b.get("text").and_then(|t| t.as_str())) + .filter(|t| !t.starts_with("")) + .collect::>() + .join("\n") + }) + .unwrap_or_default(); + let clean_prompt = prompt_text.trim(); + + let mut args: Vec = Vec::new(); + if let Some(session) = self.sessions.get(session_id) { + if session.has_history { + args.push("--continue".to_string()); + } + } + args.push("-p".to_string()); + args.push(clean_prompt.to_string()); + + let result = Command::new("agy") + .args(&args) + .current_dir(&self.working_dir) + .stdin(std::process::Stdio::null()) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::null()) + .output() + .await; + + let mut output_lines = Vec::new(); + + match result { + Ok(output) => { + let text = String::from_utf8_lossy(&output.stdout).to_string(); + if let Some(session) = self.sessions.get_mut(session_id) { + session.has_history = true; + } + let notification = serde_json::to_string(&JsonRpcNotification { + jsonrpc: "2.0", + method: "session/update".to_string(), + params: json!({ + "sessionId": session_id, + "update": { + "sessionUpdate": "agent_message_chunk", + "content": { "type": "text", "text": text }, + }, + }), + }).unwrap(); + output_lines.push(notification); + let resp = JsonRpcResponse { jsonrpc: "2.0", id, result: Some(json!({ "stopReason": "end_turn" })), error: None }; + output_lines.push(serde_json::to_string(&resp).unwrap()); + } + Err(e) => { + let resp = JsonRpcResponse { jsonrpc: "2.0", id, result: None, error: Some(json!({"code":-32000,"message":format!("failed to run agy: {e}")})) }; + output_lines.push(serde_json::to_string(&resp).unwrap()); + } + } + output_lines + } +} + +#[tokio::main] +async fn main() { + let mut adapter = Adapter::new(); + + // Read stdin lines in a blocking thread, send to async handler + let (tx, mut rx) = mpsc::unbounded_channel::(); + std::thread::spawn(move || { + let stdin = io::stdin(); + for line in stdin.lock().lines() { + match line { + Ok(l) if !l.trim().is_empty() => { + if tx.send(l).is_err() { + break; + } + } + Err(_) => break, + _ => {} + } + } + }); + + let mut stdout = io::stdout(); + + while let Some(line) = rx.recv().await { + let req: JsonRpcRequest = match serde_json::from_str(&line) { + Ok(r) => r, + Err(_) => continue, + }; + let id = match req.id { + Some(id) => id, + None => continue, + }; + + let output = match req.method.as_deref() { + Some("initialize") => { + vec![serde_json::to_string(&adapter.handle_initialize(id)).unwrap()] + } + Some("session/new") => { + vec![serde_json::to_string(&adapter.handle_session_new(id)).unwrap()] + } + Some("session/prompt") => { + let params = req.params.unwrap_or(json!({})); + adapter.handle_session_prompt(id, ¶ms).await + } + Some("session/cancel") => { + let r = JsonRpcResponse { jsonrpc: "2.0", id, result: Some(json!({})), error: None }; + vec![serde_json::to_string(&r).unwrap()] + } + Some(method) => { + let r = JsonRpcResponse { jsonrpc: "2.0", id, result: None, error: Some(json!({"code":-32601,"message":format!("method not found: {method}")})) }; + vec![serde_json::to_string(&r).unwrap()] + } + None => continue, + }; + + for line in output { + let _ = writeln!(stdout, "{}", line); + } + let _ = stdout.flush(); + } +} diff --git a/docs/antigravity.md b/docs/antigravity.md new file mode 100644 index 000000000..14d3d8338 --- /dev/null +++ b/docs/antigravity.md @@ -0,0 +1,79 @@ +# Google Antigravity CLI (agy) + +OpenAB supports [Google Antigravity CLI](https://antigravity.google/) via the `agy-acp` adapter — a thin Rust binary that translates ACP JSON-RPC into `agy -p` invocations. + +## How It Works + +``` +openab ──ACP JSON-RPC──► agy-acp ──spawns──► agy --dangerously-skip-permissions -p "prompt" + agy --continue -p "follow-up" +``` + +- First prompt in a session: `agy -p "text"` +- Subsequent prompts: `agy --continue -p "text"` (resumes most recent conversation) +- Tool permissions are auto-approved via `--dangerously-skip-permissions` + +## Configuration + +```toml +[agent] +command = "agy-acp" +args = [] +working_dir = "/home/agent" +``` + +Or with the Docker image: + +```toml +[agent] +command = "/usr/local/bin/agy-acp" +args = [] +working_dir = "/home/agent" +``` + +### Environment Variables + +| Variable | Description | Default | +|----------|-------------|---------| +| `AGY_WORKING_DIR` | Working directory for agy invocations | `/tmp` | + +## Docker + +```bash +docker build -f Dockerfile.antigravity -t openab-antigravity . +``` + +## Authentication + +Antigravity CLI uses Google Sign-In (OAuth). Authenticate inside the container: + +```bash +kubectl exec -it deployment/openab-antigravity -- agy auth +``` + +Complete the device flow in your browser. Auth tokens persist in the PVC at `~/.gemini/`. + +## Helm + +```yaml +agents: + antigravity: + discord: + botToken: "${DISCORD_BOT_TOKEN}" + allowedChannels: ["123456789"] + agent: + command: "agy-acp" + args: [] + workingDir: "/home/agent" + env: + AGY_WORKING_DIR: "/home/agent" + image: + repository: ghcr.io/openabdev/openab-antigravity + tag: "latest" +``` + +## Limitations + +- **No streaming**: `agy -p` returns the full response at once; the adapter sends it as a single `agent_message_chunk` notification. +- **Cancel is a no-op**: `agy -p` runs to completion; `session/cancel` acknowledges but cannot interrupt. +- **Session continuity uses `--continue`**: This resumes the *most recent* agy conversation, which works for single-user-per-pod deployments but may conflict if multiple sessions run concurrently in the same container. From 37f463222bd64cd25e17e24a11644f7f7932a5a1 Mon Sep 17 00:00:00 2001 From: "openab-app[bot]" <274185012+openab-app[bot]@users.noreply.github.com> Date: Thu, 21 May 2026 23:56:37 -0400 Subject: [PATCH 074/100] release: v0.8.4-beta.2 (#897) Co-authored-by: openab-app[bot] <274185012+openab-app[bot]@users.noreply.github.com> --- charts/openab/Chart.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/charts/openab/Chart.yaml b/charts/openab/Chart.yaml index e71861ba1..1d0286789 100644 --- a/charts/openab/Chart.yaml +++ b/charts/openab/Chart.yaml @@ -2,5 +2,5 @@ apiVersion: v2 name: openab description: A lightweight, secure, cloud-native ACP harness that bridges Discord and any ACP-compatible coding CLI. type: application -version: 0.8.4-beta.1 -appVersion: "0.8.4-beta.1" +version: 0.8.4-beta.2 +appVersion: "0.8.4-beta.2" From 0ac3132b30d74dee7c32a1ffb785e3d64c1bf94c Mon Sep 17 00:00:00 2001 From: chaodufashi Date: Fri, 22 May 2026 00:05:29 -0400 Subject: [PATCH 075/100] docs: add Antigravity CLI to README agent list (#898) Co-authored-by: Pahud Hsieh --- README.md | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index c5f81ddc1..73da28f2f 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ ![OpenAB banner](images/banner.jpg) -A lightweight, secure, cloud-native ACP harness that bridges **Discord, Slack**, and any [Agent Client Protocol](https://github.com/anthropics/agent-protocol)-compatible coding CLI (Kiro CLI, Claude Code, Codex, Gemini, OpenCode, Copilot CLI, Hermes, Grok Build, etc.) over stdio JSON-RPC — delivering the next-generation development experience. **Telegram, LINE, Feishu/Lark, Google Chat**, and other webhook-based platforms are supported via the standalone [Custom Gateway](gateway/). +A lightweight, secure, cloud-native ACP harness that bridges **Discord, Slack**, and any [Agent Client Protocol](https://github.com/anthropics/agent-protocol)-compatible coding CLI (Kiro CLI, Claude Code, Codex, Gemini, OpenCode, Copilot CLI, Hermes, Grok Build, Antigravity, etc.) over stdio JSON-RPC — delivering the next-generation development experience. **Telegram, LINE, Feishu/Lark, Google Chat**, and other webhook-based platforms are supported via the standalone [Custom Gateway](gateway/). 🪼 **Join our community!** Come say hi on Discord — we'd love to have you: **[🪼 OpenAB — Official](https://discord.gg/DmbhfDZjQS)** 🎉 @@ -21,8 +21,8 @@ A lightweight, secure, cloud-native ACP harness that bridges **Discord, Slack**, ├──────────────┤ ▼ ▼ │ hermes-acp │ │ LINE │◄──webhook──┌──────────────────┐ │ opencode acp │ │ User │ │ Custom Gateway │ │ grok agent stdio │ -├──────────────┤ │ (standalone) │ └──────────────────┘ -│ Feishu/Lark │◄───WS──────│ │ +├──────────────┤ │ (standalone) │ │ agy-acp │ +│ Feishu/Lark │◄───WS──────│ │ └──────────────────┘ │ User │ │ │ ├──────────────┤ │ │ │ Google Chat │◄──webhook──│ │ @@ -38,7 +38,7 @@ A lightweight, secure, cloud-native ACP harness that bridges **Discord, Slack**, - **Multi-platform** — supports Discord and Slack, run one or both simultaneously - **Custom Gateway** — extend to Telegram, LINE, Feishu/Lark, Google Chat, MS Teams via standalone [gateway](gateway/) -- **Pluggable agent backend** — swap between Kiro CLI, Claude Code, Codex, Gemini, OpenCode, Copilot CLI, Hermes, Grok Build via config +- **Pluggable agent backend** — swap between Kiro CLI, Claude Code, Codex, Gemini, OpenCode, Copilot CLI, Hermes, Grok Build, Antigravity via config - **@mention trigger** — mention the bot in an allowed channel to start a conversation - **Thread-based multi-turn** — auto-creates threads; no @mention needed for follow-ups - **Multi-agent collaboration** — bot-to-bot messaging for coordinated workflows ([docs/multi-agent.md](docs/multi-agent.md)) @@ -170,6 +170,7 @@ The bot creates a thread. After that, just type in the thread — no @mention ne | Cursor | `cursor-agent acp` | Native | [docs/cursor.md](docs/cursor.md) | | Hermes Agent | `hermes-acp` | Native | [docs/hermes.md](docs/hermes.md) | | Grok Build | `grok agent stdio` | Native | [docs/grok.md](docs/grok.md) | +| Antigravity | `agy-acp` | [agy-acp](agy-acp/) | [docs/antigravity.md](docs/antigravity.md) | > 🔧 Running multiple agents? See [docs/multi-agent.md](docs/multi-agent.md) From d2aa9d302ee5975d0f170e848693f9d4919cd179 Mon Sep 17 00:00:00 2001 From: chaodufashi Date: Fri, 22 May 2026 06:32:45 -0400 Subject: [PATCH 076/100] ci: add Dockerfile.antigravity to build and smoke test matrices (#902) Co-authored-by: Pahud Hsieh --- .github/workflows/build-operator.yml | 3 +++ .github/workflows/docker-smoke-test.yml | 1 + 2 files changed, 4 insertions(+) diff --git a/.github/workflows/build-operator.yml b/.github/workflows/build-operator.yml index 7b3066aef..165cc58d3 100644 --- a/.github/workflows/build-operator.yml +++ b/.github/workflows/build-operator.yml @@ -74,6 +74,7 @@ jobs: - { suffix: "-cursor", dockerfile: "Dockerfile.cursor", artifact: "cursor" } - { suffix: "-hermes", dockerfile: "Dockerfile.hermes", artifact: "hermes" } - { suffix: "-grok", dockerfile: "Dockerfile.grok", artifact: "grok" } + - { suffix: "-antigravity", dockerfile: "Dockerfile.antigravity", artifact: "antigravity" } platform: - { os: linux/amd64, runner: ubuntu-latest } - { os: linux/arm64, runner: ubuntu-24.04-arm } @@ -139,6 +140,7 @@ jobs: - { suffix: "-cursor", artifact: "cursor" } - { suffix: "-hermes", artifact: "hermes" } - { suffix: "-grok", artifact: "grok" } + - { suffix: "-antigravity", artifact: "antigravity" } runs-on: ubuntu-latest permissions: contents: read @@ -192,6 +194,7 @@ jobs: - { suffix: "-cursor" } - { suffix: "-hermes" } - { suffix: "-grok" } + - { suffix: "-antigravity" } runs-on: ubuntu-latest permissions: contents: read diff --git a/.github/workflows/docker-smoke-test.yml b/.github/workflows/docker-smoke-test.yml index 28e5faa90..62cc58c7e 100644 --- a/.github/workflows/docker-smoke-test.yml +++ b/.github/workflows/docker-smoke-test.yml @@ -22,6 +22,7 @@ jobs: - { dockerfile: Dockerfile.cursor, suffix: "-cursor", agent: "cursor-agent", agent_args: "acp" } - { dockerfile: Dockerfile.hermes, suffix: "-hermes", agent: "hermes-acp", agent_args: "" } - { dockerfile: Dockerfile.grok, suffix: "-grok", agent: "grok", agent_args: "agent stdio" } + - { dockerfile: Dockerfile.antigravity, suffix: "-antigravity", agent: "agy-acp", agent_args: "" } runs-on: ubuntu-latest steps: - uses: actions/checkout@v6 From 109c2d91066a025ce1fc3f38e69411ad1d0a08fb Mon Sep 17 00:00:00 2001 From: Yen <5915590+antigenius0910@users.noreply.github.com> Date: Fri, 22 May 2026 08:24:39 -0500 Subject: [PATCH 077/100] feat(openab): add existingSecret support for Slack agent credentials (#901) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(openab): add existingSecret support for Slack agent credentials Add `agents..slack.existingSecret` to the openab chart. When set, the chart references the named Kubernetes Secret for SLACK_BOT_TOKEN and SLACK_APP_TOKEN instead of creating a chart-managed Secret from values. Adapts the existingSecret pattern from the openab-telegram chart (#873) to the multi-agent structure of openab, scoped per-agent. Enables ESO/Vault/SealedSecrets workflows where Slack tokens rotate without requiring a Helm re-apply. Behavior: - existingSecret unset: chart creates Secret with slack tokens (unchanged) - existingSecret set, slack-only agent: no chart-managed Secret created - existingSecret set + discord/stt/gateway: chart Secret omits slack keys; deployment references existingSecret for slack envs only (dual-secret) Closes #900 Co-Authored-By: Claude Opus 4.7 (1M context) * fix(helm): address review nits — trim existingSecret, add mixed-adapter and multi-agent tests - Pipe existingSecret through | trim in openab.slackSecretName helper to handle whitespace-only values gracefully - Add mixed-adapter deployment test verifying Discord refs chart-managed Secret while Slack refs existingSecret in the same Deployment - Add multi-agent scoping test confirming agent A's existingSecret does not affect agent B's inline token resolution --------- Co-authored-by: Claude Opus 4.7 (1M context) Co-authored-by: 超渡法師 --- charts/openab/README.md | 1 + charts/openab/templates/_helpers.tpl | 11 + charts/openab/templates/deployment.yaml | 8 +- charts/openab/templates/secret.yaml | 6 +- .../tests/slack-existing-secret_test.yaml | 196 ++++++++++++++++++ charts/openab/values.yaml | 8 + 6 files changed, 223 insertions(+), 7 deletions(-) create mode 100644 charts/openab/tests/slack-existing-secret_test.yaml diff --git a/charts/openab/README.md b/charts/openab/README.md index 1ef465392..183c79255 100644 --- a/charts/openab/README.md +++ b/charts/openab/README.md @@ -28,6 +28,7 @@ Each agent lives under `agents.`. | `slack.enabled` | Enable the Slack adapter for the agent. | `false` | | `slack.botToken` | Slack Bot User OAuth token. | `""` | | `slack.appToken` | Slack App-Level token for Socket Mode. | `""` | +| `slack.existingSecret` | Name of a pre-existing K8s Secret containing `slack-bot-token` and `slack-app-token`. When set, `botToken`/`appToken` above are ignored and the chart skips creating those keys. Enables External Secrets Operator / Vault / SealedSecrets workflows. | `""` | | `slack.allowedChannels` | Slack channel allowlist. Empty means allow all channels by default. | `[]` | | `slack.allowedUsers` | Slack user allowlist. Empty means allow all users by default. | `[]` | | `nameOverride` | Override this agent's generated resource name. | `""` | diff --git a/charts/openab/templates/_helpers.tpl b/charts/openab/templates/_helpers.tpl index 6aa197764..dea2e17bf 100644 --- a/charts/openab/templates/_helpers.tpl +++ b/charts/openab/templates/_helpers.tpl @@ -45,6 +45,17 @@ app.kubernetes.io/component: {{ .agent }} {{- end }} {{- end }} +{{/* Secret name to use for Slack credentials. + If existingSecret is set, reference it; otherwise fall back to the chart-managed agent secret. + Call with: dict "ctx" $ "agent" $name "cfg" $cfg */}} +{{- define "openab.slackSecretName" -}} +{{- if and .cfg.slack (.cfg.slack.existingSecret | default "" | trim) -}} +{{- .cfg.slack.existingSecret | trim -}} +{{- else -}} +{{- include "openab.agentFullname" . -}} +{{- end -}} +{{- end }} + {{/* Resolve image: agent-level string override → global default (repository:tag, tag defaults to appVersion). Caveat: "contains :" treats registry ports (e.g. my-registry:5000/img) as tagged. Not an issue for ghcr.io / Docker Hub; revisit if custom registries with ports are needed. */}} diff --git a/charts/openab/templates/deployment.yaml b/charts/openab/templates/deployment.yaml index a47a3e8be..7659c1f71 100644 --- a/charts/openab/templates/deployment.yaml +++ b/charts/openab/templates/deployment.yaml @@ -49,18 +49,18 @@ spec: name: {{ include "openab.agentFullname" $d }} key: discord-bot-token {{- end }} - {{- if and ($cfg.slack).enabled ($cfg.slack).botToken }} + {{- if and ($cfg.slack).enabled (or ($cfg.slack).botToken ($cfg.slack).existingSecret) }} - name: SLACK_BOT_TOKEN valueFrom: secretKeyRef: - name: {{ include "openab.agentFullname" $d }} + name: {{ include "openab.slackSecretName" $d }} key: slack-bot-token {{- end }} - {{- if and ($cfg.slack).enabled ($cfg.slack).appToken }} + {{- if and ($cfg.slack).enabled (or ($cfg.slack).appToken ($cfg.slack).existingSecret) }} - name: SLACK_APP_TOKEN valueFrom: secretKeyRef: - name: {{ include "openab.agentFullname" $d }} + name: {{ include "openab.slackSecretName" $d }} key: slack-app-token {{- end }} {{- if and ($cfg.stt).enabled ($cfg.stt).apiKey }} diff --git a/charts/openab/templates/secret.yaml b/charts/openab/templates/secret.yaml index 4dc5ba871..b30fac2b9 100644 --- a/charts/openab/templates/secret.yaml +++ b/charts/openab/templates/secret.yaml @@ -1,7 +1,7 @@ {{- range $name, $cfg := .Values.agents }} {{- if ne (include "openab.agentEnabled" $cfg) "false" }} {{- $hasDiscord := and (ne (toString ($cfg.discord).enabled) "false") ($cfg.discord).botToken }} -{{- $hasSlack := and ($cfg.slack).enabled (or ($cfg.slack).botToken ($cfg.slack).appToken) }} +{{- $hasSlack := and ($cfg.slack).enabled (or ($cfg.slack).botToken ($cfg.slack).appToken) (not ($cfg.slack).existingSecret) }} {{- $hasStt := and ($cfg.stt).enabled ($cfg.stt).apiKey }} {{- $hasGateway := and ($cfg.gateway).enabled ($cfg.gateway).token }} {{- if or $hasDiscord $hasSlack $hasStt $hasGateway }} @@ -20,10 +20,10 @@ data: {{- if $hasDiscord }} discord-bot-token: {{ $cfg.discord.botToken | b64enc | quote }} {{- end }} - {{- if and ($cfg.slack).enabled ($cfg.slack).botToken }} + {{- if and ($cfg.slack).enabled ($cfg.slack).botToken (not ($cfg.slack).existingSecret) }} slack-bot-token: {{ $cfg.slack.botToken | b64enc | quote }} {{- end }} - {{- if and ($cfg.slack).enabled ($cfg.slack).appToken }} + {{- if and ($cfg.slack).enabled ($cfg.slack).appToken (not ($cfg.slack).existingSecret) }} slack-app-token: {{ $cfg.slack.appToken | b64enc | quote }} {{- end }} {{- if $hasStt }} diff --git a/charts/openab/tests/slack-existing-secret_test.yaml b/charts/openab/tests/slack-existing-secret_test.yaml new file mode 100644 index 000000000..b48a02095 --- /dev/null +++ b/charts/openab/tests/slack-existing-secret_test.yaml @@ -0,0 +1,196 @@ +suite: Slack existingSecret support +templates: + - templates/secret.yaml + - templates/deployment.yaml + +tests: + # ── Secret rendering ────────────────────────────────────────────────────── + + - it: omits slack-bot-token and slack-app-token from chart Secret when existingSecret is set (slack-only agent renders no Secret at all) + template: templates/secret.yaml + set: + agents.kiro.discord.enabled: true + agents.kiro.discord.botToken: "discord-token" + agents.kiro.slack.enabled: true + agents.kiro.slack.botToken: "xoxb-ignored" + agents.kiro.slack.appToken: "xapp-ignored" + agents.kiro.slack.existingSecret: "my-slack-creds" + asserts: + - notExists: + path: data["slack-bot-token"] + - notExists: + path: data["slack-app-token"] + + - it: skips chart Secret entirely for slack-only agent with existingSecret + template: templates/secret.yaml + set: + agents.kiro.slack.enabled: true + agents.kiro.slack.existingSecret: "my-slack-creds" + asserts: + - hasDocuments: + count: 0 + + - it: still creates chart Secret for non-slack tokens when existingSecret is set + template: templates/secret.yaml + set: + agents.kiro.discord.enabled: true + agents.kiro.discord.botToken: "discord-token" + agents.kiro.slack.enabled: true + agents.kiro.slack.existingSecret: "my-slack-creds" + asserts: + - matchRegex: + path: data["discord-bot-token"] + pattern: '.+' + - notExists: + path: data["slack-bot-token"] + - notExists: + path: data["slack-app-token"] + + - it: renders chart-managed Slack secret keys when existingSecret is unset + template: templates/secret.yaml + set: + agents.kiro.slack.enabled: true + agents.kiro.slack.botToken: "xoxb-test" + agents.kiro.slack.appToken: "xapp-test" + asserts: + - matchRegex: + path: data["slack-bot-token"] + pattern: '.+' + - matchRegex: + path: data["slack-app-token"] + pattern: '.+' + + # ── Deployment env-var rendering ────────────────────────────────────────── + + - it: references existingSecret name in SLACK_BOT_TOKEN secretKeyRef + template: templates/deployment.yaml + set: + agents.kiro.slack.enabled: true + agents.kiro.slack.existingSecret: "my-slack-creds" + asserts: + - contains: + path: spec.template.spec.containers[0].env + content: + name: SLACK_BOT_TOKEN + valueFrom: + secretKeyRef: + name: my-slack-creds + key: slack-bot-token + + - it: references existingSecret name in SLACK_APP_TOKEN secretKeyRef + template: templates/deployment.yaml + set: + agents.kiro.slack.enabled: true + agents.kiro.slack.existingSecret: "my-slack-creds" + asserts: + - contains: + path: spec.template.spec.containers[0].env + content: + name: SLACK_APP_TOKEN + valueFrom: + secretKeyRef: + name: my-slack-creds + key: slack-app-token + + - it: falls back to chart-managed secret name when existingSecret is unset + template: templates/deployment.yaml + set: + agents.kiro.slack.enabled: true + agents.kiro.slack.botToken: "xoxb-test" + agents.kiro.slack.appToken: "xapp-test" + asserts: + - contains: + path: spec.template.spec.containers[0].env + content: + name: SLACK_BOT_TOKEN + valueFrom: + secretKeyRef: + name: RELEASE-NAME-openab-kiro + key: slack-bot-token + - contains: + path: spec.template.spec.containers[0].env + content: + name: SLACK_APP_TOKEN + valueFrom: + secretKeyRef: + name: RELEASE-NAME-openab-kiro + key: slack-app-token + + - it: omits SLACK env vars when slack is disabled even if existingSecret is set + template: templates/deployment.yaml + set: + agents.kiro.slack.enabled: false + agents.kiro.slack.existingSecret: "my-slack-creds" + asserts: + - notContains: + path: spec.template.spec.containers[0].env + content: + name: SLACK_BOT_TOKEN + valueFrom: + secretKeyRef: + name: my-slack-creds + key: slack-bot-token + + # ── Mixed-adapter deployment ────────────────────────────────────────────── + + - it: renders Discord from chart-managed Secret and Slack from existingSecret in same Deployment + template: templates/deployment.yaml + set: + agents.kiro.discord.enabled: true + agents.kiro.discord.botToken: "disc-token" + agents.kiro.slack.enabled: true + agents.kiro.slack.existingSecret: "my-slack-creds" + asserts: + - contains: + path: spec.template.spec.containers[0].env + content: + name: DISCORD_BOT_TOKEN + valueFrom: + secretKeyRef: + name: RELEASE-NAME-openab-kiro + key: discord-bot-token + - contains: + path: spec.template.spec.containers[0].env + content: + name: SLACK_BOT_TOKEN + valueFrom: + secretKeyRef: + name: my-slack-creds + key: slack-bot-token + - contains: + path: spec.template.spec.containers[0].env + content: + name: SLACK_APP_TOKEN + valueFrom: + secretKeyRef: + name: my-slack-creds + key: slack-app-token + + # ── Multi-agent scoping ─────────────────────────────────────────────────── + + - it: agent with existingSecret does not affect another agent using inline tokens + template: templates/deployment.yaml + documentIndex: 1 + set: + agents.alpha.slack.enabled: true + agents.alpha.slack.existingSecret: "alpha-ext-creds" + agents.beta.slack.enabled: true + agents.beta.slack.botToken: "xoxb-beta" + agents.beta.slack.appToken: "xapp-beta" + asserts: + - contains: + path: spec.template.spec.containers[0].env + content: + name: SLACK_BOT_TOKEN + valueFrom: + secretKeyRef: + name: RELEASE-NAME-openab-beta + key: slack-bot-token + - contains: + path: spec.template.spec.containers[0].env + content: + name: SLACK_APP_TOKEN + valueFrom: + secretKeyRef: + name: RELEASE-NAME-openab-beta + key: slack-app-token diff --git a/charts/openab/values.yaml b/charts/openab/values.yaml index 8e4f2fa9e..6ea0f0921 100644 --- a/charts/openab/values.yaml +++ b/charts/openab/values.yaml @@ -238,6 +238,14 @@ agents: enabled: false botToken: "" # Bot User OAuth Token (xoxb-...) appToken: "" # App-Level Token (xapp-...) for Socket Mode + # Use a pre-existing K8s Secret for slack-bot-token and slack-app-token + # instead of having the chart create one from botToken/appToken. + # Set to the Secret name; the Secret must contain keys: + # slack-bot-token → Bot User OAuth Token (xoxb-...) + # slack-app-token → App-Level Token (xapp-...) + # When set, botToken and appToken above are ignored. Recommended path for + # External Secrets Operator / Vault / SealedSecrets deployments. + existingSecret: "" # allowAllChannels/allowAllUsers: same auto-infer logic as discord allowedChannels: [] # empty + no allowAllChannels → allow all (auto-inferred) allowedUsers: [] # empty + no allowAllUsers → allow all (auto-inferred) From 97f429b94c6f3879b21a813ea0719bb825c026c5 Mon Sep 17 00:00:00 2001 From: chaodufashi Date: Fri, 22 May 2026 19:07:43 -0400 Subject: [PATCH 078/100] fix(agy-acp): use --conversation ID + delta extraction for multi-turn (#906) Replace --continue with --conversation to fix two bugs: 1. Full conversation history repeated on every turn (#905) 2. Concurrent sessions unsafe (--continue targets most recent globally) Now tracks per-session: agy conversation ID (from conversations dir) and cumulative output length. Only emits the delta on each turn. Fixes #905 Co-authored-by: Pahud Hsieh --- agy-acp/src/main.rs | 74 +++++++++++++++++++++++++++++++++++++++------ docs/antigravity.md | 36 +++++++++++----------- 2 files changed, 82 insertions(+), 28 deletions(-) diff --git a/agy-acp/src/main.rs b/agy-acp/src/main.rs index 8cb2d92b7..51ede1e43 100644 --- a/agy-acp/src/main.rs +++ b/agy-acp/src/main.rs @@ -2,6 +2,7 @@ use serde::{Deserialize, Serialize}; use serde_json::{json, Value}; use std::collections::HashMap; use std::io::{self, BufRead, Write}; +use std::path::PathBuf; use tokio::process::Command; use tokio::sync::mpsc; use uuid::Uuid; @@ -31,23 +32,41 @@ struct JsonRpcNotification { } struct Session { - has_history: bool, + /// agy conversation ID (from conversations directory) + conversation_id: Option, + /// cumulative stdout length from previous turns + prev_output_len: usize, } struct Adapter { sessions: HashMap, working_dir: String, + conversations_dir: PathBuf, } impl Adapter { fn new() -> Self { + let home = std::env::var("HOME").unwrap_or_else(|_| "/tmp".to_string()); Self { sessions: HashMap::new(), - working_dir: std::env::var("AGY_WORKING_DIR") + working_dir: std::env::current_dir() + .map(|p| p.to_string_lossy().to_string()) .unwrap_or_else(|_| "/tmp".to_string()), + conversations_dir: PathBuf::from(&home) + .join(".gemini/antigravity-cli/conversations"), } } + /// Find the most recently modified conversation ID from agy's data dir. + fn latest_conversation_id(&self) -> Option { + let entries = std::fs::read_dir(&self.conversations_dir).ok()?; + entries + .filter_map(|e| e.ok()) + .filter(|e| e.path().extension().map(|x| x == "pb").unwrap_or(false)) + .max_by_key(|e| e.metadata().ok().and_then(|m| m.modified().ok())) + .and_then(|e| e.path().file_stem().map(|s| s.to_string_lossy().to_string())) + } + fn handle_initialize(&self, id: u64) -> JsonRpcResponse { JsonRpcResponse { jsonrpc: "2.0", @@ -63,7 +82,10 @@ impl Adapter { fn handle_session_new(&mut self, id: u64) -> JsonRpcResponse { let session_id = Uuid::new_v4().to_string(); - self.sessions.insert(session_id.clone(), Session { has_history: false }); + self.sessions.insert(session_id.clone(), Session { + conversation_id: None, + prev_output_len: 0, + }); JsonRpcResponse { jsonrpc: "2.0", id, @@ -80,17 +102,25 @@ impl Adapter { .map(|arr| { arr.iter() .filter_map(|b| b.get("text").and_then(|t| t.as_str())) - .filter(|t| !t.starts_with("")) .collect::>() .join("\n") }) .unwrap_or_default(); let clean_prompt = prompt_text.trim(); + // Build args: use --conversation for subsequent turns let mut args: Vec = Vec::new(); + // Always add working dir as workspace so agy reads AGENTS.md/GEMINI.md + args.push("--add-dir".to_string()); + args.push(self.working_dir.clone()); + // Add extra args from AGY_EXTRA_ARGS env var if set + if let Ok(extra) = std::env::var("AGY_EXTRA_ARGS") { + args.extend(extra.split_whitespace().map(String::from)); + } if let Some(session) = self.sessions.get(session_id) { - if session.has_history { - args.push("--continue".to_string()); + if let Some(conv_id) = &session.conversation_id { + args.push("--conversation".to_string()); + args.push(conv_id.clone()); } } args.push("-p".to_string()); @@ -109,10 +139,35 @@ impl Adapter { match result { Ok(output) => { - let text = String::from_utf8_lossy(&output.stdout).to_string(); + let full_text = String::from_utf8_lossy(&output.stdout).to_string(); + + // Extract only the new content (delta) + let prev_len = self.sessions.get(session_id) + .map(|s| s.prev_output_len) + .unwrap_or(0); + let new_text = if prev_len < full_text.len() { + full_text[prev_len..].trim_start().to_string() + } else { + full_text.clone() + }; + + // Update session state + let conv_id = if self.sessions.get(session_id) + .map(|s| s.conversation_id.is_none()) + .unwrap_or(false) + { + self.latest_conversation_id() + } else { + None + }; + if let Some(session) = self.sessions.get_mut(session_id) { - session.has_history = true; + session.prev_output_len = full_text.len(); + if session.conversation_id.is_none() { + session.conversation_id = conv_id; + } } + let notification = serde_json::to_string(&JsonRpcNotification { jsonrpc: "2.0", method: "session/update".to_string(), @@ -120,7 +175,7 @@ impl Adapter { "sessionId": session_id, "update": { "sessionUpdate": "agent_message_chunk", - "content": { "type": "text", "text": text }, + "content": { "type": "text", "text": new_text }, }, }), }).unwrap(); @@ -141,7 +196,6 @@ impl Adapter { async fn main() { let mut adapter = Adapter::new(); - // Read stdin lines in a blocking thread, send to async handler let (tx, mut rx) = mpsc::unbounded_channel::(); std::thread::spawn(move || { let stdin = io::stdin(); diff --git a/docs/antigravity.md b/docs/antigravity.md index 14d3d8338..6022edebf 100644 --- a/docs/antigravity.md +++ b/docs/antigravity.md @@ -5,13 +5,14 @@ OpenAB supports [Google Antigravity CLI](https://antigravity.google/) via the `a ## How It Works ``` -openab ──ACP JSON-RPC──► agy-acp ──spawns──► agy --dangerously-skip-permissions -p "prompt" - agy --continue -p "follow-up" +openab ──ACP JSON-RPC──► agy-acp ──spawns──► agy --add-dir /home/agent -p "prompt" + agy --add-dir /home/agent --conversation -p "follow-up" ``` -- First prompt in a session: `agy -p "text"` -- Subsequent prompts: `agy --continue -p "text"` (resumes most recent conversation) -- Tool permissions are auto-approved via `--dangerously-skip-permissions` +- First prompt in a session: `agy -p "text"`, then discovers the conversation ID +- Subsequent prompts: `agy --conversation -p "text"` (resumes specific conversation) +- Only the **delta** (new response) is sent back — previous turns are not repeated +- Full `` metadata is passed through to agy ## Configuration @@ -22,20 +23,22 @@ args = [] working_dir = "/home/agent" ``` -Or with the Docker image: - -```toml -[agent] -command = "/usr/local/bin/agy-acp" -args = [] -working_dir = "/home/agent" -``` - ### Environment Variables | Variable | Description | Default | |----------|-------------|---------| | `AGY_WORKING_DIR` | Working directory for agy invocations | `/tmp` | +| `AGY_EXTRA_ARGS` | Extra arguments prepended to every `agy` invocation (optional) | (none) | + +## Steering Files + +agy reads `AGENTS.md` and `GEMINI.md` when it considers a directory a workspace: + +1. `AGENTS.md` and `GEMINI.md` are loaded first and injected into the system prompt +2. agy does not disclose how it determines HOME as a workspace, but `--add-dir` explicitly adds a directory +3. agy-acp **automatically** passes `--add-dir ` on every invocation — no configuration needed + +Place your steering instructions in `/home/agent/AGENTS.md` or `/home/agent/GEMINI.md` — they will be read on every prompt as long as `working_dir` points to that directory. ## Docker @@ -48,7 +51,7 @@ docker build -f Dockerfile.antigravity -t openab-antigravity . Antigravity CLI uses Google Sign-In (OAuth). Authenticate inside the container: ```bash -kubectl exec -it deployment/openab-antigravity -- agy auth +kubectl exec -it deployment/openab-antigravity -- /lib64/ld-linux-x86-64.so.2 /usr/local/bin/agy auth ``` Complete the device flow in your browser. Auth tokens persist in the PVC at `~/.gemini/`. @@ -65,8 +68,6 @@ agents: command: "agy-acp" args: [] workingDir: "/home/agent" - env: - AGY_WORKING_DIR: "/home/agent" image: repository: ghcr.io/openabdev/openab-antigravity tag: "latest" @@ -76,4 +77,3 @@ agents: - **No streaming**: `agy -p` returns the full response at once; the adapter sends it as a single `agent_message_chunk` notification. - **Cancel is a no-op**: `agy -p` runs to completion; `session/cancel` acknowledges but cannot interrupt. -- **Session continuity uses `--continue`**: This resumes the *most recent* agy conversation, which works for single-user-per-pod deployments but may conflict if multiple sessions run concurrently in the same container. From b89fd401d9ed0186306a598db01dc799ee229ea3 Mon Sep 17 00:00:00 2001 From: "openab-app[bot]" <274185012+openab-app[bot]@users.noreply.github.com> Date: Fri, 22 May 2026 19:11:43 -0400 Subject: [PATCH 079/100] release: v0.8.4-beta.3 (#907) Co-authored-by: openab-app[bot] <274185012+openab-app[bot]@users.noreply.github.com> --- charts/openab/Chart.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/charts/openab/Chart.yaml b/charts/openab/Chart.yaml index 1d0286789..d7db053ba 100644 --- a/charts/openab/Chart.yaml +++ b/charts/openab/Chart.yaml @@ -2,5 +2,5 @@ apiVersion: v2 name: openab description: A lightweight, secure, cloud-native ACP harness that bridges Discord and any ACP-compatible coding CLI. type: application -version: 0.8.4-beta.2 -appVersion: "0.8.4-beta.2" +version: 0.8.4-beta.3 +appVersion: "0.8.4-beta.3" From a8186c8690e2e96bd914e1e1d566632701087394 Mon Sep 17 00:00:00 2001 From: chaodufashi Date: Sat, 23 May 2026 08:53:20 -0400 Subject: [PATCH 080/100] =?UTF-8?q?feat:=20pivot=20xai-proxy=20=E2=86=92?= =?UTF-8?q?=20openab-auth-proxy=20(generic=20OAuth=20sidecar)=20(#891)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: rename xai-proxy → openab-auth-proxy, make provider-generic BREAKING: xai-proxy binary renamed to openab-auth-proxy. - Extract xAI-specific OAuth values into a TOML config file - Default to xAI preset when no config is provided (backward compat) - Support any OIDC provider via auth-proxy.toml - Add AUTH_PROXY_TOKEN_PATH env var (XAI_PROXY_TOKEN_PATH still works) - Token storage moved to ~/.openab-auth-proxy//tokens.json - Update CI workflow, Dockerfile, README, and docs - Add docs/refarch/sidecar-proxy.md for the generic pattern * fix(auth-proxy): remove needless borrow to satisfy clippy --------- Co-authored-by: chaodu-agent --- .../{ci-xai-proxy.yml => ci-auth-proxy.yml} | 10 +- docs/refarch/sidecar-proxy.md | 84 + docs/xai-proxy.md | 151 +- {xai-proxy => openab-auth-proxy}/.gitignore | 0 {xai-proxy => openab-auth-proxy}/Cargo.toml | 7 +- {xai-proxy => openab-auth-proxy}/Dockerfile | 4 +- openab-auth-proxy/README.md | 81 + {xai-proxy => openab-auth-proxy}/src/main.rs | 334 +-- xai-proxy/Cargo.lock | 2381 ----------------- xai-proxy/README.md | 143 - 10 files changed, 384 insertions(+), 2811 deletions(-) rename .github/workflows/{ci-xai-proxy.yml => ci-auth-proxy.yml} (77%) create mode 100644 docs/refarch/sidecar-proxy.md rename {xai-proxy => openab-auth-proxy}/.gitignore (100%) rename {xai-proxy => openab-auth-proxy}/Cargo.toml (83%) rename {xai-proxy => openab-auth-proxy}/Dockerfile (71%) create mode 100644 openab-auth-proxy/README.md rename {xai-proxy => openab-auth-proxy}/src/main.rs (59%) delete mode 100644 xai-proxy/Cargo.lock delete mode 100644 xai-proxy/README.md diff --git a/.github/workflows/ci-xai-proxy.yml b/.github/workflows/ci-auth-proxy.yml similarity index 77% rename from .github/workflows/ci-xai-proxy.yml rename to .github/workflows/ci-auth-proxy.yml index 45ac2ed74..740b647c5 100644 --- a/.github/workflows/ci-xai-proxy.yml +++ b/.github/workflows/ci-auth-proxy.yml @@ -1,13 +1,13 @@ -name: CI (xai-proxy) +name: CI (openab-auth-proxy) on: pull_request: paths: - - "xai-proxy/**" + - "openab-auth-proxy/**" push: branches: [main] paths: - - "xai-proxy/**" + - "openab-auth-proxy/**" env: CARGO_TERM_COLOR: always @@ -17,7 +17,7 @@ jobs: runs-on: ubuntu-latest defaults: run: - working-directory: xai-proxy + working-directory: openab-auth-proxy steps: - uses: actions/checkout@v6 @@ -27,7 +27,7 @@ jobs: - uses: Swatinem/rust-cache@v2 with: - workspaces: xai-proxy + workspaces: openab-auth-proxy - name: cargo check run: cargo check diff --git a/docs/refarch/sidecar-proxy.md b/docs/refarch/sidecar-proxy.md new file mode 100644 index 000000000..ca85e2771 --- /dev/null +++ b/docs/refarch/sidecar-proxy.md @@ -0,0 +1,84 @@ +# Reference Architecture: OAuth Sidecar Proxy + +> **Note:** For xAI/Grok models, OpenCode ≥1.15.0 supports native xAI OAuth. +> The sidecar proxy is no longer required for OpenCode deployments. +> See [docs/xai-proxy.md](../xai-proxy.md) for the recommended approach. + +This document describes the **sidecar proxy pattern** implemented by +`openab-auth-proxy` — a generic OAuth proxy that injects Bearer tokens into +upstream API requests. + +## When to use this pattern + +- Agents **without** built-in OAuth (Hermes, custom agents) +- Centralizing token management across multiple containers in a pod +- Proxying to any OAuth-protected API (not just xAI) + +## Architecture + +``` +┌─ Kubernetes Pod ──────────────────────────────────────────────┐ +│ │ +│ agent container (any OpenAI-compatible client) │ +│ │ POST /v1/chat/completions │ +│ │ (no auth header needed) │ +│ ▼ │ +│ openab-auth-proxy :9090 │ +│ • Reads OAuth token from disk │ +│ • Injects Authorization: Bearer header │ +│ • Auto-refreshes 120s before expiry │ +│ │ │ +│ Token: ~/.openab-auth-proxy//tokens.json │ +└───────────────┼───────────────────────────────────────────────┘ + ▼ + upstream API (configured via TOML or xAI default) +``` + +## Configuration + +Without a config file, `openab-auth-proxy` defaults to xAI/SuperGrok. + +For other providers, create `auth-proxy.toml`: + +```toml +[provider] +name = "my-provider" +discovery_url = "https://auth.example.com/.well-known/openid-configuration" +client_id = "my-client-id" +scopes = "openid offline_access api:access" +upstream_base_url = "https://api.example.com" +redirect_port = 8080 +``` + +## Helm deployment (xAI example) + +```bash +# 1. Login locally +openab-auth-proxy login-device + +# 2. Create K8s secret +kubectl create secret generic auth-proxy-tokens \ + --from-file=tokens.json=$HOME/.openab-auth-proxy/xai/tokens.json + +# 3. Deploy with sidecar +helm install openab openab/openab \ + --set agents.mybot.command=opencode \ + --set-json 'agents.mybot.args=["acp"]' \ + --set agents.mybot.image=ghcr.io/openabdev/openab-opencode \ + --set-json 'agents.mybot.extraContainers=[{"name":"auth-proxy","image":"ghcr.io/openabdev/openab-auth-proxy:latest","args":["serve","--bind","0.0.0.0"],"ports":[{"containerPort":9090}],"volumeMounts":[{"name":"data","mountPath":"/home/agent"}]}]' \ + --set-json 'agents.mybot.extraInitContainers=[{"name":"copy-tokens","image":"busybox","command":["sh","-c","mkdir -p /dest/.openab-auth-proxy/xai && cp /src/tokens.json /dest/.openab-auth-proxy/xai/tokens.json"],"volumeMounts":[{"name":"tokens-src","mountPath":"/src","readOnly":true},{"name":"data","mountPath":"/dest"}]}]' \ + --set-json 'agents.mybot.extraVolumes=[{"name":"tokens-src","secret":{"secretName":"auth-proxy-tokens"}}]' +``` + +## Environment variables + +| Variable | Default | Description | +|----------|---------|-------------| +| `AUTH_PROXY_TOKEN_PATH` | `~/.openab-auth-proxy//tokens.json` | Token file location | +| `XAI_PROXY_TOKEN_PATH` | (legacy alias) | Backward-compatible | +| `RUST_LOG` | `openab_auth_proxy=info` | Log verbosity | + +## See also + +- [openab-auth-proxy source](../../openab-auth-proxy/) — Rust implementation +- [docs/xai-proxy.md](../xai-proxy.md) — xAI-specific quick-start diff --git a/docs/xai-proxy.md b/docs/xai-proxy.md index 93bbbd443..3a2302c54 100644 --- a/docs/xai-proxy.md +++ b/docs/xai-proxy.md @@ -1,140 +1,43 @@ -# xAI Proxy (SuperGrok Sidecar) +# xAI / SuperGrok Integration -xai-proxy is a lightweight Rust sidecar that lets any OpenAI-compatible agent use your **SuperGrok subscription** instead of per-token API credits. It authenticates via OAuth and proxies requests to `api.x.ai/v1`. +## Recommended: Native xAI OAuth (OpenCode ≥1.15.0) -## Architecture +OpenCode now has **built-in xAI OAuth support** — no sidecar proxy needed. -``` -┌─ Kubernetes Pod ──────────────────────────────────────────────┐ -│ │ -│ openab → opencode acp │ -│ │ POST /v1/chat/completions │ -│ ▼ │ -│ xai-proxy :9090 │ -│ • Injects OAuth Bearer token │ -│ • Auto-refreshes 120s before expiry │ -│ │ │ -│ PVC: /home/agent/.openab/xai-proxy/tokens.json │ -└───────────────┼───────────────────────────────────────────────┘ - ▼ - https://api.x.ai/v1 (SuperGrok) -``` +1. Run `/connect` inside OpenCode → select **xAI Grok OAuth (Headless / Remote / VPS)** +2. Approve the device-code on any browser +3. Select your model with `/models` (e.g. `grok-4.3`) -## Prerequisites +OpenCode handles token storage and auto-refresh internally. -- Active SuperGrok subscription (any tier) -- A machine with browser access (or SSH tunnel) for initial login +--- -## Helm Install +## Alternative: openab-auth-proxy sidecar -```bash -# 1. Login locally to get tokens -xai-proxy login-device - -# 2. Create K8s secret from token file -kubectl create secret generic xai-proxy-tokens \ - --from-file=tokens.json=$HOME/.xai-proxy/tokens.json - -# 3. Deploy with opencode + xai-proxy sidecar -helm install openab openab/openab \ - --set agents.kiro.enabled=false \ - --set agents.mybot.discord.botToken="$DISCORD_BOT_TOKEN" \ - --set agents.mybot.discord.allowAllChannels=true \ - --set agents.mybot.command=opencode \ - --set-json 'agents.mybot.args=["acp"]' \ - --set agents.mybot.image=ghcr.io/openabdev/openab-opencode \ - --set-json 'agents.mybot.extraVolumes=[{"name":"xai-tokens-src","secret":{"secretName":"xai-proxy-tokens"}},{"name":"opencode-config","configMap":{"name":"opencode-xai-config"}},{"name":"opencode-auth","configMap":{"name":"opencode-xai-auth"}}]' \ - --set-json 'agents.mybot.extraVolumeMounts=[{"name":"opencode-config","mountPath":"/home/agent/opencode.json","subPath":"opencode.json"},{"name":"opencode-config","mountPath":"/home/agent/.config/opencode/opencode.json","subPath":"opencode.json"},{"name":"opencode-auth","mountPath":"/home/agent/.local/share/opencode/auth.json","subPath":"auth.json"}]' \ - --set-json 'agents.mybot.extraInitContainers=[{"name":"copy-tokens","image":"busybox","command":["sh","-c","if [ ! -f /dest/.openab/xai-proxy/tokens.json ]; then mkdir -p /dest/.openab/xai-proxy && cp /src/tokens.json /dest/.openab/xai-proxy/tokens.json; fi"],"volumeMounts":[{"name":"xai-tokens-src","mountPath":"/src","readOnly":true},{"name":"data","mountPath":"/dest"}]}]' \ - --set-json 'agents.mybot.extraContainers=[{"name":"xai-proxy","image":"xai-proxy:latest","args":["serve","--bind","0.0.0.0"],"env":[{"name":"XAI_PROXY_TOKEN_PATH","value":"/home/agent/.openab/xai-proxy/tokens.json"}],"ports":[{"containerPort":9090}],"volumeMounts":[{"name":"data","mountPath":"/home/agent"}]}]' -``` - -## OpenCode Configuration - -Create a ConfigMap for the opencode provider config: +For agents **without** native xAI OAuth (Hermes, custom agents), use +`openab-auth-proxy` — a generic OAuth sidecar that defaults to xAI. ```bash -kubectl create configmap opencode-xai-config --from-file=opencode.json=- <<'EOF' -{ - "$schema": "https://opencode.ai/config.json", - "model": "xai/grok-4.3", - "provider": { - "xai": { - "npm": "@ai-sdk/openai-compatible", - "name": "xAI (SuperGrok)", - "options": { - "baseURL": "http://localhost:9090/v1", - "apiKey": "dummy" - }, - "models": { - "grok-4.3": { "name": "Grok 4.3" } - } - } - } -} -EOF +# Login (one-time) +openab-auth-proxy login-device -kubectl create configmap opencode-xai-auth --from-file=auth.json=- <<'EOF' -{ "xai": "dummy" } -EOF -``` +# Start proxy +openab-auth-proxy serve --port 9090 -## Authentication - -### Device-code flow (recommended for headless) - -```bash -xai-proxy login-device -``` - -Prints a URL and code. Open the URL in any browser, enter the code, and authorize. - -### Browser OAuth (local machine) - -```bash -xai-proxy login -``` - -Opens your browser to `auth.x.ai`. Sign in and authorize. Callback is received on `127.0.0.1:56121`. - -### Token refresh - -xai-proxy auto-refreshes the OAuth token 120 seconds before expiry. The refreshed token is written back to the token file (persisted on PVC across pod restarts). - -## Environment Variables - -| Variable | Default | Description | -|----------|---------|-------------| -| `XAI_PROXY_TOKEN_PATH` | `~/.xai-proxy/tokens.json` | Custom token file path | -| `RUST_LOG` | `xai_proxy=info` | Log level | - -## Token Persistence - -The init container seeds the token from the K8s secret on first boot only. After that, xai-proxy reads and writes the token directly on the PVC. This means: - -- Token refreshes survive pod restarts -- The K8s secret is only needed for initial bootstrap -- To force a token reset, delete `/home/agent/.openab/xai-proxy/tokens.json` from the PVC - -## Standalone Usage (no K8s) - -```bash -# Build -cargo build --release - -# Login -./target/release/xai-proxy login-device - -# Serve -./target/release/xai-proxy serve --port 9090 - -# Use with any OpenAI-compatible client +# Point any OpenAI-compatible client at the proxy export OPENAI_BASE_URL=http://127.0.0.1:9090/v1 export OPENAI_API_KEY=dummy -opencode ``` -## Limitations +See [docs/refarch/sidecar-proxy.md](refarch/sidecar-proxy.md) for the full +architecture, Helm deployment, and custom provider configuration. + +## Comparison -- **codex-acp** and **claude-agent-acp** require their own proprietary auth and won't use `OPENAI_BASE_URL` — use opencode or hermes-acp instead -- Browser OAuth (`xai-proxy login`) requires Cloudflare to not block your IP — use `login-device` if blocked +| | Native OAuth | openab-auth-proxy sidecar | +|---|---|---| +| **Requires** | OpenCode ≥1.15.0 | Any OpenAI-compatible agent | +| **Extra container** | No | Yes | +| **Token management** | Built into OpenCode | Proxy handles refresh | +| **Multi-agent sharing** | Each agent needs own auth | Single proxy serves all | +| **Custom providers** | xAI only | Any OIDC provider via TOML config | diff --git a/xai-proxy/.gitignore b/openab-auth-proxy/.gitignore similarity index 100% rename from xai-proxy/.gitignore rename to openab-auth-proxy/.gitignore diff --git a/xai-proxy/Cargo.toml b/openab-auth-proxy/Cargo.toml similarity index 83% rename from xai-proxy/Cargo.toml rename to openab-auth-proxy/Cargo.toml index a392d436d..637349df3 100644 --- a/xai-proxy/Cargo.toml +++ b/openab-auth-proxy/Cargo.toml @@ -1,8 +1,8 @@ [package] -name = "xai-proxy" -version = "0.1.0" +name = "openab-auth-proxy" +version = "0.2.0" edition = "2021" -description = "Lightweight xAI OAuth proxy sidecar — PKCE login via accounts.x.ai, forwards OpenAI-compatible requests to api.x.ai/v1" +description = "Generic OAuth proxy sidecar — authenticates via OIDC/PKCE and forwards requests to any upstream API with Bearer token injection" [dependencies] tokio = { version = "1.44", features = ["full"] } @@ -15,6 +15,7 @@ http-body-util = "0.1" reqwest = { version = "0.12", features = ["json", "rustls-tls"], default-features = false } serde = { version = "1", features = ["derive"] } serde_json = "1" +toml = "0.8" clap = { version = "4", features = ["derive"] } sha2 = "0.10" base64 = "0.22" diff --git a/xai-proxy/Dockerfile b/openab-auth-proxy/Dockerfile similarity index 71% rename from xai-proxy/Dockerfile rename to openab-auth-proxy/Dockerfile index 21dc548cf..ee4c8baea 100644 --- a/xai-proxy/Dockerfile +++ b/openab-auth-proxy/Dockerfile @@ -6,6 +6,6 @@ RUN cargo build --release FROM debian:bookworm-slim RUN apt-get update && apt-get install -y ca-certificates && rm -rf /var/lib/apt/lists/* -COPY --from=builder /app/target/release/xai-proxy /usr/local/bin/ -ENTRYPOINT ["xai-proxy"] +COPY --from=builder /app/target/release/openab-auth-proxy /usr/local/bin/ +ENTRYPOINT ["openab-auth-proxy"] CMD ["serve", "--bind", "0.0.0.0"] diff --git a/openab-auth-proxy/README.md b/openab-auth-proxy/README.md new file mode 100644 index 000000000..d07ec94bf --- /dev/null +++ b/openab-auth-proxy/README.md @@ -0,0 +1,81 @@ +# openab-auth-proxy + +Generic OAuth proxy sidecar for LLM APIs. Authenticates via OIDC (PKCE or device-code flow) and injects Bearer tokens into proxied requests. + +Ships with a built-in **xAI/SuperGrok** preset. Configure any OAuth-protected API via a TOML config file. + +## Quick start (xAI default) + +```bash +# Login (device-code for headless, or browser PKCE) +openab-auth-proxy login-device +openab-auth-proxy login + +# Start proxy +openab-auth-proxy serve --port 9090 + +# Use with any OpenAI-compatible client +export OPENAI_BASE_URL=http://127.0.0.1:9090/v1 +export OPENAI_API_KEY=dummy +opencode +``` + +## Custom provider + +Create `auth-proxy.toml`: + +```toml +[provider] +name = "my-provider" +discovery_url = "https://auth.example.com/.well-known/openid-configuration" +client_id = "my-client-id" +scopes = "openid offline_access api:access" +upstream_base_url = "https://api.example.com" +redirect_port = 8080 +``` + +```bash +openab-auth-proxy -c auth-proxy.toml login-device +openab-auth-proxy -c auth-proxy.toml serve +``` + +## Architecture + +``` +┌─ Pod / Host ──────────────────────────────────────────────────┐ +│ │ +│ agent (any OpenAI-compatible client) │ +│ │ POST /v1/chat/completions │ +│ ▼ │ +│ openab-auth-proxy :9090 │ +│ • Reads OAuth token from disk │ +│ • Injects Authorization: Bearer header │ +│ • Auto-refreshes 120s before expiry │ +│ │ │ +│ Token: ~/.openab-auth-proxy//tokens.json │ +└───────────────┼───────────────────────────────────────────────┘ + ▼ + upstream API (configured via TOML) +``` + +## Environment variables + +| Variable | Default | Description | +|----------|---------|-------------| +| `AUTH_PROXY_TOKEN_PATH` | `~/.openab-auth-proxy//tokens.json` | Custom token file path | +| `XAI_PROXY_TOKEN_PATH` | (legacy) | Backward-compatible alias | +| `RUST_LOG` | `openab_auth_proxy=info` | Log level | + +## Docker + +```bash +docker build -t openab-auth-proxy . +docker run --rm -v ~/.openab-auth-proxy:/root/.openab-auth-proxy openab-auth-proxy serve --bind 0.0.0.0 +``` + +## Presets + +| Provider | Config needed? | Notes | +|----------|---------------|-------| +| xAI SuperGrok | No (built-in default) | Uses Grok CLI public OAuth client | +| Custom | Yes (`auth-proxy.toml`) | Any OIDC provider with device-code or PKCE | diff --git a/xai-proxy/src/main.rs b/openab-auth-proxy/src/main.rs similarity index 59% rename from xai-proxy/src/main.rs rename to openab-auth-proxy/src/main.rs index c39649f78..256f56c6f 100644 --- a/xai-proxy/src/main.rs +++ b/openab-auth-proxy/src/main.rs @@ -19,36 +19,104 @@ use tokio::{ }; use tracing::{error, info}; -// === Constants (borrowed from Hermes) === - -const XAI_OAUTH_DISCOVERY_URL: &str = "https://auth.x.ai/.well-known/openid-configuration"; -const XAI_OAUTH_CLIENT_ID: &str = "b1a00492-073a-47ea-816f-4c329264a828"; -const XAI_OAUTH_SCOPE: &str = "openid profile email offline_access grok-cli:access api:access"; -const XAI_OAUTH_REDIRECT_PORT: u16 = 56121; -const XAI_API_BASE: &str = "https://api.x.ai"; const REFRESH_SKEW_SECONDS: u64 = 120; +// === Provider Config === + +#[derive(Debug, Clone, Serialize, Deserialize)] +struct ProviderConfig { + name: String, + discovery_url: String, + client_id: String, + scopes: String, + upstream_base_url: String, + #[serde(default = "default_redirect_port")] + redirect_port: u16, + #[serde(default)] + device_authorization_endpoint: Option, +} + +fn default_redirect_port() -> u16 { + 56121 +} + +impl ProviderConfig { + fn xai() -> Self { + Self { + name: "xAI".to_string(), + discovery_url: "https://auth.x.ai/.well-known/openid-configuration".to_string(), + client_id: "b1a00492-073a-47ea-816f-4c329264a828".to_string(), + scopes: "openid profile email offline_access grok-cli:access api:access".to_string(), + upstream_base_url: "https://api.x.ai".to_string(), + redirect_port: 56121, + device_authorization_endpoint: None, + } + } + + fn upstream_host(&self) -> &str { + self.upstream_base_url + .strip_prefix("https://") + .or_else(|| self.upstream_base_url.strip_prefix("http://")) + .unwrap_or(&self.upstream_base_url) + .split('/') + .next() + .unwrap_or("localhost") + } +} + +#[derive(Debug, Deserialize)] +struct ConfigFile { + provider: ProviderConfig, +} + +fn load_config(path: Option<&PathBuf>) -> Result { + if let Some(p) = path { + let content = std::fs::read_to_string(p) + .with_context(|| format!("Cannot read config file: {}", p.display()))?; + let cfg: ConfigFile = toml::from_str(&content)?; + return Ok(cfg.provider); + } + // Check default locations + let candidates = [ + PathBuf::from("auth-proxy.toml"), + dirs::config_dir() + .unwrap_or_default() + .join("openab-auth-proxy/config.toml"), + ]; + for c in &candidates { + if c.exists() { + let content = std::fs::read_to_string(c)?; + let cfg: ConfigFile = toml::from_str(&content)?; + return Ok(cfg.provider); + } + } + // Default to xAI + Ok(ProviderConfig::xai()) +} + // === CLI === #[derive(Parser)] -#[command(name = "xai-proxy", about = "xAI OAuth proxy sidecar")] +#[command(name = "openab-auth-proxy", about = "Generic OAuth proxy sidecar for LLM APIs")] struct Cli { + /// Path to config file (default: auth-proxy.toml or xAI preset) + #[arg(short, long, global = true)] + config: Option, + #[command(subcommand)] command: Commands, } #[derive(Subcommand)] enum Commands { - /// Authenticate with xAI via browser OAuth (PKCE) + /// Authenticate via browser OAuth (PKCE) Login, - /// Authenticate with xAI via device-code flow (headless/K8s/ECS) + /// Authenticate via device-code flow (headless/K8s/ECS) LoginDevice, /// Start the proxy server Serve { - /// Listen port #[arg(short, long, default_value = "9090")] port: u16, - /// Listen address #[arg(long, default_value = "127.0.0.1")] bind: String, }, @@ -61,12 +129,20 @@ struct TokenStore { access_token: String, refresh_token: String, #[serde(default)] - expires_at: u64, // unix timestamp + expires_at: u64, #[serde(default)] token_endpoint: String, } -fn token_path() -> PathBuf { +fn token_path(provider: &ProviderConfig) -> PathBuf { + if let Ok(p) = std::env::var("AUTH_PROXY_TOKEN_PATH") { + let path = PathBuf::from(p); + if let Some(dir) = path.parent() { + std::fs::create_dir_all(dir).ok(); + } + return path; + } + // Legacy env var for backward compat if let Ok(p) = std::env::var("XAI_PROXY_TOKEN_PATH") { let path = PathBuf::from(p); if let Some(dir) = path.parent() { @@ -76,23 +152,23 @@ fn token_path() -> PathBuf { } let dir = dirs::home_dir() .unwrap_or_else(|| PathBuf::from(".")) - .join(".xai-proxy"); + .join(".openab-auth-proxy") + .join(provider.name.to_lowercase().replace(' ', "-")); std::fs::create_dir_all(&dir).ok(); dir.join("tokens.json") } -fn load_tokens() -> Result { - let path = token_path(); +fn load_tokens(provider: &ProviderConfig) -> Result { + let path = token_path(provider); let data = std::fs::read_to_string(&path) - .with_context(|| format!("No token file at {}. Run `xai-proxy login` first.", path.display()))?; + .with_context(|| format!("No token file at {}. Run `openab-auth-proxy login` first.", path.display()))?; serde_json::from_str(&data).context("Invalid token file") } -fn save_tokens(store: &TokenStore) -> Result<()> { - let path = token_path(); +fn save_tokens(provider: &ProviderConfig, store: &TokenStore) -> Result<()> { + let path = token_path(provider); let data = serde_json::to_string_pretty(store)?; std::fs::write(&path, data)?; - // chmod 600 #[cfg(unix)] { use std::os::unix::fs::PermissionsExt; @@ -111,10 +187,10 @@ struct OidcDiscovery { device_authorization_endpoint: String, } -async fn discover_endpoints() -> Result { +async fn discover_endpoints(provider: &ProviderConfig) -> Result { let client = reqwest::Client::new(); let resp = client - .get(XAI_OAUTH_DISCOVERY_URL) + .get(&provider.discovery_url) .send() .await? .error_for_status()?; @@ -134,101 +210,84 @@ fn pkce_challenge(verifier: &str) -> String { URL_SAFE_NO_PAD.encode(digest) } -// === OAuth Login === +// === OAuth Login (browser PKCE) === -async fn do_login() -> Result<()> { - info!("Starting xAI OAuth PKCE login..."); - let discovery = discover_endpoints().await?; +async fn do_login(provider: &ProviderConfig) -> Result<()> { + info!("Starting {} OAuth PKCE login...", provider.name); + let discovery = discover_endpoints(provider).await?; let code_verifier = pkce_verifier(); let code_challenge = pkce_challenge(&code_verifier); let state = uuid::Uuid::new_v4().to_string(); let nonce = uuid::Uuid::new_v4().to_string(); - - let redirect_uri = format!("http://127.0.0.1:{}/callback", XAI_OAUTH_REDIRECT_PORT); + let redirect_uri = format!("http://127.0.0.1:{}/callback", provider.redirect_port); let authorize_url = format!( - "{}?response_type=code&client_id={}&redirect_uri={}&scope={}&code_challenge={}&code_challenge_method=S256&state={}&nonce={}&plan=generic&referrer=xai-proxy", + "{}?response_type=code&client_id={}&redirect_uri={}&scope={}&code_challenge={}&code_challenge_method=S256&state={}&nonce={}", discovery.authorization_endpoint, - urlencoding::encode(XAI_OAUTH_CLIENT_ID), + urlencoding::encode(&provider.client_id), urlencoding::encode(&redirect_uri), - urlencoding::encode(XAI_OAUTH_SCOPE), + urlencoding::encode(&provider.scopes), urlencoding::encode(&code_challenge), urlencoding::encode(&state), urlencoding::encode(&nonce), ); - // Start local callback server - let listener = TcpListener::bind(format!("127.0.0.1:{}", XAI_OAUTH_REDIRECT_PORT)) + let listener = TcpListener::bind(format!("127.0.0.1:{}", provider.redirect_port)) .await - .context("Failed to bind callback port 56121")?; + .with_context(|| format!("Failed to bind callback port {}", provider.redirect_port))?; - println!("\nOpen this URL to authorize:\n"); - println!(" {}\n", authorize_url); - - // Try to open browser + println!("\nOpen this URL to authorize:\n\n {}\n", authorize_url); if open::that(&authorize_url).is_ok() { println!("Browser opened. Waiting for callback..."); } else { println!("Could not open browser. Please open the URL above manually."); } - // Wait for callback let (mut stream, _) = listener.accept().await?; let mut reader = BufReader::new(&mut stream); let mut request_line = String::new(); reader.read_line(&mut request_line).await?; - // Parse GET /callback?code=...&state=... HTTP/1.1 let path = request_line .split_whitespace() .nth(1) .ok_or_else(|| anyhow!("Invalid HTTP request"))?; - let url = url::Url::parse(&format!("http://localhost{}", path))?; let params: std::collections::HashMap<_, _> = url.query_pairs().collect(); - // Drain remaining headers loop { let mut line = String::new(); reader.read_line(&mut line).await?; - if line.trim().is_empty() { - break; - } + if line.trim().is_empty() { break; } } - // Send response - let body = "

xAI authorization received.

You can close this tab."; + let body = "

Authorization received.

You can close this tab."; let response = format!( "HTTP/1.1 200 OK\r\nContent-Type: text/html\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}", - body.len(), - body + body.len(), body ); stream.write_all(response.as_bytes()).await?; - // Validate state let received_state = params.get("state").ok_or_else(|| anyhow!("No state in callback"))?; if received_state.as_ref() != state { return Err(anyhow!("State mismatch — possible CSRF")); } - let code = params .get("code") .ok_or_else(|| anyhow!("No code in callback. Error: {:?}", params.get("error")))?; - // Exchange code for tokens info!("Exchanging authorization code for tokens..."); let client = reqwest::Client::new(); let resp = client .post(&discovery.token_endpoint) - .header("Content-Type", "application/x-www-form-urlencoded") .form(&[ ("grant_type", "authorization_code"), ("code", code.as_ref()), - ("redirect_uri", &redirect_uri), - ("client_id", XAI_OAUTH_CLIENT_ID), - ("code_verifier", &code_verifier), - ("code_challenge", &code_challenge), + ("redirect_uri", redirect_uri.as_str()), + ("client_id", provider.client_id.as_str()), + ("code_verifier", code_verifier.as_str()), + ("code_challenge", code_challenge.as_str()), ("code_challenge_method", "S256"), ]) .send() @@ -241,47 +300,45 @@ async fn do_login() -> Result<()> { } let token_resp: serde_json::Value = resp.json().await?; - let access_token = token_resp["access_token"] - .as_str() - .ok_or_else(|| anyhow!("No access_token in response"))?; - let refresh_token = token_resp["refresh_token"] - .as_str() - .ok_or_else(|| anyhow!("No refresh_token in response"))?; + let access_token = token_resp["access_token"].as_str().ok_or_else(|| anyhow!("No access_token"))?; + let refresh_token = token_resp["refresh_token"].as_str().ok_or_else(|| anyhow!("No refresh_token"))?; let expires_in = token_resp["expires_in"].as_u64().unwrap_or(3600); - let now = SystemTime::now().duration_since(UNIX_EPOCH)?.as_secs(); + let store = TokenStore { access_token: access_token.to_string(), refresh_token: refresh_token.to_string(), expires_at: now + expires_in, token_endpoint: discovery.token_endpoint, }; - save_tokens(&store)?; - - println!("\n✅ Login successful! Token saved to {:?}", token_path()); - println!(" Run `xai-proxy serve` to start the proxy."); + save_tokens(provider, &store)?; + println!("\n✅ Login successful! Token saved to {:?}", token_path(provider)); Ok(()) } -// === Device-Code Login (headless) === - -async fn do_login_device() -> Result<()> { - info!("Starting xAI device-code login..."); - let discovery = discover_endpoints().await?; - - let device_endpoint = if discovery.device_authorization_endpoint.is_empty() { - "https://auth.x.ai/oauth2/device/code".to_string() - } else { - discovery.device_authorization_endpoint - }; +// === Device-Code Login === + +async fn do_login_device(provider: &ProviderConfig) -> Result<()> { + info!("Starting {} device-code login...", provider.name); + let discovery = discover_endpoints(provider).await?; + + let device_endpoint = provider + .device_authorization_endpoint + .clone() + .unwrap_or_else(|| { + if discovery.device_authorization_endpoint.is_empty() { + // Fallback: derive from discovery URL + let base = provider.discovery_url.trim_end_matches("/.well-known/openid-configuration"); + format!("{}/oauth2/device/code", base) + } else { + discovery.device_authorization_endpoint.clone() + } + }); let client = reqwest::Client::new(); let resp = client .post(&device_endpoint) - .form(&[ - ("client_id", XAI_OAUTH_CLIENT_ID), - ("scope", XAI_OAUTH_SCOPE), - ]) + .form(&[("client_id", provider.client_id.as_str()), ("scope", provider.scopes.as_str())]) .send() .await?; @@ -291,16 +348,12 @@ async fn do_login_device() -> Result<()> { } let device_resp: serde_json::Value = resp.json().await?; - let device_code = device_resp["device_code"] - .as_str() - .ok_or_else(|| anyhow!("No device_code in response"))?; - let user_code = device_resp["user_code"] - .as_str() - .ok_or_else(|| anyhow!("No user_code in response"))?; + let device_code = device_resp["device_code"].as_str().ok_or_else(|| anyhow!("No device_code"))?; + let user_code = device_resp["user_code"].as_str().ok_or_else(|| anyhow!("No user_code"))?; let verification_uri = device_resp["verification_uri"] .as_str() .or_else(|| device_resp["verification_url"].as_str()) - .unwrap_or("https://auth.x.ai/oauth2/device"); + .unwrap_or("(see provider docs)"); let interval = device_resp["interval"].as_u64().unwrap_or(5); println!("\n Go to: {}", verification_uri); @@ -309,12 +362,11 @@ async fn do_login_device() -> Result<()> { loop { tokio::time::sleep(tokio::time::Duration::from_secs(interval)).await; - let resp = client .post(&discovery.token_endpoint) .form(&[ ("grant_type", "urn:ietf:params:oauth:grant-type:device_code"), - ("client_id", XAI_OAUTH_CLIENT_ID), + ("client_id", provider.client_id.as_str()), ("device_code", device_code), ]) .send() @@ -324,12 +376,8 @@ async fn do_login_device() -> Result<()> { let payload: serde_json::Value = resp.json().await?; if status.is_success() { - let access_token = payload["access_token"] - .as_str() - .ok_or_else(|| anyhow!("No access_token"))?; - let refresh_token = payload["refresh_token"] - .as_str() - .ok_or_else(|| anyhow!("No refresh_token"))?; + let access_token = payload["access_token"].as_str().ok_or_else(|| anyhow!("No access_token"))?; + let refresh_token = payload["refresh_token"].as_str().ok_or_else(|| anyhow!("No refresh_token"))?; let expires_in = payload["expires_in"].as_u64().unwrap_or(3600); let now = SystemTime::now().duration_since(UNIX_EPOCH)?.as_secs(); @@ -339,32 +387,30 @@ async fn do_login_device() -> Result<()> { expires_at: now + expires_in, token_endpoint: discovery.token_endpoint, }; - save_tokens(&store)?; - println!("\n✅ Login successful! Token saved to {:?}", token_path()); - println!(" Run `xai-proxy serve` to start the proxy."); + save_tokens(provider, &store)?; + println!("\n✅ Login successful! Token saved to {:?}", token_path(provider)); return Ok(()); } - let error = payload["error"].as_str().unwrap_or_default(); - match error { + match payload["error"].as_str().unwrap_or_default() { "authorization_pending" | "slow_down" => continue, "expired_token" => return Err(anyhow!("Device code expired. Try again.")), "access_denied" => return Err(anyhow!("Authorization denied by user.")), - _ => return Err(anyhow!("Device-code error: {} — {:?}", error, payload)), + e => return Err(anyhow!("Device-code error: {} — {:?}", e, payload)), } } } // === Token Refresh === -async fn refresh_token(store: &TokenStore) -> Result { +async fn refresh_token(provider: &ProviderConfig, store: &TokenStore) -> Result { let client = reqwest::Client::new(); let resp = client .post(&store.token_endpoint) .form(&[ ("grant_type", "refresh_token"), ("refresh_token", &store.refresh_token), - ("client_id", XAI_OAUTH_CLIENT_ID), + ("client_id", &provider.client_id), ]) .send() .await?; @@ -376,26 +422,23 @@ async fn refresh_token(store: &TokenStore) -> Result { } let token_resp: serde_json::Value = resp.json().await?; - let access_token = token_resp["access_token"] - .as_str() - .ok_or_else(|| anyhow!("No access_token in refresh response"))?; - let refresh_token = token_resp["refresh_token"] - .as_str() - .unwrap_or(&store.refresh_token); + let access_token = token_resp["access_token"].as_str().ok_or_else(|| anyhow!("No access_token"))?; + let new_refresh = token_resp["refresh_token"].as_str().unwrap_or(&store.refresh_token); let expires_in = token_resp["expires_in"].as_u64().unwrap_or(3600); - let now = SystemTime::now().duration_since(UNIX_EPOCH)?.as_secs(); + Ok(TokenStore { access_token: access_token.to_string(), - refresh_token: refresh_token.to_string(), + refresh_token: new_refresh.to_string(), expires_at: now + expires_in, token_endpoint: store.token_endpoint.clone(), }) } -// === Proxy State === +// === Proxy === struct ProxyState { + provider: ProviderConfig, tokens: RwLock, http_client: Client, Body>, } @@ -409,27 +452,20 @@ impl ProxyState { return Ok(tokens.access_token.clone()); } } - // Need refresh let mut tokens = self.tokens.write().await; - // Double-check after acquiring write lock let now = SystemTime::now().duration_since(UNIX_EPOCH)?.as_secs(); if tokens.expires_at > now + REFRESH_SKEW_SECONDS { return Ok(tokens.access_token.clone()); } - info!("Refreshing xAI OAuth token..."); - let new_tokens = refresh_token(&tokens).await?; - save_tokens(&new_tokens)?; + info!("Refreshing OAuth token..."); + let new_tokens = refresh_token(&self.provider, &tokens).await?; + save_tokens(&self.provider, &new_tokens)?; *tokens = new_tokens; Ok(tokens.access_token.clone()) } } -// === Proxy Handler === - -async fn proxy_handler( - State(state): State>, - mut req: Request, -) -> Response { +async fn proxy_handler(State(state): State>, mut req: Request) -> Response { let token = match state.get_valid_token().await { Ok(t) => t, Err(e) => { @@ -441,27 +477,19 @@ async fn proxy_handler( } }; - // Rewrite URI to api.x.ai - let path_and_query = req - .uri() - .path_and_query() - .map(|pq| pq.as_str()) - .unwrap_or("/"); - let target_uri = format!("{}{}", XAI_API_BASE, path_and_query); - + let path_and_query = req.uri().path_and_query().map(|pq| pq.as_str()).unwrap_or("/"); + let target_uri = format!("{}{}", state.provider.upstream_base_url, path_and_query); *req.uri_mut() = target_uri.parse().unwrap(); - // Inject auth header req.headers_mut().insert( hyper::header::AUTHORIZATION, format!("Bearer {}", token).parse().unwrap(), ); req.headers_mut().insert( hyper::header::HOST, - "api.x.ai".parse().unwrap(), + state.provider.upstream_host().parse().unwrap(), ); - // Forward match state.http_client.request(req).await { Ok(resp) => { let (parts, body) = resp.into_parts(); @@ -477,23 +505,19 @@ async fn proxy_handler( } } -// === Serve === - -async fn do_serve(bind: &str, port: u16) -> Result<()> { - let store = load_tokens()?; +async fn do_serve(provider: &ProviderConfig, bind: &str, port: u16) -> Result<()> { + let store = load_tokens(provider)?; - // Check if token needs immediate refresh let now = SystemTime::now().duration_since(UNIX_EPOCH)?.as_secs(); let store = if store.expires_at <= now + REFRESH_SKEW_SECONDS { info!("Token expired, refreshing..."); - let new_store = refresh_token(&store).await?; - save_tokens(&new_store)?; + let new_store = refresh_token(provider, &store).await?; + save_tokens(provider, &new_store)?; new_store } else { store }; - // Build HTTPS client for upstream let https = hyper_rustls::HttpsConnectorBuilder::new() .with_native_roots()? .https_or_http() @@ -503,6 +527,7 @@ async fn do_serve(bind: &str, port: u16) -> Result<()> { let http_client = Client::builder(TokioExecutor::new()).build(https); let state = Arc::new(ProxyState { + provider: provider.clone(), tokens: RwLock::new(store), http_client, }); @@ -514,9 +539,9 @@ async fn do_serve(bind: &str, port: u16) -> Result<()> { let addr: SocketAddr = format!("{}:{}", bind, port).parse()?; let listener = TcpListener::bind(addr).await?; - info!("xai-proxy listening on http://{}", addr); - println!("xai-proxy listening on http://{}", addr); - println!("Set your client's base URL to: http://{}/v1", addr); + info!("openab-auth-proxy ({}) listening on http://{}", provider.name, addr); + println!("openab-auth-proxy ({}) listening on http://{}", provider.name, addr); + println!("Upstream: {}", provider.upstream_base_url); axum::serve(listener, app).await?; Ok(()) @@ -533,14 +558,17 @@ async fn main() -> Result<()> { tracing_subscriber::fmt() .with_env_filter( tracing_subscriber::EnvFilter::try_from_default_env() - .unwrap_or_else(|_| "xai_proxy=info".into()), + .unwrap_or_else(|_| "openab_auth_proxy=info".into()), ) .init(); let cli = Cli::parse(); + let provider = load_config(cli.config.as_ref())?; + info!("Provider: {} (upstream: {})", provider.name, provider.upstream_base_url); + match cli.command { - Commands::Login => do_login().await, - Commands::LoginDevice => do_login_device().await, - Commands::Serve { port, bind } => do_serve(&bind, port).await, + Commands::Login => do_login(&provider).await, + Commands::LoginDevice => do_login_device(&provider).await, + Commands::Serve { port, bind } => do_serve(&provider, &bind, port).await, } } diff --git a/xai-proxy/Cargo.lock b/xai-proxy/Cargo.lock deleted file mode 100644 index 5fe2be21d..000000000 --- a/xai-proxy/Cargo.lock +++ /dev/null @@ -1,2381 +0,0 @@ -# This file is automatically @generated by Cargo. -# It is not intended for manual editing. -version = 4 - -[[package]] -name = "aho-corasick" -version = "1.1.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" -dependencies = [ - "memchr", -] - -[[package]] -name = "anstream" -version = "1.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" -dependencies = [ - "anstyle", - "anstyle-parse", - "anstyle-query", - "anstyle-wincon", - "colorchoice", - "is_terminal_polyfill", - "utf8parse", -] - -[[package]] -name = "anstyle" -version = "1.0.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" - -[[package]] -name = "anstyle-parse" -version = "1.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" -dependencies = [ - "utf8parse", -] - -[[package]] -name = "anstyle-query" -version = "1.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" -dependencies = [ - "windows-sys 0.61.2", -] - -[[package]] -name = "anstyle-wincon" -version = "3.0.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" -dependencies = [ - "anstyle", - "once_cell_polyfill", - "windows-sys 0.61.2", -] - -[[package]] -name = "anyhow" -version = "1.0.102" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" - -[[package]] -name = "atomic-waker" -version = "1.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" - -[[package]] -name = "aws-lc-rs" -version = "1.17.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5ec2f1fc3ec205783a5da9a7e6c1509cc69dedf09a1949e412c1e18469326d00" -dependencies = [ - "aws-lc-sys", - "zeroize", -] - -[[package]] -name = "aws-lc-sys" -version = "0.41.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1a2f9779ce85b93ab6170dd940ad0169b5766ff848247aff13bb788b832fe3f4" -dependencies = [ - "cc", - "cmake", - "dunce", - "fs_extra", -] - -[[package]] -name = "axum" -version = "0.8.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "31b698c5f9a010f6573133b09e0de5408834d0c82f8d7475a89fc1867a71cd90" -dependencies = [ - "axum-core", - "bytes", - "form_urlencoded", - "futures-util", - "http", - "http-body", - "http-body-util", - "hyper", - "hyper-util", - "itoa", - "matchit", - "memchr", - "mime", - "percent-encoding", - "pin-project-lite", - "serde_core", - "serde_json", - "serde_path_to_error", - "serde_urlencoded", - "sync_wrapper", - "tokio", - "tower", - "tower-layer", - "tower-service", - "tracing", -] - -[[package]] -name = "axum-core" -version = "0.5.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "08c78f31d7b1291f7ee735c1c6780ccde7785daae9a9206026862dab7d8792d1" -dependencies = [ - "bytes", - "futures-core", - "http", - "http-body", - "http-body-util", - "mime", - "pin-project-lite", - "sync_wrapper", - "tower-layer", - "tower-service", - "tracing", -] - -[[package]] -name = "base64" -version = "0.22.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" - -[[package]] -name = "bitflags" -version = "2.11.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3" - -[[package]] -name = "block-buffer" -version = "0.10.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" -dependencies = [ - "generic-array", -] - -[[package]] -name = "bumpalo" -version = "3.20.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" - -[[package]] -name = "bytes" -version = "1.11.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" - -[[package]] -name = "cc" -version = "1.2.62" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a1dce859f0832a7d088c4f1119888ab94ef4b5d6795d1ce05afb7fe159d79f98" -dependencies = [ - "find-msvc-tools", - "jobserver", - "libc", - "shlex", -] - -[[package]] -name = "cfg-if" -version = "1.0.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" - -[[package]] -name = "cfg_aliases" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" - -[[package]] -name = "clap" -version = "4.6.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1ddb117e43bbf7dacf0a4190fef4d345b9bad68dfc649cb349e7d17d28428e51" -dependencies = [ - "clap_builder", - "clap_derive", -] - -[[package]] -name = "clap_builder" -version = "4.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f" -dependencies = [ - "anstream", - "anstyle", - "clap_lex", - "strsim", -] - -[[package]] -name = "clap_derive" -version = "4.6.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f2ce8604710f6733aa641a2b3731eaa1e8b3d9973d5e3565da11800813f997a9" -dependencies = [ - "heck", - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "clap_lex" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" - -[[package]] -name = "cmake" -version = "0.1.58" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c0f78a02292a74a88ac736019ab962ece0bc380e3f977bf72e376c5d78ff0678" -dependencies = [ - "cc", -] - -[[package]] -name = "colorchoice" -version = "1.0.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" - -[[package]] -name = "core-foundation" -version = "0.10.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" -dependencies = [ - "core-foundation-sys", - "libc", -] - -[[package]] -name = "core-foundation-sys" -version = "0.8.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" - -[[package]] -name = "cpufeatures" -version = "0.2.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" -dependencies = [ - "libc", -] - -[[package]] -name = "crypto-common" -version = "0.1.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" -dependencies = [ - "generic-array", - "typenum", -] - -[[package]] -name = "digest" -version = "0.10.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" -dependencies = [ - "block-buffer", - "crypto-common", -] - -[[package]] -name = "dirs" -version = "6.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c3e8aa94d75141228480295a7d0e7feb620b1a5ad9f12bc40be62411e38cce4e" -dependencies = [ - "dirs-sys", -] - -[[package]] -name = "dirs-sys" -version = "0.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab" -dependencies = [ - "libc", - "option-ext", - "redox_users", - "windows-sys 0.61.2", -] - -[[package]] -name = "displaydoc" -version = "0.2.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "dunce" -version = "1.0.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" - -[[package]] -name = "equivalent" -version = "1.0.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" - -[[package]] -name = "errno" -version = "0.3.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" -dependencies = [ - "libc", - "windows-sys 0.61.2", -] - -[[package]] -name = "find-msvc-tools" -version = "0.1.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" - -[[package]] -name = "fnv" -version = "1.0.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" - -[[package]] -name = "foldhash" -version = "0.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" - -[[package]] -name = "form_urlencoded" -version = "1.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" -dependencies = [ - "percent-encoding", -] - -[[package]] -name = "fs_extra" -version = "1.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" - -[[package]] -name = "futures-channel" -version = "0.3.32" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" -dependencies = [ - "futures-core", -] - -[[package]] -name = "futures-core" -version = "0.3.32" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" - -[[package]] -name = "futures-sink" -version = "0.3.32" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" - -[[package]] -name = "futures-task" -version = "0.3.32" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" - -[[package]] -name = "futures-util" -version = "0.3.32" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" -dependencies = [ - "futures-core", - "futures-task", - "pin-project-lite", - "slab", -] - -[[package]] -name = "generic-array" -version = "0.14.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" -dependencies = [ - "typenum", - "version_check", -] - -[[package]] -name = "getrandom" -version = "0.2.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" -dependencies = [ - "cfg-if", - "js-sys", - "libc", - "wasi", - "wasm-bindgen", -] - -[[package]] -name = "getrandom" -version = "0.3.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" -dependencies = [ - "cfg-if", - "js-sys", - "libc", - "r-efi 5.3.0", - "wasip2", - "wasm-bindgen", -] - -[[package]] -name = "getrandom" -version = "0.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" -dependencies = [ - "cfg-if", - "libc", - "r-efi 6.0.0", - "wasip2", - "wasip3", -] - -[[package]] -name = "h2" -version = "0.4.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "171fefbc92fe4a4de27e0698d6a5b392d6a0e333506bc49133760b3bcf948733" -dependencies = [ - "atomic-waker", - "bytes", - "fnv", - "futures-core", - "futures-sink", - "http", - "indexmap", - "slab", - "tokio", - "tokio-util", - "tracing", -] - -[[package]] -name = "hashbrown" -version = "0.15.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" -dependencies = [ - "foldhash", -] - -[[package]] -name = "hashbrown" -version = "0.17.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" - -[[package]] -name = "heck" -version = "0.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" - -[[package]] -name = "http" -version = "1.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3ba2a386d7f85a81f119ad7498ebe444d2e22c2af0b86b069416ace48b3311a" -dependencies = [ - "bytes", - "itoa", -] - -[[package]] -name = "http-body" -version = "1.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" -dependencies = [ - "bytes", - "http", -] - -[[package]] -name = "http-body-util" -version = "0.1.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" -dependencies = [ - "bytes", - "futures-core", - "http", - "http-body", - "pin-project-lite", -] - -[[package]] -name = "httparse" -version = "1.10.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" - -[[package]] -name = "httpdate" -version = "1.0.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" - -[[package]] -name = "hyper" -version = "1.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6299f016b246a94207e63da54dbe807655bf9e00044f73ded42c3ac5305fbcca" -dependencies = [ - "atomic-waker", - "bytes", - "futures-channel", - "futures-core", - "h2", - "http", - "http-body", - "httparse", - "httpdate", - "itoa", - "pin-project-lite", - "smallvec", - "tokio", - "want", -] - -[[package]] -name = "hyper-rustls" -version = "0.27.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f" -dependencies = [ - "http", - "hyper", - "hyper-util", - "log", - "rustls", - "rustls-native-certs", - "tokio", - "tokio-rustls", - "tower-service", - "webpki-roots", -] - -[[package]] -name = "hyper-util" -version = "0.1.20" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" -dependencies = [ - "base64", - "bytes", - "futures-channel", - "futures-util", - "http", - "http-body", - "hyper", - "ipnet", - "libc", - "percent-encoding", - "pin-project-lite", - "socket2", - "tokio", - "tower-service", - "tracing", -] - -[[package]] -name = "icu_collections" -version = "2.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c" -dependencies = [ - "displaydoc", - "potential_utf", - "utf8_iter", - "yoke", - "zerofrom", - "zerovec", -] - -[[package]] -name = "icu_locale_core" -version = "2.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29" -dependencies = [ - "displaydoc", - "litemap", - "tinystr", - "writeable", - "zerovec", -] - -[[package]] -name = "icu_normalizer" -version = "2.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4" -dependencies = [ - "icu_collections", - "icu_normalizer_data", - "icu_properties", - "icu_provider", - "smallvec", - "zerovec", -] - -[[package]] -name = "icu_normalizer_data" -version = "2.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38" - -[[package]] -name = "icu_properties" -version = "2.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de" -dependencies = [ - "icu_collections", - "icu_locale_core", - "icu_properties_data", - "icu_provider", - "zerotrie", - "zerovec", -] - -[[package]] -name = "icu_properties_data" -version = "2.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14" - -[[package]] -name = "icu_provider" -version = "2.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421" -dependencies = [ - "displaydoc", - "icu_locale_core", - "writeable", - "yoke", - "zerofrom", - "zerotrie", - "zerovec", -] - -[[package]] -name = "id-arena" -version = "2.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" - -[[package]] -name = "idna" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" -dependencies = [ - "idna_adapter", - "smallvec", - "utf8_iter", -] - -[[package]] -name = "idna_adapter" -version = "1.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" -dependencies = [ - "icu_normalizer", - "icu_properties", -] - -[[package]] -name = "indexmap" -version = "2.14.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" -dependencies = [ - "equivalent", - "hashbrown 0.17.1", - "serde", - "serde_core", -] - -[[package]] -name = "ipnet" -version = "2.12.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" - -[[package]] -name = "is-docker" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "928bae27f42bc99b60d9ac7334e3a21d10ad8f1835a4e12ec3ec0464765ed1b3" -dependencies = [ - "once_cell", -] - -[[package]] -name = "is-wsl" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "173609498df190136aa7dea1a91db051746d339e18476eed5ca40521f02d7aa5" -dependencies = [ - "is-docker", - "once_cell", -] - -[[package]] -name = "is_terminal_polyfill" -version = "1.70.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" - -[[package]] -name = "itoa" -version = "1.0.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" - -[[package]] -name = "jobserver" -version = "0.1.34" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33" -dependencies = [ - "getrandom 0.3.4", - "libc", -] - -[[package]] -name = "js-sys" -version = "0.3.98" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "67df7112613f8bfd9150013a0314e196f4800d3201ae742489d999db2f979f08" -dependencies = [ - "cfg-if", - "futures-util", - "once_cell", - "wasm-bindgen", -] - -[[package]] -name = "lazy_static" -version = "1.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" - -[[package]] -name = "leb128fmt" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" - -[[package]] -name = "libc" -version = "0.2.186" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" - -[[package]] -name = "libredox" -version = "0.1.16" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e02f3bb43d335493c96bf3fd3a321600bf6bd07ed34bc64118e9293bdffea46c" -dependencies = [ - "libc", -] - -[[package]] -name = "litemap" -version = "0.8.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" - -[[package]] -name = "lock_api" -version = "0.4.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" -dependencies = [ - "scopeguard", -] - -[[package]] -name = "log" -version = "0.4.29" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" - -[[package]] -name = "lru-slab" -version = "0.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" - -[[package]] -name = "matchers" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9" -dependencies = [ - "regex-automata", -] - -[[package]] -name = "matchit" -version = "0.8.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" - -[[package]] -name = "memchr" -version = "2.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" - -[[package]] -name = "mime" -version = "0.3.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" - -[[package]] -name = "mio" -version = "1.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1" -dependencies = [ - "libc", - "wasi", - "windows-sys 0.61.2", -] - -[[package]] -name = "nu-ansi-term" -version = "0.50.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" -dependencies = [ - "windows-sys 0.61.2", -] - -[[package]] -name = "once_cell" -version = "1.21.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" - -[[package]] -name = "once_cell_polyfill" -version = "1.70.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" - -[[package]] -name = "open" -version = "5.3.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2fbaa89d2ddc8473c78a3adf69eea8cffa28c483b8e02a971ef31527cd0fc92c" -dependencies = [ - "is-wsl", - "libc", - "pathdiff", -] - -[[package]] -name = "openssl-probe" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" - -[[package]] -name = "option-ext" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" - -[[package]] -name = "parking_lot" -version = "0.12.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" -dependencies = [ - "lock_api", - "parking_lot_core", -] - -[[package]] -name = "parking_lot_core" -version = "0.9.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" -dependencies = [ - "cfg-if", - "libc", - "redox_syscall", - "smallvec", - "windows-link", -] - -[[package]] -name = "pathdiff" -version = "0.2.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df94ce210e5bc13cb6651479fa48d14f601d9858cfe0467f43ae157023b938d3" - -[[package]] -name = "percent-encoding" -version = "2.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" - -[[package]] -name = "pin-project-lite" -version = "0.2.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" - -[[package]] -name = "potential_utf" -version = "0.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" -dependencies = [ - "zerovec", -] - -[[package]] -name = "ppv-lite86" -version = "0.2.21" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" -dependencies = [ - "zerocopy", -] - -[[package]] -name = "prettyplease" -version = "0.2.37" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" -dependencies = [ - "proc-macro2", - "syn", -] - -[[package]] -name = "proc-macro2" -version = "1.0.106" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" -dependencies = [ - "unicode-ident", -] - -[[package]] -name = "quinn" -version = "0.11.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9e20a958963c291dc322d98411f541009df2ced7b5a4f2bd52337638cfccf20" -dependencies = [ - "bytes", - "cfg_aliases", - "pin-project-lite", - "quinn-proto", - "quinn-udp", - "rustc-hash", - "rustls", - "socket2", - "thiserror", - "tokio", - "tracing", - "web-time", -] - -[[package]] -name = "quinn-proto" -version = "0.11.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "434b42fec591c96ef50e21e886936e66d3cc3f737104fdb9b737c40ffb94c098" -dependencies = [ - "bytes", - "getrandom 0.3.4", - "lru-slab", - "rand 0.9.4", - "ring", - "rustc-hash", - "rustls", - "rustls-pki-types", - "slab", - "thiserror", - "tinyvec", - "tracing", - "web-time", -] - -[[package]] -name = "quinn-udp" -version = "0.5.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd" -dependencies = [ - "cfg_aliases", - "libc", - "once_cell", - "socket2", - "tracing", - "windows-sys 0.60.2", -] - -[[package]] -name = "quote" -version = "1.0.45" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" -dependencies = [ - "proc-macro2", -] - -[[package]] -name = "r-efi" -version = "5.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" - -[[package]] -name = "r-efi" -version = "6.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" - -[[package]] -name = "rand" -version = "0.8.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a" -dependencies = [ - "libc", - "rand_chacha 0.3.1", - "rand_core 0.6.4", -] - -[[package]] -name = "rand" -version = "0.9.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea" -dependencies = [ - "rand_chacha 0.9.0", - "rand_core 0.9.5", -] - -[[package]] -name = "rand_chacha" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" -dependencies = [ - "ppv-lite86", - "rand_core 0.6.4", -] - -[[package]] -name = "rand_chacha" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" -dependencies = [ - "ppv-lite86", - "rand_core 0.9.5", -] - -[[package]] -name = "rand_core" -version = "0.6.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" -dependencies = [ - "getrandom 0.2.17", -] - -[[package]] -name = "rand_core" -version = "0.9.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" -dependencies = [ - "getrandom 0.3.4", -] - -[[package]] -name = "redox_syscall" -version = "0.5.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" -dependencies = [ - "bitflags", -] - -[[package]] -name = "redox_users" -version = "0.5.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac" -dependencies = [ - "getrandom 0.2.17", - "libredox", - "thiserror", -] - -[[package]] -name = "regex-automata" -version = "0.4.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" -dependencies = [ - "aho-corasick", - "memchr", - "regex-syntax", -] - -[[package]] -name = "regex-syntax" -version = "0.8.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" - -[[package]] -name = "reqwest" -version = "0.12.28" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" -dependencies = [ - "base64", - "bytes", - "futures-core", - "http", - "http-body", - "http-body-util", - "hyper", - "hyper-rustls", - "hyper-util", - "js-sys", - "log", - "percent-encoding", - "pin-project-lite", - "quinn", - "rustls", - "rustls-pki-types", - "serde", - "serde_json", - "serde_urlencoded", - "sync_wrapper", - "tokio", - "tokio-rustls", - "tower", - "tower-http", - "tower-service", - "url", - "wasm-bindgen", - "wasm-bindgen-futures", - "web-sys", - "webpki-roots", -] - -[[package]] -name = "ring" -version = "0.17.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" -dependencies = [ - "cc", - "cfg-if", - "getrandom 0.2.17", - "libc", - "untrusted", - "windows-sys 0.52.0", -] - -[[package]] -name = "rustc-hash" -version = "2.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe" - -[[package]] -name = "rustls" -version = "0.23.40" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ef86cd5876211988985292b91c96a8f2d298df24e75989a43a3c73f2d4d8168b" -dependencies = [ - "aws-lc-rs", - "log", - "once_cell", - "ring", - "rustls-pki-types", - "rustls-webpki", - "subtle", - "zeroize", -] - -[[package]] -name = "rustls-native-certs" -version = "0.8.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "612460d5f7bea540c490b2b6395d8e34a953e52b491accd6c86c8164c5932a63" -dependencies = [ - "openssl-probe", - "rustls-pki-types", - "schannel", - "security-framework", -] - -[[package]] -name = "rustls-pki-types" -version = "1.14.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "30a7197ae7eb376e574fe940d068c30fe0462554a3ddbe4eca7838e049c937a9" -dependencies = [ - "web-time", - "zeroize", -] - -[[package]] -name = "rustls-webpki" -version = "0.103.13" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" -dependencies = [ - "aws-lc-rs", - "ring", - "rustls-pki-types", - "untrusted", -] - -[[package]] -name = "rustversion" -version = "1.0.22" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" - -[[package]] -name = "ryu" -version = "1.0.23" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" - -[[package]] -name = "schannel" -version = "0.1.29" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939" -dependencies = [ - "windows-sys 0.61.2", -] - -[[package]] -name = "scopeguard" -version = "1.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" - -[[package]] -name = "security-framework" -version = "3.7.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" -dependencies = [ - "bitflags", - "core-foundation", - "core-foundation-sys", - "libc", - "security-framework-sys", -] - -[[package]] -name = "security-framework-sys" -version = "2.17.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" -dependencies = [ - "core-foundation-sys", - "libc", -] - -[[package]] -name = "semver" -version = "1.0.28" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" - -[[package]] -name = "serde" -version = "1.0.228" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" -dependencies = [ - "serde_core", - "serde_derive", -] - -[[package]] -name = "serde_core" -version = "1.0.228" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" -dependencies = [ - "serde_derive", -] - -[[package]] -name = "serde_derive" -version = "1.0.228" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "serde_json" -version = "1.0.149" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" -dependencies = [ - "itoa", - "memchr", - "serde", - "serde_core", - "zmij", -] - -[[package]] -name = "serde_path_to_error" -version = "0.1.20" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457" -dependencies = [ - "itoa", - "serde", - "serde_core", -] - -[[package]] -name = "serde_urlencoded" -version = "0.7.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" -dependencies = [ - "form_urlencoded", - "itoa", - "ryu", - "serde", -] - -[[package]] -name = "sha2" -version = "0.10.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" -dependencies = [ - "cfg-if", - "cpufeatures", - "digest", -] - -[[package]] -name = "sharded-slab" -version = "0.1.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" -dependencies = [ - "lazy_static", -] - -[[package]] -name = "shlex" -version = "1.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" - -[[package]] -name = "signal-hook-registry" -version = "1.4.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" -dependencies = [ - "errno", - "libc", -] - -[[package]] -name = "slab" -version = "0.4.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" - -[[package]] -name = "smallvec" -version = "1.15.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" - -[[package]] -name = "socket2" -version = "0.6.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e" -dependencies = [ - "libc", - "windows-sys 0.61.2", -] - -[[package]] -name = "stable_deref_trait" -version = "1.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" - -[[package]] -name = "strsim" -version = "0.11.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" - -[[package]] -name = "subtle" -version = "2.6.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" - -[[package]] -name = "syn" -version = "2.0.117" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" -dependencies = [ - "proc-macro2", - "quote", - "unicode-ident", -] - -[[package]] -name = "sync_wrapper" -version = "1.0.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" -dependencies = [ - "futures-core", -] - -[[package]] -name = "synstructure" -version = "0.13.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "thiserror" -version = "2.0.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" -dependencies = [ - "thiserror-impl", -] - -[[package]] -name = "thiserror-impl" -version = "2.0.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "thread_local" -version = "1.1.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185" -dependencies = [ - "cfg-if", -] - -[[package]] -name = "tinystr" -version = "0.8.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" -dependencies = [ - "displaydoc", - "zerovec", -] - -[[package]] -name = "tinyvec" -version = "1.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3e61e67053d25a4e82c844e8424039d9745781b3fc4f32b8d55ed50f5f667ef3" -dependencies = [ - "tinyvec_macros", -] - -[[package]] -name = "tinyvec_macros" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" - -[[package]] -name = "tokio" -version = "1.52.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe" -dependencies = [ - "bytes", - "libc", - "mio", - "parking_lot", - "pin-project-lite", - "signal-hook-registry", - "socket2", - "tokio-macros", - "windows-sys 0.61.2", -] - -[[package]] -name = "tokio-macros" -version = "2.7.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "tokio-rustls" -version = "0.26.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" -dependencies = [ - "rustls", - "tokio", -] - -[[package]] -name = "tokio-util" -version = "0.7.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098" -dependencies = [ - "bytes", - "futures-core", - "futures-sink", - "pin-project-lite", - "tokio", -] - -[[package]] -name = "tower" -version = "0.5.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" -dependencies = [ - "futures-core", - "futures-util", - "pin-project-lite", - "sync_wrapper", - "tokio", - "tower-layer", - "tower-service", - "tracing", -] - -[[package]] -name = "tower-http" -version = "0.6.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "68d6fdd9f81c2819c9a8b0e0cd91660e7746a8e6ea2ba7c6b2b057985f6bcb51" -dependencies = [ - "bitflags", - "bytes", - "futures-util", - "http", - "http-body", - "pin-project-lite", - "tower", - "tower-layer", - "tower-service", - "tracing", - "url", -] - -[[package]] -name = "tower-layer" -version = "0.3.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" - -[[package]] -name = "tower-service" -version = "0.3.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" - -[[package]] -name = "tracing" -version = "0.1.44" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" -dependencies = [ - "log", - "pin-project-lite", - "tracing-attributes", - "tracing-core", -] - -[[package]] -name = "tracing-attributes" -version = "0.1.31" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "tracing-core" -version = "0.1.36" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" -dependencies = [ - "once_cell", - "valuable", -] - -[[package]] -name = "tracing-log" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" -dependencies = [ - "log", - "once_cell", - "tracing-core", -] - -[[package]] -name = "tracing-subscriber" -version = "0.3.23" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319" -dependencies = [ - "matchers", - "nu-ansi-term", - "once_cell", - "regex-automata", - "sharded-slab", - "smallvec", - "thread_local", - "tracing", - "tracing-core", - "tracing-log", -] - -[[package]] -name = "try-lock" -version = "0.2.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" - -[[package]] -name = "typenum" -version = "1.20.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "40ce102ab67701b8526c123c1bab5cbe42d7040ccfd0f64af1a385808d2f43de" - -[[package]] -name = "unicode-ident" -version = "1.0.24" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" - -[[package]] -name = "unicode-xid" -version = "0.2.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" - -[[package]] -name = "untrusted" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" - -[[package]] -name = "url" -version = "2.5.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" -dependencies = [ - "form_urlencoded", - "idna", - "percent-encoding", - "serde", -] - -[[package]] -name = "urlencoding" -version = "2.1.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da" - -[[package]] -name = "utf8_iter" -version = "1.0.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" - -[[package]] -name = "utf8parse" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" - -[[package]] -name = "uuid" -version = "1.23.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ddd74a9687298c6858e9b88ec8935ec45d22e8fd5e6394fa1bd4e99a87789c76" -dependencies = [ - "getrandom 0.4.2", - "js-sys", - "wasm-bindgen", -] - -[[package]] -name = "valuable" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" - -[[package]] -name = "version_check" -version = "0.9.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" - -[[package]] -name = "want" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" -dependencies = [ - "try-lock", -] - -[[package]] -name = "wasi" -version = "0.11.1+wasi-snapshot-preview1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" - -[[package]] -name = "wasip2" -version = "1.0.3+wasi-0.2.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "20064672db26d7cdc89c7798c48a0fdfac8213434a1186e5ef29fd560ae223d6" -dependencies = [ - "wit-bindgen 0.57.1", -] - -[[package]] -name = "wasip3" -version = "0.4.0+wasi-0.3.0-rc-2026-01-06" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" -dependencies = [ - "wit-bindgen 0.51.0", -] - -[[package]] -name = "wasm-bindgen" -version = "0.2.121" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "49ace1d07c165b0864824eee619580c4689389afa9dc9ed3a4c75040d82e6790" -dependencies = [ - "cfg-if", - "once_cell", - "rustversion", - "wasm-bindgen-macro", - "wasm-bindgen-shared", -] - -[[package]] -name = "wasm-bindgen-futures" -version = "0.4.71" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "96492d0d3ffba25305a7dc88720d250b1401d7edca02cc3bcd50633b424673b8" -dependencies = [ - "js-sys", - "wasm-bindgen", -] - -[[package]] -name = "wasm-bindgen-macro" -version = "0.2.121" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e68e6f4afd367a562002c05637acb8578ff2dea1943df76afb9e83d177c8578" -dependencies = [ - "quote", - "wasm-bindgen-macro-support", -] - -[[package]] -name = "wasm-bindgen-macro-support" -version = "0.2.121" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d95a9ec35c64b2a7cb35d3fead40c4238d0940c86d107136999567a4703259f2" -dependencies = [ - "bumpalo", - "proc-macro2", - "quote", - "syn", - "wasm-bindgen-shared", -] - -[[package]] -name = "wasm-bindgen-shared" -version = "0.2.121" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c4e0100b01e9f0d03189a92b96772a1fb998639d981193d7dbab487302513441" -dependencies = [ - "unicode-ident", -] - -[[package]] -name = "wasm-encoder" -version = "0.244.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" -dependencies = [ - "leb128fmt", - "wasmparser", -] - -[[package]] -name = "wasm-metadata" -version = "0.244.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" -dependencies = [ - "anyhow", - "indexmap", - "wasm-encoder", - "wasmparser", -] - -[[package]] -name = "wasmparser" -version = "0.244.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" -dependencies = [ - "bitflags", - "hashbrown 0.15.5", - "indexmap", - "semver", -] - -[[package]] -name = "web-sys" -version = "0.3.98" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4b572dff8bcf38bad0fa19729c89bb5748b2b9b1d8be70cf90df697e3a8f32aa" -dependencies = [ - "js-sys", - "wasm-bindgen", -] - -[[package]] -name = "web-time" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" -dependencies = [ - "js-sys", - "wasm-bindgen", -] - -[[package]] -name = "webpki-roots" -version = "1.0.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "52f5ee44c96cf55f1b349600768e3ece3a8f26010c05265ab73f945bb1a2eb9d" -dependencies = [ - "rustls-pki-types", -] - -[[package]] -name = "windows-link" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" - -[[package]] -name = "windows-sys" -version = "0.52.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" -dependencies = [ - "windows-targets 0.52.6", -] - -[[package]] -name = "windows-sys" -version = "0.60.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" -dependencies = [ - "windows-targets 0.53.5", -] - -[[package]] -name = "windows-sys" -version = "0.61.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" -dependencies = [ - "windows-link", -] - -[[package]] -name = "windows-targets" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" -dependencies = [ - "windows_aarch64_gnullvm 0.52.6", - "windows_aarch64_msvc 0.52.6", - "windows_i686_gnu 0.52.6", - "windows_i686_gnullvm 0.52.6", - "windows_i686_msvc 0.52.6", - "windows_x86_64_gnu 0.52.6", - "windows_x86_64_gnullvm 0.52.6", - "windows_x86_64_msvc 0.52.6", -] - -[[package]] -name = "windows-targets" -version = "0.53.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" -dependencies = [ - "windows-link", - "windows_aarch64_gnullvm 0.53.1", - "windows_aarch64_msvc 0.53.1", - "windows_i686_gnu 0.53.1", - "windows_i686_gnullvm 0.53.1", - "windows_i686_msvc 0.53.1", - "windows_x86_64_gnu 0.53.1", - "windows_x86_64_gnullvm 0.53.1", - "windows_x86_64_msvc 0.53.1", -] - -[[package]] -name = "windows_aarch64_gnullvm" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" - -[[package]] -name = "windows_aarch64_gnullvm" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" - -[[package]] -name = "windows_aarch64_msvc" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" - -[[package]] -name = "windows_aarch64_msvc" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" - -[[package]] -name = "windows_i686_gnu" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" - -[[package]] -name = "windows_i686_gnu" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" - -[[package]] -name = "windows_i686_gnullvm" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" - -[[package]] -name = "windows_i686_gnullvm" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" - -[[package]] -name = "windows_i686_msvc" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" - -[[package]] -name = "windows_i686_msvc" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" - -[[package]] -name = "windows_x86_64_gnu" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" - -[[package]] -name = "windows_x86_64_gnu" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" - -[[package]] -name = "windows_x86_64_gnullvm" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" - -[[package]] -name = "windows_x86_64_gnullvm" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" - -[[package]] -name = "windows_x86_64_msvc" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" - -[[package]] -name = "windows_x86_64_msvc" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" - -[[package]] -name = "wit-bindgen" -version = "0.51.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" -dependencies = [ - "wit-bindgen-rust-macro", -] - -[[package]] -name = "wit-bindgen" -version = "0.57.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" - -[[package]] -name = "wit-bindgen-core" -version = "0.51.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" -dependencies = [ - "anyhow", - "heck", - "wit-parser", -] - -[[package]] -name = "wit-bindgen-rust" -version = "0.51.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" -dependencies = [ - "anyhow", - "heck", - "indexmap", - "prettyplease", - "syn", - "wasm-metadata", - "wit-bindgen-core", - "wit-component", -] - -[[package]] -name = "wit-bindgen-rust-macro" -version = "0.51.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a" -dependencies = [ - "anyhow", - "prettyplease", - "proc-macro2", - "quote", - "syn", - "wit-bindgen-core", - "wit-bindgen-rust", -] - -[[package]] -name = "wit-component" -version = "0.244.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" -dependencies = [ - "anyhow", - "bitflags", - "indexmap", - "log", - "serde", - "serde_derive", - "serde_json", - "wasm-encoder", - "wasm-metadata", - "wasmparser", - "wit-parser", -] - -[[package]] -name = "wit-parser" -version = "0.244.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" -dependencies = [ - "anyhow", - "id-arena", - "indexmap", - "log", - "semver", - "serde", - "serde_derive", - "serde_json", - "unicode-xid", - "wasmparser", -] - -[[package]] -name = "writeable" -version = "0.6.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" - -[[package]] -name = "xai-proxy" -version = "0.1.0" -dependencies = [ - "anyhow", - "axum", - "base64", - "clap", - "dirs", - "http-body-util", - "hyper", - "hyper-rustls", - "hyper-util", - "open", - "rand 0.8.6", - "reqwest", - "rustls", - "serde", - "serde_json", - "sha2", - "tokio", - "tower", - "tower-http", - "tracing", - "tracing-subscriber", - "url", - "urlencoding", - "uuid", -] - -[[package]] -name = "yoke" -version = "0.8.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "abe8c5fda708d9ca3df187cae8bfb9ceda00dd96231bed36e445a1a48e66f9ca" -dependencies = [ - "stable_deref_trait", - "yoke-derive", - "zerofrom", -] - -[[package]] -name = "yoke-derive" -version = "0.8.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" -dependencies = [ - "proc-macro2", - "quote", - "syn", - "synstructure", -] - -[[package]] -name = "zerocopy" -version = "0.8.48" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eed437bf9d6692032087e337407a86f04cd8d6a16a37199ed57949d415bd68e9" -dependencies = [ - "zerocopy-derive", -] - -[[package]] -name = "zerocopy-derive" -version = "0.8.48" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "70e3cd084b1788766f53af483dd21f93881ff30d7320490ec3ef7526d203bad4" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "zerofrom" -version = "0.1.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" -dependencies = [ - "zerofrom-derive", -] - -[[package]] -name = "zerofrom-derive" -version = "0.1.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" -dependencies = [ - "proc-macro2", - "quote", - "syn", - "synstructure", -] - -[[package]] -name = "zeroize" -version = "1.8.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" - -[[package]] -name = "zerotrie" -version = "0.2.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf" -dependencies = [ - "displaydoc", - "yoke", - "zerofrom", -] - -[[package]] -name = "zerovec" -version = "0.11.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239" -dependencies = [ - "yoke", - "zerofrom", - "zerovec-derive", -] - -[[package]] -name = "zerovec-derive" -version = "0.11.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "zmij" -version = "1.0.21" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" diff --git a/xai-proxy/README.md b/xai-proxy/README.md deleted file mode 100644 index ccd638683..000000000 --- a/xai-proxy/README.md +++ /dev/null @@ -1,143 +0,0 @@ -# xai-proxy - -Lightweight Rust sidecar that authenticates with xAI via OAuth PKCE (SuperGrok subscription) and proxies OpenAI-compatible requests to `api.x.ai/v1`. - -## Why - -Use your SuperGrok subscription quota (instead of API credits) with any OpenAI-compatible coding agent — OpenCode, Hermes, etc. - -## How it works - -``` -┌──────────────────────────────────────────────────────────────┐ -│ Pod / Host │ -│ │ -│ ┌────────────────────┐ POST /v1/chat/completions │ -│ │ coding agent │──────────────────────┐ │ -│ │ (OpenCode, etc.) │ ▼ │ -│ └────────────────────┘ ┌─────────────────────────────┐ │ -│ │ xai-proxy :9090 │ │ -│ │ │ │ -│ │ • Injects Bearer token │ │ -│ │ • Auto-refreshes < 120s │ │ -│ └──────────────┬──────────────┘ │ -└───────────────────────────────────────────┼──────────────────┘ - │ - ▼ - ┌─────────────────────────────┐ - │ https://api.x.ai/v1 │ - │ (SuperGrok subscription) │ - └─────────────────────────────┘ -``` - -## Build - -```bash -cargo build --release -``` - -## Docker - -```bash -docker build -t xai-proxy . -docker run --rm -v ~/.xai-proxy:/root/.xai-proxy xai-proxy serve --bind 0.0.0.0 -``` - -## Usage - -### 1. Login (one-time) - -```bash -# Browser OAuth (local machine) -./target/release/xai-proxy login - -# Device-code flow (headless / K8s / ECS) -./target/release/xai-proxy login-device -``` - -Token is saved to `~/.xai-proxy/tokens.json` (or custom path via `XAI_PROXY_TOKEN_PATH`). - -### 2. Start proxy - -```bash -./target/release/xai-proxy serve --port 9090 -``` - -### 3. Point your client - -```bash -export OPENAI_BASE_URL=http://127.0.0.1:9090/v1 -export OPENAI_API_KEY=dummy -``` - -## Environment Variables - -| Variable | Default | Description | -|----------|---------|-------------| -| `XAI_PROXY_TOKEN_PATH` | `~/.xai-proxy/tokens.json` | Custom token file location | -| `RUST_LOG` | `xai_proxy=info` | Log level | - -## Kubernetes Sidecar - -Deploy alongside openab as a sidecar container with PVC persistence: - -```yaml -extraInitContainers: - - name: copy-tokens - image: busybox - command: ["sh", "-c", "if [ ! -f /dest/.openab/xai-proxy/tokens.json ]; then mkdir -p /dest/.openab/xai-proxy && cp /src/tokens.json /dest/.openab/xai-proxy/tokens.json; fi"] - volumeMounts: - - name: xai-tokens-src - mountPath: /src - readOnly: true - - name: data - mountPath: /dest - -extraContainers: - - name: xai-proxy - image: xai-proxy:latest - args: ["serve", "--bind", "0.0.0.0"] - env: - - name: XAI_PROXY_TOKEN_PATH - value: /home/agent/.openab/xai-proxy/tokens.json - volumeMounts: - - name: data - mountPath: /home/agent - -extraVolumes: - - name: xai-tokens-src - secret: - secretName: xai-proxy-tokens -``` - -## OAuth details - -| Item | Value | -|------|-------| -| Auth server | `https://auth.x.ai` | -| Client ID | Grok CLI public client | -| Flow | OAuth 2.0 PKCE (loopback) or device-code | -| Scope | `openid profile email offline_access grok-cli:access api:access` | -| Token storage | `~/.xai-proxy/tokens.json` (chmod 600) | -| Auto-refresh | Yes, 120s before expiry | - -## Requirements - -- Active SuperGrok subscription (any tier) -- Rust 1.86+ (build) -- Browser or device-code access for initial login - -## Headless / SSH login - -```bash -# Option A: device-code (recommended) -xai-proxy login-device - -# Option B: SSH port-forward -ssh -N -L 56121:127.0.0.1:56121 user@remote-host -xai-proxy login # open the URL in your local browser -``` - -## License - -MIT From 65c8a7ab73e2546aeb40b3cf566a560ddde74cab Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E8=AA=9E=E5=AB=A3?= Date: Sat, 23 May 2026 21:02:05 +0800 Subject: [PATCH 081/100] feat: add openab-feishu chart (#883) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: add openab-feishu chart (colocated OAB + gateway, WebSocket default) Single-pod Helm chart for Feishu/Lark deployments: - OAB agent and gateway as colocated containers - WebSocket mode (default): outbound-only, no public endpoint needed - Optional webhook mode with cloudflared sidecar - Supports both Feishu (feishu.cn) and Lark (larksuite.com) - Only 2 required --set flags: feishu.appId, feishu.appSecret - existingSecret support for production credential management - Security contexts: non-root, read-only rootfs, drop all caps * fix(openab-feishu): address chart review findings from chaodu-agent Six issues fixed across deployment.yaml, configmap.yaml, and pvc.yaml: F1 (🔴) existingSecret + webhook mode silently dropped env vars: FEISHU_VERIFICATION_TOKEN and FEISHU_ENCRYPT_KEY secretKeyRefs are now rendered whenever connectionMode=webhook (not only when values are non-empty). Added optional: true so pods start even if those keys are absent from the secret (both are optional security hardening). F2 (🟡) Boolean default trap in reactions config: Removed `| default true/false` pipes from configmap.yaml. Defaults are declared in values.yaml; the pipes caused `false` to be treated as empty and substituted with `true`, making reactions un-disableable. BUG1 (🔴) tunnel.enabled=true without token caused silent CrashLoop: Added a `fail` guard that aborts helm template/install with a clear error message when the tunnel is enabled but no token is provided and no existingSecret is set. BUG2 (🟡) storageClass: "-" rendered as literal "-" storageClassName: Applied the standard Helm convention: "-" is mapped to storageClassName: "" (static PV / empty class), any other non-empty value is passed through as-is. BUG3 (🟡) checksum/secret annotation had wrong semantics in existingSecret mode: When existingSecret is set, secret.yaml renders empty and the checksum was a constant — external secret rotations would not trigger a rolling restart. Annotation is now skipped when existingSecret is set. BUG4 (🟡) TOML env map rendered in non-deterministic order: Replaced manual $first-flag iteration with `keys | sortAlpha` + index lookup. Env keys now render alphabetically, eliminating spurious checksum/config diffs in GitOps pipelines. * docs(openab-feishu): document existingSecret rotation limitation Helm cannot track changes to externally-managed Secrets, so rotating credentials does not automatically trigger a Pod rollout when existingSecret is set. Added a comment in values.yaml explaining this limitation and pointing to Reloader as the recommended solution. --------- Co-authored-by: wangyuyan-agent --- charts/openab-feishu/Chart.yaml | 6 + charts/openab-feishu/README.md | 220 ++++++++++++++++++ charts/openab-feishu/templates/NOTES.txt | 73 ++++++ charts/openab-feishu/templates/_helpers.tpl | 41 ++++ charts/openab-feishu/templates/configmap.yaml | 36 +++ .../openab-feishu/templates/deployment.yaml | 187 +++++++++++++++ charts/openab-feishu/templates/pvc.yaml | 23 ++ charts/openab-feishu/templates/secret.yaml | 27 +++ charts/openab-feishu/values.yaml | 122 ++++++++++ 9 files changed, 735 insertions(+) create mode 100644 charts/openab-feishu/Chart.yaml create mode 100644 charts/openab-feishu/README.md create mode 100644 charts/openab-feishu/templates/NOTES.txt create mode 100644 charts/openab-feishu/templates/_helpers.tpl create mode 100644 charts/openab-feishu/templates/configmap.yaml create mode 100644 charts/openab-feishu/templates/deployment.yaml create mode 100644 charts/openab-feishu/templates/pvc.yaml create mode 100644 charts/openab-feishu/templates/secret.yaml create mode 100644 charts/openab-feishu/values.yaml diff --git a/charts/openab-feishu/Chart.yaml b/charts/openab-feishu/Chart.yaml new file mode 100644 index 000000000..7904d83aa --- /dev/null +++ b/charts/openab-feishu/Chart.yaml @@ -0,0 +1,6 @@ +apiVersion: v2 +name: openab-feishu +description: OpenAB + Feishu/Lark — single-pod deployment with gateway (WebSocket by default, optional Cloudflare Tunnel for webhook mode). +type: application +version: 0.1.0 +appVersion: "0.8.3" diff --git a/charts/openab-feishu/README.md b/charts/openab-feishu/README.md new file mode 100644 index 000000000..69711e5ef --- /dev/null +++ b/charts/openab-feishu/README.md @@ -0,0 +1,220 @@ +# openab-feishu + +OpenAB + Feishu/Lark — single-pod Helm chart for deploying an AI agent on Feishu (飛書) or Lark. + +## Architecture + +``` +┌──────────── Pod: openab-feishu ────────────┐ +│ │ +│ ┌──────────┐ localhost ┌─────────────┐ │ +│ │ openab │ ◄──────────► │ gateway │ │ +│ │ (agent) │ │ (feishu) │ │ +│ └──────────┘ └──────┬──────┘ │ +│ │ │ │ +│ ▼ ▼ │ +│ PVC /home/agent Outbound WebSocket │ +│ to open.feishu.cn │ +└─────────────────────────────────────────────┘ +``` + +**Default mode: WebSocket** — gateway connects outbound to Feishu, no public endpoint needed. + +Optional webhook mode adds a cloudflared sidecar (same pattern as `openab-telegram`). + +## Quick Start + +```bash +helm install my-bot ./charts/openab-feishu \ + --set feishu.appId="cli_xxx" \ + --set feishu.appSecret="xxx" \ + --namespace openab --create-namespace +``` + +Only **2 required parameters**. Everything else has sane defaults. + +## Prerequisites: Feishu Open Platform Setup + +Before deploying, create a Feishu app. This is a one-time setup. + +> **Hermes Agent** and **OpenClaw** both support Feishu via their own gateway implementations (env-var based config, `pip install` / `npm install`). OAB's approach is K8s-native: a single `helm install` deploys everything, with credentials managed as K8s Secrets. + +### 1. Create App + +1. Go to [Feishu Open Platform](https://open.feishu.cn) (or [Lark Developer](https://open.larksuite.com) for overseas) +2. Click **Create Custom App** +3. Note down the **App ID** (`cli_xxx`) and **App Secret** + +### 2. Enable Bot Capability + +1. In the left sidebar, go to **App Features** → **Bot** +2. Toggle **Enable Bot** to ON + +> ⚠️ This step is easy to miss. Without it, the app cannot receive messages. + +### 3. Configure Event Subscription + +1. Navigate to **Event Subscriptions** in the left sidebar +2. **Connection mode**: Select **WebSocket** (recommended) + - WebSocket requires no public URL — the gateway connects outbound + - If you must use webhook mode, see [Webhook Mode](#webhook-mode) below +3. **Add event**: `im.message.receive_v1` (Receive messages) + +### 4. Add Permissions + +Under **Permissions & Scopes**, add these scopes: + +| Scope | Purpose | +|-------|---------| +| `im:message` | Send and receive messages | +| `im:message.group_at_msg` | Receive @mention messages in groups | +| `im:message.group_at_msg:readonly` | Read group @mention messages | +| `im:message.p2p_msg:readonly` | Read DM messages | +| `im:resource` | Download images/files from messages | +| `contact:user.base:readonly` | Resolve user display names | + +### 5. Publish + +Click **Create Version** → **Apply for publish**. For development, you can use the app in test mode without full approval. + +### 6. Get IDs for Access Control (Optional) + +To restrict which users/groups can interact with the bot: + +- **Group ID** (`oc_xxx`): Open the group → click top-right menu → Settings → Group ID +- **User Open ID** (`ou_xxx`): Check gateway logs after the user sends a message, or use the Feishu Contact API + +## Credential Management + +Three options from simplest to most secure: + +| # | Method | Security | Notes | +|---|--------|----------|-------| +| 1 | `--set feishu.appId=X --set feishu.appSecret=Y` | ⚠️ Stored in Helm release | Good for dev/testing | +| 2 | `kubectl create secret` + `--set existingSecret=name` | ✅ Out of Helm values | Good for production | +| 3 | `kubectl create secret --from-env-file=<(vault/aws sm)` + `--set existingSecret=name` | ✅✅ Never touches disk | Best for security | + +### Option 2 example: + +```bash +kubectl create secret generic feishu-creds -n openab \ + --from-literal=feishu-app-id="cli_xxx" \ + --from-literal=feishu-app-secret="xxx" + +helm install my-bot ./charts/openab-feishu \ + --set existingSecret=feishu-creds \ + --namespace openab --create-namespace +``` + +### Option 3 example (AWS Secrets Manager): + +```bash +kubectl create secret generic feishu-creds -n openab \ + --from-env-file=<(aws secretsmanager get-secret-value \ + --secret-id oab-feishu --query SecretString --output text | \ + jq -r '{"feishu-app-id": .appId, "feishu-app-secret": .appSecret} | to_entries[] | "\(.key)=\(.value)"') + +helm install my-bot ./charts/openab-feishu \ + --set existingSecret=feishu-creds \ + --namespace openab --create-namespace +``` + +## Release Channel + +| `channel` | Core image tag | Gateway image tag | +|-----------|---------------|-------------------| +| `stable` (default) | `ghcr.io/openabdev/openab:stable` | `v0.5.1` (pinned) | +| `beta` | `ghcr.io/openabdev/openab:beta` | `v0.5.1` (pinned) | + +## Webhook Mode + +If WebSocket is not available (e.g., network policy blocks outbound WebSocket), switch to webhook mode: + +```bash +helm install my-bot ./charts/openab-feishu \ + --set feishu.appId="cli_xxx" \ + --set feishu.appSecret="xxx" \ + --set feishu.connectionMode="webhook" \ + --set feishu.verificationToken="xxx" \ + --set feishu.encryptKey="xxx" \ + --set tunnel.token="eyJ..." \ + --set webhookDomain="bot.example.com" \ + --namespace openab --create-namespace +``` + +This adds a cloudflared sidecar (3-container pod, same as `openab-telegram`). + +After deployment: +1. Configure Cloudflare Tunnel ingress to point your domain at `localhost:8080` +2. In Feishu Open Platform → Event Subscriptions → set Request URL to `https://bot.example.com/webhook/feishu` + - ⚠️ The gateway must be running when you set the URL — Feishu sends a challenge request immediately + +## Lark (Overseas) + +For Lark (larksuite.com) instead of Feishu (feishu.cn): + +```bash +helm install my-bot ./charts/openab-feishu \ + --set feishu.appId="cli_xxx" \ + --set feishu.appSecret="xxx" \ + --set feishu.domain="lark" \ + --namespace openab --create-namespace +``` + +## Comparison with Other Platforms + +| Feature | openab-feishu | openab-telegram | OpenClaw | Hermes Agent | +|---------|--------------|-----------------|----------|--------------| +| Default containers | 2 (agent + gateway) | 3 (+ cloudflared) | N/A (no Helm) | N/A (no Helm) | +| Public endpoint needed | ❌ (WebSocket) | ✅ (webhook) | Varies | Varies | +| Feishu/Lark support | ✅ Native | ❌ | ❌ | ❌ | +| K8s-native deployment | ✅ Helm chart | ✅ Helm chart | ❌ docker-compose | ❌ pip install | +| Credential params | 2 (appId + appSecret) | 2 (botToken + tunnelToken) | N/A | N/A | + +## Values Reference + +| Key | Default | Description | +|-----|---------|-------------| +| `feishu.appId` | `""` | **(required)** Feishu App ID | +| `feishu.appSecret` | `""` | **(required)** Feishu App Secret | +| `feishu.domain` | `"feishu"` | `"feishu"` or `"lark"` | +| `feishu.connectionMode` | `"websocket"` | `"websocket"` or `"webhook"` | +| `feishu.verificationToken` | `""` | Webhook verification token | +| `feishu.encryptKey` | `""` | Webhook encrypt key | +| `existingSecret` | `""` | Use pre-existing K8s Secret | +| `tunnel.enabled` | `false` | Enable cloudflared sidecar | +| `tunnel.token` | `""` | Cloudflare Tunnel token | +| `webhookDomain` | `""` | Domain for webhook URL | +| `channel` | `"stable"` | `"stable"` or `"beta"` | +| `platform.requireMention` | `true` | Require @mention in groups | +| `platform.allowedGroups` | `[]` | Allowed group chat IDs | +| `platform.allowedUsers` | `[]` | Allowed user open_ids | +| `persistence.enabled` | `true` | Enable PVC for agent state | +| `persistence.size` | `"1Gi"` | PVC size | + +## Troubleshooting + +| Problem | Fix | +|---------|-----| +| Bot doesn't respond to DMs | Ensure Bot capability is enabled in App Features → Bot | +| Bot doesn't respond in groups | Ensure you @mention the bot (default: `requireMention: true`) | +| Bot doesn't receive any messages | Check event subscription: must have `im.message.receive_v1` and WebSocket mode selected | +| Gateway logs show "token refresh error" | Verify `appId` and `appSecret` are correct | +| Gateway logs show "feishu ws endpoint error" | WebSocket mode requires the app to be published (at least test version) | +| Permission denied on image/file download | Grant `im:resource` scope and re-publish the app | +| User names show as `ou_xxx` | Grant `contact:user.base:readonly` scope | +| Pod CrashLoopBackOff | Check `kubectl logs -c gateway` — usually a credential issue | + +## Comparison: OAB vs OpenClaw vs Hermes Agent (Feishu) + +| Aspect | OAB (this chart) | OpenClaw | Hermes Agent | +|--------|-----------------|----------|--------------| +| Deployment | `helm install` (K8s-native) | `npx @larksuite/openclaw-lark install` | `hermes gateway setup` | +| Runtime | Rust binary (gateway) + any agent | Node.js | Python | +| Connection mode | WebSocket (default) / Webhook | WebSocket (default) / Webhook | WebSocket (default) / Webhook | +| Config style | Helm values + K8s Secrets | JSON config file | `.env` + `config.yaml` | +| Credential management | 3-tier (--set → K8s Secret → external SM) | Plain config file | `.env` file | +| Security hardening | Non-root, read-only rootfs, drop all caps | N/A (runs as user process) | N/A (runs as user process) | +| Public endpoint needed | ❌ (WebSocket mode) | ❌ (WebSocket mode) | ❌ (WebSocket mode) | +| Feishu-specific features | @mention gating, user allowlist, group allowlist, bot-to-bot, media proxy | Streaming cards, multi-account, ACP sessions, pairing | Interactive cards, document comments, per-group ACL, burst batching | + diff --git a/charts/openab-feishu/templates/NOTES.txt b/charts/openab-feishu/templates/NOTES.txt new file mode 100644 index 000000000..f25b8373f --- /dev/null +++ b/charts/openab-feishu/templates/NOTES.txt @@ -0,0 +1,73 @@ +🎉 OpenAB Feishu bot deployed! + +{{- if include "openab-feishu.tunnelEnabled" . }} +Pod: {{ include "openab-feishu.fullname" . }} (3 containers: openab, gateway, cloudflared) +Mode: webhook (with Cloudflare Tunnel) +{{- else }} +Pod: {{ include "openab-feishu.fullname" . }} (2 containers: openab, gateway) +Mode: websocket (outbound-only, no public endpoint needed) +{{- end }} + +## Post-Install Steps + +{{- if eq .Values.feishu.connectionMode "websocket" }} + +### Step 1: Configure Feishu Open Platform (one-time) + +1. Go to https://open.feishu.cn → Create or select your app +2. Under "Event Subscriptions" → Connection mode: select "WebSocket" +3. Subscribe to event: im.message.receive_v1 +4. Under "Permissions", add: + - im:message (Send and receive messages) + - im:message.group_at_msg (Receive group @messages) + - im:resource (Download message resources) + - contact:user.base:readonly (Read user basic info — for display names) +5. Publish the app version + +No public URL or webhook configuration needed — the gateway connects outbound. + +{{- else }} + +### Step 1: Configure Cloudflare Tunnel ingress + +Option A — Via Cloudflare Dashboard: + https://one.dash.cloudflare.com/ → Networks → Tunnels → your tunnel → Public Hostname → Add: + Hostname: {{ .Values.webhookDomain | default "bot.example.com" }} + Type: HTTP + URL: localhost:8080 + +### Step 2: Configure Feishu Open Platform + +1. Go to https://open.feishu.cn → Create or select your app +2. Under "Event Subscriptions" → Connection mode: select "HTTP" +3. Set Request URL: https://{{ .Values.webhookDomain | default "YOUR_DOMAIN" }}/webhook/feishu + (The gateway must be running — Feishu sends a challenge request immediately) +4. Subscribe to event: im.message.receive_v1 +5. Under "Permissions", add: + - im:message + - im:message.group_at_msg + - im:resource + - contact:user.base:readonly +6. Publish the app version + +{{- end }} + +### {{ if eq .Values.feishu.connectionMode "websocket" }}Step 2{{ else }}Step 3{{ end }}: Authenticate the agent + +The agent needs a one-time OAuth login: + + kubectl exec -it deployment/{{ include "openab-feishu.fullname" . }} -n {{ .Release.Namespace }} -c openab -- {{ .Values.agent.command }} login --use-device-flow + +Then restart to pick up credentials: + + kubectl rollout restart deployment/{{ include "openab-feishu.fullname" . }} -n {{ .Release.Namespace }} + +## Verify + +Send a message to your bot on Feishu (DM or @mention in a group). Check logs if no response: + + kubectl logs -n {{ .Release.Namespace }} deployment/{{ include "openab-feishu.fullname" . }} -c openab --tail=20 + kubectl logs -n {{ .Release.Namespace }} deployment/{{ include "openab-feishu.fullname" . }} -c gateway --tail=20 +{{- if include "openab-feishu.tunnelEnabled" . }} + kubectl logs -n {{ .Release.Namespace }} deployment/{{ include "openab-feishu.fullname" . }} -c cloudflared --tail=20 +{{- end }} diff --git a/charts/openab-feishu/templates/_helpers.tpl b/charts/openab-feishu/templates/_helpers.tpl new file mode 100644 index 000000000..f0c4a85d6 --- /dev/null +++ b/charts/openab-feishu/templates/_helpers.tpl @@ -0,0 +1,41 @@ +{{- define "openab-feishu.fullname" -}} +{{- .Release.Name | trunc 63 | trimSuffix "-" }} +{{- end }} + +{{- define "openab-feishu.labels" -}} +helm.sh/chart: {{ printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" | trunc 63 }} +app.kubernetes.io/name: {{ .Chart.Name }} +app.kubernetes.io/instance: {{ .Release.Name }} +app.kubernetes.io/version: {{ .Chart.AppVersion | quote }} +app.kubernetes.io/managed-by: {{ .Release.Service }} +{{- end }} + +{{- define "openab-feishu.selectorLabels" -}} +app.kubernetes.io/name: {{ .Chart.Name }} +app.kubernetes.io/instance: {{ .Release.Name }} +{{- end }} + +{{- define "openab-feishu.agentImage" -}} +{{- $tag := .Values.image.tag -}} +{{- if not $tag -}} + {{- $tag = .Values.channel | default "stable" -}} +{{- end -}} +{{- printf "%s:%s" .Values.image.repository $tag -}} +{{- end }} + +{{- define "openab-feishu.gatewayImage" -}} +{{- printf "%s:%s" .Values.gateway.image .Values.gateway.tag -}} +{{- end }} + +{{- define "openab-feishu.secretName" -}} +{{- .Values.existingSecret | default (include "openab-feishu.fullname" .) -}} +{{- end }} + +{{- define "openab-feishu.tunnelEnabled" -}} +{{- if .Values.tunnel.enabled -}} +true +{{- else if and (eq .Values.feishu.connectionMode "webhook") .Values.tunnel.token -}} +true +{{- else -}} +{{- end -}} +{{- end }} diff --git a/charts/openab-feishu/templates/configmap.yaml b/charts/openab-feishu/templates/configmap.yaml new file mode 100644 index 000000000..baf3fe999 --- /dev/null +++ b/charts/openab-feishu/templates/configmap.yaml @@ -0,0 +1,36 @@ +apiVersion: v1 +kind: ConfigMap +metadata: + name: {{ include "openab-feishu.fullname" . }} + labels: + {{- include "openab-feishu.labels" . | nindent 4 }} +data: + config.toml: | + [agent] + command = {{ .Values.agent.command | toJson }} + args = {{ .Values.agent.args | default list | toJson }} + working_dir = {{ .Values.agent.workingDir | default "/home/agent" | toJson }} + {{- if .Values.agent.env }} + env = { {{ range $i, $k := (.Values.agent.env | keys | sortAlpha) }}{{ if gt $i 0 }}, {{ end }}{{ $k }} = {{ index $.Values.agent.env $k | toJson }}{{ end }} } + {{- end }} + {{- $secretEnvKeys := list }} + {{- range .Values.agent.secretEnv }}{{ $secretEnvKeys = append $secretEnvKeys .name }}{{ end }} + {{- if $secretEnvKeys }} + inherit_env = {{ $secretEnvKeys | toJson }} + {{- end }} + + [pool] + max_sessions = {{ .Values.agent.pool.maxSessions | default 10 }} + session_ttl_hours = {{ .Values.agent.pool.sessionTtlHours | default 24 }} + + [reactions] + enabled = {{ .Values.agent.reactions.enabled }} + remove_after_reply = {{ .Values.agent.reactions.removeAfterReply }} + + [gateway] + url = "ws://localhost:8080/ws" + platform = "feishu" + allow_all_channels = {{ if .Values.platform.allowedGroups }}false{{ else }}true{{ end }} + allowed_channels = {{ .Values.platform.allowedGroups | default list | toJson }} + allow_all_users = {{ if .Values.platform.allowedUsers }}false{{ else }}true{{ end }} + allowed_users = {{ .Values.platform.allowedUsers | default list | toJson }} diff --git a/charts/openab-feishu/templates/deployment.yaml b/charts/openab-feishu/templates/deployment.yaml new file mode 100644 index 000000000..dbadc7962 --- /dev/null +++ b/charts/openab-feishu/templates/deployment.yaml @@ -0,0 +1,187 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: {{ include "openab-feishu.fullname" . }} + labels: + {{- include "openab-feishu.labels" . | nindent 4 }} +spec: + replicas: 1 + strategy: + type: Recreate + selector: + matchLabels: + {{- include "openab-feishu.selectorLabels" . | nindent 6 }} + template: + metadata: + annotations: + checksum/config: {{ include (print .Template.BasePath "/configmap.yaml") . | sha256sum }} + {{- if not .Values.existingSecret }} + checksum/secret: {{ include (print .Template.BasePath "/secret.yaml") . | sha256sum }} + {{- end }} + labels: + {{- include "openab-feishu.selectorLabels" . | nindent 8 }} + spec: + {{- with .Values.podSecurityContext }} + securityContext: + {{- toYaml . | nindent 8 }} + {{- end }} + containers: + # --- OAB agent (main) --- + - name: openab + image: {{ include "openab-feishu.agentImage" . | quote }} + imagePullPolicy: {{ .Values.image.pullPolicy }} + {{- with .Values.containerSecurityContext }} + securityContext: + {{- toYaml . | nindent 12 }} + {{- end }} + env: + - name: HOME + value: {{ .Values.agent.workingDir | default "/home/agent" }} + {{- range $k, $v := .Values.agent.env }} + - name: {{ $k }} + value: {{ $v | quote }} + {{- end }} + {{- range .Values.agent.secretEnv }} + - name: {{ .name }} + valueFrom: + secretKeyRef: + name: {{ .secretName }} + key: {{ .secretKey }} + {{- end }} + {{- with .Values.resources }} + resources: + {{- toYaml . | nindent 12 }} + {{- end }} + volumeMounts: + - name: config + mountPath: /etc/openab + readOnly: true + {{- if .Values.persistence.enabled }} + - name: data + mountPath: {{ .Values.agent.workingDir | default "/home/agent" }} + {{- end }} + - name: tmp + mountPath: /tmp + + # --- Gateway (sidecar) --- + - name: gateway + image: {{ include "openab-feishu.gatewayImage" . | quote }} + imagePullPolicy: {{ .Values.image.pullPolicy }} + {{- with .Values.containerSecurityContext }} + securityContext: + {{- toYaml . | nindent 12 }} + {{- end }} + ports: + - name: http + containerPort: 8080 + protocol: TCP + env: + - name: HOME + value: {{ .Values.agent.workingDir | default "/home/agent" }} + - name: FEISHU_APP_ID + valueFrom: + secretKeyRef: + name: {{ include "openab-feishu.secretName" . }} + key: feishu-app-id + - name: FEISHU_APP_SECRET + valueFrom: + secretKeyRef: + name: {{ include "openab-feishu.secretName" . }} + key: feishu-app-secret + - name: FEISHU_DOMAIN + value: {{ .Values.feishu.domain | quote }} + - name: FEISHU_CONNECTION_MODE + value: {{ .Values.feishu.connectionMode | quote }} + {{- if or .Values.feishu.verificationToken (eq .Values.feishu.connectionMode "webhook") }} + - name: FEISHU_VERIFICATION_TOKEN + valueFrom: + secretKeyRef: + name: {{ include "openab-feishu.secretName" . }} + key: feishu-verification-token + optional: true + {{- end }} + {{- if or .Values.feishu.encryptKey (eq .Values.feishu.connectionMode "webhook") }} + - name: FEISHU_ENCRYPT_KEY + valueFrom: + secretKeyRef: + name: {{ include "openab-feishu.secretName" . }} + key: feishu-encrypt-key + optional: true + {{- end }} + {{- if .Values.platform.requireMention }} + - name: FEISHU_REQUIRE_MENTION + value: "true" + {{- else }} + - name: FEISHU_REQUIRE_MENTION + value: "false" + {{- end }} + {{- if .Values.platform.allowedGroups }} + - name: FEISHU_ALLOWED_GROUPS + value: {{ join "," .Values.platform.allowedGroups | quote }} + {{- end }} + {{- if .Values.platform.allowedUsers }} + - name: FEISHU_ALLOWED_USERS + value: {{ join "," .Values.platform.allowedUsers | quote }} + {{- end }} + volumeMounts: + {{- if .Values.persistence.enabled }} + - name: data + mountPath: {{ .Values.agent.workingDir | default "/home/agent" }} + {{- end }} + - name: tmp + mountPath: /tmp + + {{- if include "openab-feishu.tunnelEnabled" . }} + {{- if and (not .Values.tunnel.token) (not .Values.existingSecret) }} + {{- fail "tunnel.token is required when the cloudflared tunnel is enabled (set tunnel.token, or supply credentials via existingSecret with a cloudflare-tunnel-token key)" }} + {{- end }} + # --- Cloudflared tunnel (sidecar, webhook mode only) --- + - name: cloudflared + image: {{ printf "%s:%s" .Values.tunnel.image .Values.tunnel.tag }} + imagePullPolicy: IfNotPresent + securityContext: + allowPrivilegeEscalation: false + readOnlyRootFilesystem: true + capabilities: + drop: + - ALL + args: + - tunnel + - --no-autoupdate + - run + - --token + - $(TUNNEL_TOKEN) + env: + - name: TUNNEL_TOKEN + valueFrom: + secretKeyRef: + name: {{ include "openab-feishu.secretName" . }} + key: cloudflare-tunnel-token + volumeMounts: + - name: tmp + mountPath: /tmp + {{- end }} + + {{- with .Values.nodeSelector }} + nodeSelector: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.affinity }} + affinity: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.tolerations }} + tolerations: + {{- toYaml . | nindent 8 }} + {{- end }} + volumes: + - name: config + configMap: + name: {{ include "openab-feishu.fullname" . }} + {{- if .Values.persistence.enabled }} + - name: data + persistentVolumeClaim: + claimName: {{ .Values.persistence.existingClaim | default (include "openab-feishu.fullname" .) }} + {{- end }} + - name: tmp + emptyDir: {} diff --git a/charts/openab-feishu/templates/pvc.yaml b/charts/openab-feishu/templates/pvc.yaml new file mode 100644 index 000000000..0cca83059 --- /dev/null +++ b/charts/openab-feishu/templates/pvc.yaml @@ -0,0 +1,23 @@ +{{- if and .Values.persistence.enabled (not .Values.persistence.existingClaim) }} +apiVersion: v1 +kind: PersistentVolumeClaim +metadata: + name: {{ include "openab-feishu.fullname" . }} + annotations: + "helm.sh/resource-policy": keep + labels: + {{- include "openab-feishu.labels" . | nindent 4 }} +spec: + accessModes: + - ReadWriteOnce + {{- if .Values.persistence.storageClass }} + {{- if eq "-" .Values.persistence.storageClass }} + storageClassName: "" + {{- else }} + storageClassName: {{ .Values.persistence.storageClass | quote }} + {{- end }} + {{- end }} + resources: + requests: + storage: {{ .Values.persistence.size | default "1Gi" }} +{{- end }} diff --git a/charts/openab-feishu/templates/secret.yaml b/charts/openab-feishu/templates/secret.yaml new file mode 100644 index 000000000..39f4ae930 --- /dev/null +++ b/charts/openab-feishu/templates/secret.yaml @@ -0,0 +1,27 @@ +{{- if not .Values.existingSecret }} +{{- if not .Values.feishu.appId }} +{{- fail "feishu.appId is required when existingSecret is not set (--set feishu.appId=YOUR_APP_ID)" }} +{{- end }} +{{- if not .Values.feishu.appSecret }} +{{- fail "feishu.appSecret is required when existingSecret is not set (--set feishu.appSecret=YOUR_APP_SECRET)" }} +{{- end }} +apiVersion: v1 +kind: Secret +metadata: + name: {{ include "openab-feishu.fullname" . }} + labels: + {{- include "openab-feishu.labels" . | nindent 4 }} +type: Opaque +stringData: + feishu-app-id: {{ .Values.feishu.appId | quote }} + feishu-app-secret: {{ .Values.feishu.appSecret | quote }} + {{- if .Values.feishu.verificationToken }} + feishu-verification-token: {{ .Values.feishu.verificationToken | quote }} + {{- end }} + {{- if .Values.feishu.encryptKey }} + feishu-encrypt-key: {{ .Values.feishu.encryptKey | quote }} + {{- end }} + {{- if .Values.tunnel.token }} + cloudflare-tunnel-token: {{ .Values.tunnel.token | quote }} + {{- end }} +{{- end }} diff --git a/charts/openab-feishu/values.yaml b/charts/openab-feishu/values.yaml new file mode 100644 index 000000000..dd9f11c04 --- /dev/null +++ b/charts/openab-feishu/values.yaml @@ -0,0 +1,122 @@ +# openab-feishu values +# +# Install (WebSocket mode — default, no tunnel needed): +# helm install my-bot ./charts/openab-feishu \ +# --set feishu.appId="cli_xxx" \ +# --set feishu.appSecret="xxx" \ +# --namespace openab --create-namespace +# +# Required: +# feishu.appId -- Feishu/Lark App ID (from open.feishu.cn) +# feishu.appSecret -- Feishu/Lark App Secret +# +# Optional: +# feishu.domain -- "feishu" (default, China) or "lark" (overseas) +# feishu.connectionMode -- "websocket" (default) or "webhook" +# tunnel.enabled -- Enable cloudflared sidecar (auto-enabled in webhook mode) +# tunnel.token -- Cloudflare Tunnel token (required if tunnel.enabled) + +# -- Feishu/Lark application credentials +feishu: + # -- (required unless existingSecret is set) App ID from Feishu Open Platform + appId: "" + # -- (required unless existingSecret is set) App Secret + appSecret: "" + # -- "feishu" for feishu.cn (China) or "lark" for larksuite.com (overseas) + domain: "feishu" + # -- "websocket" (default, outbound-only, no public endpoint needed) or "webhook" + connectionMode: "websocket" + # -- (webhook mode only) Verification token from Feishu Open Platform + verificationToken: "" + # -- (webhook mode only) Encrypt key for event signature verification + encryptKey: "" + +# -- Use a pre-existing K8s Secret instead of creating one from --set values. +# The Secret must contain keys: feishu-app-id, feishu-app-secret +# Optional keys: feishu-verification-token, feishu-encrypt-key, cloudflare-tunnel-token +# +# NOTE: When existingSecret is set, the chart cannot track changes to the external +# Secret's contents — rotating credentials will NOT automatically trigger a Pod +# rollout. To enable automatic rollout on secret rotation, use a tool such as +# Reloader (https://github.com/stakater/Reloader) and annotate the Deployment: +# kubectl annotate deployment reloader.stakater.com/auto="true" +existingSecret: "" + +# -- Cloudflare Tunnel sidecar (only needed for webhook mode) +tunnel: + # -- Enable tunnel sidecar. Auto-enabled when connectionMode=webhook and token is set. + enabled: false + # -- Cloudflare Tunnel token + token: "" + image: cloudflare/cloudflared + tag: "2026.5.0" + +# -- Webhook domain (shown in post-install notes) +webhookDomain: "" + +# -- Release channel: "stable" or "beta" +channel: stable + +# -- OAB agent image +image: + repository: ghcr.io/openabdev/openab + tag: "" # defaults to channel + pullPolicy: IfNotPresent + +# -- Gateway image +gateway: + image: ghcr.io/openabdev/openab-gateway + tag: "v0.5.1" + +# -- Agent configuration +agent: + command: kiro-cli + args: + - acp + - --trust-all-tools + workingDir: /home/agent + env: {} + secretEnv: [] + pool: + maxSessions: 10 + sessionTtlHours: 24 + reactions: + enabled: true + removeAfterReply: false + +# -- Gateway platform settings +platform: + # -- Require @mention in groups (recommended for shared groups) + requireMention: true + # -- Feishu group chat IDs allowed (empty = all groups) + allowedGroups: [] + # -- Feishu user open_ids allowed (empty = all users) + allowedUsers: [] + +# -- Persistence for agent working directory +persistence: + enabled: true + existingClaim: "" + storageClass: "" + size: 1Gi + +# -- Pod-level settings +resources: {} +nodeSelector: {} +tolerations: [] +affinity: {} + +podSecurityContext: + runAsNonRoot: true + runAsUser: 1000 + runAsGroup: 1000 + fsGroup: 1000 + seccompProfile: + type: RuntimeDefault + +containerSecurityContext: + allowPrivilegeEscalation: false + readOnlyRootFilesystem: true + capabilities: + drop: + - ALL From b29c765581c747b375d0f8975bae4ed3b2f6c9e3 Mon Sep 17 00:00:00 2001 From: chaodufashi Date: Sat, 23 May 2026 14:47:52 -0400 Subject: [PATCH 082/100] docs: add PR lifecycle flow to CONTRIBUTING.md (#912) * docs: add PR lifecycle flow to CONTRIBUTING.md * docs: fix box alignment and split label transition table rows * docs: note immediate closing-soon for missing Discord URL * docs: align stale to 2 days, clarify re-check may re-apply closing-soon * docs: rewrite lifecycle diagram with checks-first flow and re-check loop * docs: clarify maintainer flips to pending-contributor when pending actions exist * docs: add LGTM/approve/merge path from pending-maintainer --------- Co-authored-by: chaodu-agent[bot] --- CONTRIBUTING.md | 71 +++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 71 insertions(+) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 08abe5ab9..556fa27e0 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -80,3 +80,74 @@ cargo check - Run `cargo fmt` before committing - Run `cargo clippy` and address warnings - Keep PRs focused — one feature or fix per PR + +## PR Lifecycle + +Every PR follows a label-driven lifecycle that keeps the review loop moving. + +``` +┌──────────────┐ +│ PR Created │ +└──────┬───────┘ + │ + ▼ +┌──────────────────────┐ +│ Automated Checks │ +│ (CI, rebase, etc.) │ +└──────┬───────────────┘ + │ + ├── all pass ──────────────────────►┌──────────────────────┐ + │ │ pending-maintainer │ + │ └──────────┬───────────┘ + │ │ + │ ├── LGTM → approve & merge (or request + │ │ another maintainer review) + │ │ stays pending-maintainer + │ │ + │ └── pending actions for contributor + │ │ + │ ▼ + └── any fail ──────────────────────►┌──────────────────────┐ + │ pending-contributor │◄─────────┐ + └──────────┬───────────┘ │ + │ │ + │ stale 2 days │ + │ (no author activity) │ + ▼ │ + ┌───────────────────┐ │ + │ closing-soon │ │ + │ (or immediate if │ │ + │ blocker detected)│ │ + └────────┬──────────┘ │ + │ │ + ┌────────────┴──────────┐ │ + │ │ │ + ▼ ▼ │ + author comments 3 more days │ + within 3 days no activity │ + │ │ │ + ▼ ▼ │ + ┌────────────────────┐ ┌────────────┐ │ + │ pending-maintainer │ │ PR Closed │ │ + │ (labels removed) │ └────────────┘ │ + └────────┬───────────┘ │ + │ │ + └── re-check fails ────────────────────┘ +``` + +### Label Transitions + +| Current State | Trigger | Action | +|---------------|---------|--------| +| `pending-contributor` | No author activity for 2 days | Add `closing-soon` | +| `closing-soon` | No author activity for 3 more days | Auto-close PR | +| `pending-contributor` | Author adds a comment | Remove `pending-contributor`, add `pending-maintainer` | +| `closing-soon` | Author adds a comment | Remove `closing-soon` and `pending-contributor`, add `pending-maintainer` | + +### Key Rules + +- **`pending-contributor`** — the ball is on the contributor; maintainers are waiting for updates. +- **`closing-soon`** — warning that the PR will be auto-closed if no response within 3 days. +- **Author comment always resets** — any comment by the PR author removes `pending-contributor` and `closing-soon`, flipping the PR back to `pending-maintainer`. +- **Re-check may re-apply `closing-soon`** — after the flip, automated checks still run. If blockers remain (e.g., missing Discord URL, CI failure, `needs-rebase`), `closing-soon` will be re-applied immediately, keeping the ball on the contributor. +- **Immediate `closing-soon`** — in some cases (e.g., missing Discord Discussion URL), `closing-soon` is applied immediately without waiting for the stale period. From ac01c2944988a1bccb11abfffce3cbaae7791dda Mon Sep 17 00:00:00 2001 From: chaodufashi Date: Sat, 23 May 2026 14:55:35 -0400 Subject: [PATCH 083/100] fix: align workflows to PR lifecycle spec (#915) - pending-maintainer.yml: remove closing-soon skip, add closing-soon label removal when author comments and all checks pass - close-stale-prs.yml: update auto-close message to generic stale message instead of Discord-URL-specific Co-authored-by: chaodu-agent[bot] --- .github/workflows/close-stale-prs.yml | 2 +- .github/workflows/pending-maintainer.yml | 13 +++++++------ 2 files changed, 8 insertions(+), 7 deletions(-) diff --git a/.github/workflows/close-stale-prs.yml b/.github/workflows/close-stale-prs.yml index bf10ca989..77170fd4f 100644 --- a/.github/workflows/close-stale-prs.yml +++ b/.github/workflows/close-stale-prs.yml @@ -47,7 +47,7 @@ jobs: await github.rest.issues.createComment({ ...context.repo, issue_number: pr.number, - body: `🔒 Auto-closing: this PR has had the \`${label}\` label for more than ${staleDays} days without a Discord Discussion URL being added.\n\nFeel free to reopen after adding the discussion link to the PR body.` + body: `🔒 Auto-closing: this PR has had the \`${label}\` label for more than ${staleDays} days without activity from the author.\n\nIf you'd like to continue working on this, feel free to reopen and leave a comment.` }); await github.rest.pulls.update({ diff --git a/.github/workflows/pending-maintainer.yml b/.github/workflows/pending-maintainer.yml index 55c741d86..c153e1113 100644 --- a/.github/workflows/pending-maintainer.yml +++ b/.github/workflows/pending-maintainer.yml @@ -53,12 +53,6 @@ jobs: continue; } - // Skip if closing-soon — contributor has incomplete work - if (labels.includes('closing-soon')) { - console.log(`#${prNumber} — closing-soon, skipping`); - continue; - } - // Skip if has merge conflicts or already labeled needs-rebase if (pr.mergeable === false || labels.includes('needs-rebase')) { console.log(`#${prNumber} — has conflicts or needs-rebase, skipping`); @@ -121,5 +115,12 @@ jobs: name: CONTRIBUTOR }).catch(() => {}); } + if (labels.includes('closing-soon')) { + await github.rest.issues.removeLabel({ + ...context.repo, + issue_number: prNumber, + name: 'closing-soon' + }).catch(() => {}); + } console.log(`#${prNumber} — all clear, set ${MAINTAINER}`); } From 5d2980e32efaa99a1a038c1409c4577393ce6b54 Mon Sep 17 00:00:00 2001 From: Yen <5915590+antigenius0910@users.noreply.github.com> Date: Sat, 23 May 2026 14:01:11 -0500 Subject: [PATCH 084/100] feat(chart): add serviceAccountName support at per-agent and global level (#914) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(chart): add serviceAccountName support at per-agent and global level Per-agent value (agents..serviceAccountName) wins when set; otherwise falls back to chart-global $.Values.serviceAccountName. Both empty preserves current behaviour (no serviceAccountName rendered, Kubernetes uses cluster default SA). This is required to activate IRSA on EKS — without an explicit serviceAccountName, the pod-identity-webhook never injects AWS credentials and workloads silently fall back to the broad EC2 node role, breaking least-privilege. Scope: string reference to an existing SA only. The chart does NOT create a new SA or manage IRSA annotations (operators provision out-of-band via Terraform / IDP / kubectl), matching how PR #901 (existingSecret) and #910 (imagePullSecrets) reference existing K8s resources rather than creating them. Closes #913 * docs: generalize serviceAccountName descriptions, remove EKS/IRSA-specific wording --------- Co-authored-by: chaodu-agent[bot] --- charts/openab/README.md | 2 + charts/openab/templates/deployment.yaml | 4 ++ charts/openab/tests/serviceaccount_test.yaml | 51 ++++++++++++++++++++ charts/openab/values.yaml | 14 ++++++ 4 files changed, 71 insertions(+) create mode 100644 charts/openab/tests/serviceaccount_test.yaml diff --git a/charts/openab/README.md b/charts/openab/README.md index 183c79255..a9e50b029 100644 --- a/charts/openab/README.md +++ b/charts/openab/README.md @@ -12,6 +12,7 @@ This page highlights commonly used values and deployment patterns. For the compl |-------|-------------|---------| | `nameOverride` | Override the chart name portion used in generated resource names. For per-agent resource names, use `agents..nameOverride`. | `""` | | `fullnameOverride` | Override the full generated release name for chart resources. Useful when deploying multiple instances with predictable names. | `""` | +| `serviceAccountName` | Chart-global ServiceAccount name attached to every agent pod that doesn't define its own. Empty = cluster `default` SA. Per-agent `agents..serviceAccountName` fully overrides this. Chart references an existing SA only — does not create one. Required for workload identity and pod-level RBAC. | `""` | ### Agent values @@ -51,6 +52,7 @@ Each agent lives under `agents.`. | `persistence.enabled` | Enable persistent storage for auth and settings. | `true` | | `persistence.existingClaim` | Reuse an existing PVC instead of creating one. | `""` | | `agentsMd` | Contents of `AGENTS.md` mounted into the working directory. | `""` | +| `serviceAccountName` | Per-agent ServiceAccount name. When set (non-empty), fully overrides chart-global `serviceAccountName`. Useful when only some agents need a dedicated SA. | `""` | | `extraInitContainers` | Additional init containers for the agent pod. | `[]` | | `extraContainers` | Additional sidecar containers for the agent pod. | `[]` | | `extraVolumeMounts` | Additional volume mounts for the main agent container. | `[]` | diff --git a/charts/openab/templates/deployment.yaml b/charts/openab/templates/deployment.yaml index 7659c1f71..1a6570613 100644 --- a/charts/openab/templates/deployment.yaml +++ b/charts/openab/templates/deployment.yaml @@ -29,6 +29,10 @@ spec: securityContext: {{- toYaml . | nindent 8 }} {{- end }} + {{- $svcAcct := default $.Values.serviceAccountName $cfg.serviceAccountName }} + {{- if $svcAcct }} + serviceAccountName: {{ $svcAcct }} + {{- end }} {{- with $cfg.extraInitContainers }} initContainers: {{- toYaml . | nindent 8 }} diff --git a/charts/openab/tests/serviceaccount_test.yaml b/charts/openab/tests/serviceaccount_test.yaml new file mode 100644 index 000000000..d5b92de43 --- /dev/null +++ b/charts/openab/tests/serviceaccount_test.yaml @@ -0,0 +1,51 @@ +suite: serviceAccountName support (chart-global + per-agent override) +templates: + - templates/deployment.yaml + +tests: + - it: does not render serviceAccountName when neither global nor per-agent is set + asserts: + - notExists: + path: spec.template.spec.serviceAccountName + + - it: renders chart-global serviceAccountName when only the global value is set + set: + serviceAccountName: "openab" + asserts: + - equal: + path: spec.template.spec.serviceAccountName + value: openab + + - it: renders per-agent serviceAccountName when only the per-agent value is set + set: + agents.kiro.serviceAccountName: "kiro-sa" + asserts: + - equal: + path: spec.template.spec.serviceAccountName + value: kiro-sa + + - it: per-agent serviceAccountName fully overrides chart-global + set: + serviceAccountName: "openab" + agents.kiro.serviceAccountName: "kiro-sa" + asserts: + - equal: + path: spec.template.spec.serviceAccountName + value: kiro-sa + + - it: empty per-agent serviceAccountName falls back to chart-global + set: + serviceAccountName: "openab" + agents.kiro.serviceAccountName: "" + asserts: + - equal: + path: spec.template.spec.serviceAccountName + value: openab + + - it: explicit empty global + empty per-agent renders no serviceAccountName field + set: + serviceAccountName: "" + agents.kiro.serviceAccountName: "" + asserts: + - notExists: + path: spec.template.spec.serviceAccountName diff --git a/charts/openab/values.yaml b/charts/openab/values.yaml index 6ea0f0921..5a28007d9 100644 --- a/charts/openab/values.yaml +++ b/charts/openab/values.yaml @@ -11,6 +11,15 @@ nameOverride: "" # Override the full release name used in generated resource names. fullnameOverride: "" +# Chart-global ServiceAccount name for agent pods, used when an agent doesn't +# set its own `serviceAccountName`. Empty string = use cluster default SA. +# Per-agent values (agents..serviceAccountName) take precedence — when +# set, they fully override this. The chart only references an existing SA; it +# does NOT create one or manage annotations (provision out-of-band). +# Example: +# serviceAccountName: "openab" +serviceAccountName: "" + podSecurityContext: runAsNonRoot: true runAsUser: 1000 @@ -406,6 +415,11 @@ agents: nodeSelector: {} tolerations: [] affinity: {} + # Per-agent ServiceAccount name. When set (non-empty), overrides the + # chart-global `serviceAccountName` for this agent only. Useful in + # multi-agent deployments where only some agents need a dedicated SA. + # serviceAccountName: "openab" + serviceAccountName: "" # extraInitContainers adds init containers to the pod (runs before the main container) extraInitContainers: [] # extraContainers adds sidecar containers to the pod From 090ffe19428c12ce878f9215e28dd9f74e45f833 Mon Sep 17 00:00:00 2001 From: Yen <5915590+antigenius0910@users.noreply.github.com> Date: Sat, 23 May 2026 14:05:20 -0500 Subject: [PATCH 085/100] feat(chart): add imagePullSecrets support at per-agent and global level (#911) Per-agent value (agents..imagePullSecrets) wins when set; otherwise falls back to chart-global $.Values.imagePullSecrets. Both empty preserves current behaviour (no imagePullSecrets rendered). This enables multi-agent deployments where only some agents pull from a private registry without forcing pull credentials onto every pod. Follows the same per-agent K8s-native secrets pattern as PR #901 (slack existingSecret). Closes #910 --- charts/openab/README.md | 2 + charts/openab/templates/deployment.yaml | 4 ++ .../openab/tests/imagepullsecrets_test.yaml | 64 +++++++++++++++++++ charts/openab/values.yaml | 14 ++++ 4 files changed, 84 insertions(+) create mode 100644 charts/openab/tests/imagepullsecrets_test.yaml diff --git a/charts/openab/README.md b/charts/openab/README.md index a9e50b029..a7ddae8b1 100644 --- a/charts/openab/README.md +++ b/charts/openab/README.md @@ -13,6 +13,7 @@ This page highlights commonly used values and deployment patterns. For the compl | `nameOverride` | Override the chart name portion used in generated resource names. For per-agent resource names, use `agents..nameOverride`. | `""` | | `fullnameOverride` | Override the full generated release name for chart resources. Useful when deploying multiple instances with predictable names. | `""` | | `serviceAccountName` | Chart-global ServiceAccount name attached to every agent pod that doesn't define its own. Empty = cluster `default` SA. Per-agent `agents..serviceAccountName` fully overrides this. Chart references an existing SA only — does not create one. Required for workload identity and pod-level RBAC. | `""` | +| `imagePullSecrets` | Chart-global image pull secrets attached to every agent pod that doesn't define its own. Per-agent `agents..imagePullSecrets` fully overrides this. | `[]` | ### Agent values @@ -53,6 +54,7 @@ Each agent lives under `agents.`. | `persistence.existingClaim` | Reuse an existing PVC instead of creating one. | `""` | | `agentsMd` | Contents of `AGENTS.md` mounted into the working directory. | `""` | | `serviceAccountName` | Per-agent ServiceAccount name. When set (non-empty), fully overrides chart-global `serviceAccountName`. Useful when only some agents need a dedicated SA. | `""` | +| `imagePullSecrets` | Per-agent image pull secrets. When set, fully overrides chart-global `imagePullSecrets`. Useful when only some agents pull from a private registry. | `[]` | | `extraInitContainers` | Additional init containers for the agent pod. | `[]` | | `extraContainers` | Additional sidecar containers for the agent pod. | `[]` | | `extraVolumeMounts` | Additional volume mounts for the main agent container. | `[]` | diff --git a/charts/openab/templates/deployment.yaml b/charts/openab/templates/deployment.yaml index 1a6570613..5760bb42d 100644 --- a/charts/openab/templates/deployment.yaml +++ b/charts/openab/templates/deployment.yaml @@ -33,6 +33,10 @@ spec: {{- if $svcAcct }} serviceAccountName: {{ $svcAcct }} {{- end }} + {{- with (default $.Values.imagePullSecrets $cfg.imagePullSecrets) }} + imagePullSecrets: + {{- toYaml . | nindent 8 }} + {{- end }} {{- with $cfg.extraInitContainers }} initContainers: {{- toYaml . | nindent 8 }} diff --git a/charts/openab/tests/imagepullsecrets_test.yaml b/charts/openab/tests/imagepullsecrets_test.yaml new file mode 100644 index 000000000..fc5abedf0 --- /dev/null +++ b/charts/openab/tests/imagepullsecrets_test.yaml @@ -0,0 +1,64 @@ +suite: imagePullSecrets support (chart-global + per-agent override) +templates: + - templates/deployment.yaml + +tests: + - it: does not render imagePullSecrets when neither global nor per-agent is set + asserts: + - notExists: + path: spec.template.spec.imagePullSecrets + + - it: renders chart-global imagePullSecrets when only the global value is set + set: + imagePullSecrets: + - name: regcred + asserts: + - equal: + path: spec.template.spec.imagePullSecrets + value: + - name: regcred + + - it: renders per-agent imagePullSecrets when only the per-agent value is set + set: + agents.kiro.imagePullSecrets: + - name: kiro-regcred + asserts: + - equal: + path: spec.template.spec.imagePullSecrets + value: + - name: kiro-regcred + + - it: per-agent imagePullSecrets fully overrides chart-global (no merge) + set: + imagePullSecrets: + - name: global-regcred + agents.kiro.imagePullSecrets: + - name: kiro-regcred + asserts: + - equal: + path: spec.template.spec.imagePullSecrets + value: + - name: kiro-regcred + + - it: falls back to chart-global when per-agent imagePullSecrets is an empty list + set: + imagePullSecrets: + - name: global-regcred + agents.kiro.imagePullSecrets: [] + asserts: + - equal: + path: spec.template.spec.imagePullSecrets + value: + - name: global-regcred + + - it: supports multiple secrets in the list + set: + imagePullSecrets: + - name: regcred-a + - name: regcred-b + asserts: + - equal: + path: spec.template.spec.imagePullSecrets + value: + - name: regcred-a + - name: regcred-b diff --git a/charts/openab/values.yaml b/charts/openab/values.yaml index 5a28007d9..8db333258 100644 --- a/charts/openab/values.yaml +++ b/charts/openab/values.yaml @@ -20,6 +20,14 @@ fullnameOverride: "" # serviceAccountName: "openab" serviceAccountName: "" +# Chart-global image pull secrets, used when an agent doesn't set its own +# `imagePullSecrets`. Per-agent values (agents..imagePullSecrets) take +# precedence — when set, they fully override (do not merge with) this list. +# Example: +# imagePullSecrets: +# - name: regcred +imagePullSecrets: [] + podSecurityContext: runAsNonRoot: true runAsUser: 1000 @@ -420,6 +428,12 @@ agents: # multi-agent deployments where only some agents need a dedicated SA. # serviceAccountName: "openab" serviceAccountName: "" + # Per-agent image pull secrets. When set, overrides the chart-global + # `imagePullSecrets` for this agent only. Useful in multi-agent deployments + # where only some agents pull from a private registry. + # imagePullSecrets: + # - name: regcred + imagePullSecrets: [] # extraInitContainers adds init containers to the pod (runs before the main container) extraInitContainers: [] # extraContainers adds sidecar containers to the pod From ea94efd056d352ccf86bca1e5e4dfd28a4012fd9 Mon Sep 17 00:00:00 2001 From: feiyun-agent Date: Sun, 24 May 2026 03:22:46 +0800 Subject: [PATCH 086/100] fix(discord): dedup WarnAndStop warning across multiple bot processes (#886) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * ci: add PR watch workflow — notify Discord on upstream activity * fix(discord): dedup WarnAndStop warning across multiple bot processes When N bots share a thread and all hit the soft turn limit simultaneously, each bot's per-process BotTurnTracker independently fires WarnAndStop, resulting in N duplicate warnings posted to the thread. Fix: before posting the warning, fetch the last 10 messages in the thread and skip if any bot message already contains 'Bot turn limit reached'. Fail-open: if the API call fails, the warning is still posted. Closes #530 * test(discord): add unit tests for turn_limit_warning_present dedup helper (#530) * chore: remove pr-watch.yml — unrelated to #530 fix * refactor(discord): extract BOT_TURN_LIMIT_WARNING_PREFIX constant; clarify best-effort semantics - Add BOT_TURN_LIMIT_WARNING_PREFIX const to bot_turns.rs so the dedup check and the warning text share a single source of truth - Update turn_limit_warning_present() to use the constant - Add doc comment clarifying best-effort / race-window limitation - Update tests to use the constant Addresses review feedback from internal review. * fix(bot_turns): use BOT_TURN_LIMIT_WARNING_PREFIX in hard limit message and test Ensures the constant is the true single source of truth for the warning prefix — both soft and hard limit messages now use it. Addresses 臥龍 re-review feedback. * fix(discord): use (bool, &str) tuples in turn_limit_warning_present to avoid serenity Message construction in tests Follows existing codebase convention (see format_thread_export boundary comment) of not constructing serenity::model::channel::Message in unit tests. Call site maps Message → (is_bot, content) pairs before passing to the helper. Tests now use plain tuple slices — no serde_json needed. * fix(bot_turns): remove duplicate user_message line; restore hard limit message - Remove extraneous duplicate 'user_message: format!(' in hard limit test (CI blocker — caused compilation error) - Revert hard limit message to standalone '🛑 Hard bot turn limit reached' instead of using BOT_TURN_LIMIT_WARNING_PREFIX, which produced confusing '🛑 ⚠️ Bot turn limit reached' double-emoji output (Option B per review) - BOT_TURN_LIMIT_WARNING_PREFIX remains the single source of truth for the soft limit warning and the dedup check — hard limit is rare enough that dedup is not needed there Addresses masami-agent review findings. --------- Co-authored-by: feiyun968-agent --- src/bot_turns.rs | 16 +++++++++--- src/discord.rs | 64 +++++++++++++++++++++++++++++++++++++++++++++--- 2 files changed, 73 insertions(+), 7 deletions(-) diff --git a/src/bot_turns.rs b/src/bot_turns.rs index c69971f8e..130fa717b 100644 --- a/src/bot_turns.rs +++ b/src/bot_turns.rs @@ -13,6 +13,11 @@ use std::collections::HashMap; /// between human resets. pub const HARD_BOT_TURN_LIMIT: u32 = 1000; +/// Stable prefix used in all bot turn limit warning messages. +/// Referenced by the dedup check in the Discord adapter — changing this +/// string requires updating the dedup check too. +pub const BOT_TURN_LIMIT_WARNING_PREFIX: &str = "⚠️ Bot turn limit reached"; + #[derive(Debug, PartialEq, Eq)] pub enum TurnResult { /// Counter below limits — continue normally. @@ -75,8 +80,9 @@ impl BotTurnTracker { severity: TurnSeverity::Soft, turns: n, user_message: format!( - "⚠️ Bot turn limit reached ({n}/{soft}). \ + "{} ({n}/{soft}). \ A human must reply in this thread to continue bot-to-bot conversation.", + BOT_TURN_LIMIT_WARNING_PREFIX, soft = self.soft_limit, ), }, @@ -276,9 +282,11 @@ mod tests { TurnAction::WarnAndStop { severity: TurnSeverity::Soft, turns: 3, - user_message: "⚠️ Bot turn limit reached (3/3). \ - A human must reply in this thread to continue bot-to-bot conversation." - .to_string(), + user_message: format!( + "{} (3/3). \ + A human must reply in this thread to continue bot-to-bot conversation.", + BOT_TURN_LIMIT_WARNING_PREFIX, + ), }, ); } diff --git a/src/discord.rs b/src/discord.rs index 8902c2e71..4b61c0035 100644 --- a/src/discord.rs +++ b/src/discord.rs @@ -1,7 +1,7 @@ use crate::acp::protocol::ConfigOption; use crate::acp::ContentBlock; use crate::adapter::{AdapterRouter, ChannelRef, ChatAdapter, MessageRef, SenderContext}; -use crate::bot_turns::{BotTurnTracker, TurnAction, TurnSeverity}; +use crate::bot_turns::{BotTurnTracker, TurnAction, TurnSeverity, BOT_TURN_LIMIT_WARNING_PREFIX}; use crate::config::{AllowBots, AllowUsers, SttConfig}; use crate::format; use crate::media; @@ -402,7 +402,25 @@ impl EventHandler for Handler { .bot_participated_in_thread(&ctx.http, msg.channel_id, bot_id) .await; if participated { - let _ = msg.channel_id.say(&ctx.http, &user_message).await; + // Dedup: skip if another bot already posted the same + // warning in this thread. Prevents N duplicate warnings + // when N bot processes each hit the soft limit. (#530) + let recent = msg + .channel_id + .messages( + &ctx.http, + serenity::builder::GetMessages::new().limit(10), + ) + .await + .unwrap_or_default(); + let pairs: Vec<(bool, &str)> = recent + .iter() + .map(|m| (m.author.bot, m.content.as_str())) + .collect(); + let already_warned = turn_limit_warning_present(&pairs); + if !already_warned { + let _ = msg.channel_id.say(&ctx.http, &user_message).await; + } } } return; @@ -2198,10 +2216,26 @@ fn should_process_user_message( } } +/// Returns true if any bot message in `messages` contains a turn limit warning. +/// Used to dedup `WarnAndStop` across multiple bot processes sharing a thread. (#530) +/// Note: this is best-effort — a narrow race window exists where two bots fetch +/// simultaneously and both see no warning, resulting in a duplicate. For most +/// deployments this is acceptable; strict once-only semantics would require +/// shared state (e.g. gateway-owned emission or distributed lock). +/// +/// Accepts `(is_bot, content)` pairs so the logic can be unit-tested without +/// constructing `serenity::model::channel::Message` values (see existing test +/// boundary comment at `format_thread_export`). +fn turn_limit_warning_present(messages: &[(bool, &str)]) -> bool { + messages + .iter() + .any(|(is_bot, content)| *is_bot && content.contains(BOT_TURN_LIMIT_WARNING_PREFIX)) +} + #[cfg(test)] mod tests { use super::*; - use crate::bot_turns::{TurnResult, HARD_BOT_TURN_LIMIT}; + use crate::bot_turns::{TurnResult, HARD_BOT_TURN_LIMIT, BOT_TURN_LIMIT_WARNING_PREFIX}; // --- resolve_mentions tests --- @@ -2986,4 +3020,28 @@ mod tests { fn normal_channel_creates_thread() { assert!(!should_skip_thread_creation(false, false)); } + + // --- WarnAndStop dedup tests (#530) --- + + #[test] + fn dedup_detects_existing_bot_warning() { + let msg = format!("{} (20/20). A human must reply.", BOT_TURN_LIMIT_WARNING_PREFIX); + assert!(turn_limit_warning_present(&[(true, &msg)])); + } + + #[test] + fn dedup_ignores_human_warning_text() { + let msg = format!("{} (20/20). A human must reply.", BOT_TURN_LIMIT_WARNING_PREFIX); + assert!(!turn_limit_warning_present(&[(false, &msg)])); + } + + #[test] + fn dedup_returns_false_when_no_warning() { + assert!(!turn_limit_warning_present(&[(true, "hello"), (false, "world")])); + } + + #[test] + fn dedup_returns_false_for_empty_messages() { + assert!(!turn_limit_warning_present(&[])); + } } From 2d9523372e62f8b8e842f2397bda1e4d849ad8cc Mon Sep 17 00:00:00 2001 From: chaodufashi Date: Sun, 24 May 2026 09:53:59 -0400 Subject: [PATCH 087/100] docs(codex): troubleshoot bubblewrap unavailable in sandboxed runtimes (#917) * docs(codex): add troubleshooting for bubblewrap unavailable in sandboxed runtimes When Codex runs inside an already-isolated OpenAB runtime without bubblewrap installed, its inner sandbox fails with 'bubblewrap is unavailable'. Document both resolution options: installing bwrap or disabling the inner sandbox. Closes #908 * docs(codex): add non-privileged container reminder to sandbox note * docs(codex): remove Dockerfile option, we provide the images * fix(codex): install bubblewrap in Dockerfile.codex Aligns with Dockerfile.claude which already includes bubblewrap. This resolves the 'bubblewrap is unavailable' error at runtime. --------- Co-authored-by: chaodu-agent --- Dockerfile.codex | 2 +- docs/codex.md | 40 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 41 insertions(+), 1 deletion(-) diff --git a/Dockerfile.codex b/Dockerfile.codex index cb621ef2e..32fc6a257 100644 --- a/Dockerfile.codex +++ b/Dockerfile.codex @@ -8,7 +8,7 @@ RUN touch src/main.rs && cargo build --release # --- Runtime stage --- FROM node:22-bookworm-slim -RUN apt-get update && apt-get install -y --no-install-recommends ca-certificates curl procps ripgrep tini && rm -rf /var/lib/apt/lists/* +RUN apt-get update && apt-get install -y --no-install-recommends ca-certificates curl procps ripgrep tini bubblewrap && rm -rf /var/lib/apt/lists/* # Pre-install codex-acp and codex CLI globally ARG CODEX_ACP_VERSION=0.14.0 diff --git a/docs/codex.md b/docs/codex.md index 50d1b6934..ca6e23eec 100644 --- a/docs/codex.md +++ b/docs/codex.md @@ -250,6 +250,46 @@ codex exec --dangerously-bypass-approvals-and-sandbox ... Do not use this flag on an untrusted host. +### `bubblewrap is unavailable: no system bwrap was found on PATH` + +Codex's Linux sandbox modes (read-only / workspace-write) rely on `bwrap` +(bubblewrap) to create an inner sandbox. If the runtime image does not include +bubblewrap, even basic commands like `pwd` or `ls` will fail before execution +with this error. + +This commonly happens in OpenAB deployments where Codex already runs inside an +isolated container or VM — the outer runtime provides the desired isolation, so +the inner sandbox is redundant. + +**Solution — Disable Codex's inner sandbox** (recommended when the outer OpenAB +runtime already provides isolation): + +```toml +# /home/node/.codex/config.toml +[sandbox] +sandbox_mode = "danger-full-access" +approval_policy = "on-request" +``` + +Or launch with: + +```bash +codex --sandbox danger-full-access +``` + +Or via Helm: + +```bash +helm install openab openab/openab \ + --set-json 'agents.codex.extraConfig={"sandbox":{"sandbox_mode":"danger-full-access","approval_policy":"on-request"}}' +``` + +> **Important:** `danger-full-access` disables only Codex's *inner* sandbox. It +> does **not** remove the outer OpenAB container/VM isolation. The agent remains +> confined by the runtime's own security boundary. Ensure the outer runtime is a +> non-privileged container (no `--privileged` flag or excessive capabilities) for +> this security model to hold. + ### Imagegen appears to hang Check whether an image was generated even if the CLI has not returned yet: From 15dc2cc0a9890862969b352d10f124799458e80f Mon Sep 17 00:00:00 2001 From: sebastian-hsu Date: Sun, 24 May 2026 21:54:59 +0800 Subject: [PATCH 088/100] fix(gateway/googlechat): handle HTTP endpoint URL events + correct JWT signer email (#909) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(gateway/googlechat): handle HTTP endpoint URL events + correct JWT signer email Two independent bugs prevent the Google Chat adapter from working with the connection mode recommended by docs/google-chat.md: 1. Envelope schema only deserialized the Pub/Sub-wrapped shape, but HTTP endpoint URL connections deliver top-level fields (message, user, space). All real webhooks silently dropped at `envelope.chat is None` with a 200 response. 2. JWT email allow-list expected @gcp-sa-gsuiteaddons.iam.gserviceaccount.com (Workspace Add-ons signer), but Google Chat HTTP webhooks are signed by chat@system.gserviceaccount.com. Setting GOOGLE_CHAT_AUDIENCE per docs returned 401 to every webhook. Together, following the docs produced a 100% non-working bot. This change: - Extends GoogleChatEnvelope with optional top-level fields and adds a fallback branch in the webhook handler; existing wrapped-shape tests continue to pass unchanged. - Renames GOOGLE_CHAT_EMAIL_SUFFIX → GOOGLE_CHAT_SIGNER_EMAIL, changes the value to chat@system.gserviceaccount.com, and tightens the check from `ends_with` to exact equality. - Updates the existing email-claim test to assert the new signer. Verified end-to-end on a production cc-agent deployment (1:1 DM + Space @mention) via Cloudflare Tunnel sidecar; gateway forwards events to OAB and replies arrive in Chat. Closes #899 Co-Authored-By: Claude Opus 4.7 * test: add unit test for HTTP endpoint URL top-level envelope parsing --------- Co-authored-by: Claude Opus 4.7 Co-authored-by: chaodu-agent[bot] --- gateway/src/adapters/googlechat.rs | 78 ++++++++++++++++++++++++------ 1 file changed, 62 insertions(+), 16 deletions(-) diff --git a/gateway/src/adapters/googlechat.rs b/gateway/src/adapters/googlechat.rs index 69542abf4..93c0c8f8e 100644 --- a/gateway/src/adapters/googlechat.rs +++ b/gateway/src/adapters/googlechat.rs @@ -25,11 +25,22 @@ const TEXT_FILE_COUNT_CAP: usize = 5; /// Cap on aggregate text file bytes per message (matches Discord/Slack 1 MB). const TEXT_TOTAL_CAP: u64 = 1024 * 1024; -// --- Google Chat types (v2 envelope format) --- +// --- Google Chat types --- +// +// Google Chat delivers webhooks in two shapes depending on the App's +// Connection settings in the Cloud Console: +// - HTTP endpoint URL mode: top-level fields (message, user, space, ...) +// - Pub/Sub mode: wrapped under `chat.messagePayload` +// Both are supported via the optional fields below; the handler prefers +// the wrapped form and falls back to top-level when `chat` is absent. #[derive(Debug, Deserialize)] pub struct GoogleChatEnvelope { pub chat: Option, + // HTTP endpoint URL top-level fields (used when `chat` is None) + pub message: Option, + pub user: Option, + pub space: Option, } #[derive(Debug, Deserialize)] @@ -132,20 +143,20 @@ pub struct GoogleChatSpace { const GOOGLE_CHAT_ISSUER: &str = "https://accounts.google.com"; const GOOGLE_CHAT_JWKS_URL: &str = "https://www.googleapis.com/oauth2/v3/certs"; -const GOOGLE_CHAT_EMAIL_SUFFIX: &str = "@gcp-sa-gsuiteaddons.iam.gserviceaccount.com"; +const GOOGLE_CHAT_SIGNER_EMAIL: &str = "chat@system.gserviceaccount.com"; const JWKS_CACHE_TTL: std::time::Duration = std::time::Duration::from_secs(3600); -/// Verify the JWT's `email` claim belongs to a Google Chat service account. -/// Google Chat webhooks use `service-{PROJECT_NUMBER}@gcp-sa-gsuiteaddons.iam.gserviceaccount.com`. +/// Verify the JWT's `email` claim belongs to Google Chat. +/// HTTP endpoint URL webhooks are signed by `chat@system.gserviceaccount.com`. /// Without this check, any Google-issued ID token would be accepted. fn verify_email_claim(claims: &serde_json::Value) -> Result<(), String> { let email = claims .get("email") .and_then(|v| v.as_str()) .ok_or("missing email claim")?; - if !email.ends_with(GOOGLE_CHAT_EMAIL_SUFFIX) { + if email != GOOGLE_CHAT_SIGNER_EMAIL { return Err(format!( - "email claim mismatch: expected *{GOOGLE_CHAT_EMAIL_SUFFIX}, got {email}" + "email claim mismatch: expected {GOOGLE_CHAT_SIGNER_EMAIL}, got {email}" )); } Ok(()) @@ -484,13 +495,20 @@ pub async fn webhook( } }; - let Some(chat) = envelope.chat else { - return empty_json_response(); - }; - let Some(payload) = chat.message_payload else { - return empty_json_response(); + // Try the Pub/Sub `chat`-wrapped shape first, then fall back to the + // HTTP endpoint URL top-level shape. + let (msg_opt, top_user, top_space) = if let Some(chat) = envelope.chat { + let user = chat.user; + let (msg, space) = match chat.message_payload { + Some(p) => (p.message, p.space), + None => (None, None), + }; + (msg, user, space) + } else { + (envelope.message, envelope.user, envelope.space) }; - let Some(ref msg) = payload.message else { + + let Some(ref msg) = msg_opt else { return empty_json_response(); }; @@ -507,8 +525,8 @@ pub async fn webhook( return empty_json_response(); } - let sender = msg.sender.as_ref().or(chat.user.as_ref()); - let space = msg.space.as_ref().or(payload.space.as_ref()); + let sender = msg.sender.as_ref().or(top_user.as_ref()); + let space = msg.space.as_ref().or(top_space.as_ref()); let is_bot = sender.map(|s| s.user_type == "BOT").unwrap_or(false); if is_bot { @@ -1641,8 +1659,8 @@ mod tests { } #[test] - fn email_claim_accepts_gsuite_addons_account() { - let claims = serde_json::json!({"email": "service-123456@gcp-sa-gsuiteaddons.iam.gserviceaccount.com"}); + fn email_claim_accepts_chat_system_account() { + let claims = serde_json::json!({"email": "chat@system.gserviceaccount.com"}); assert!(verify_email_claim(&claims).is_ok()); } @@ -2421,4 +2439,32 @@ mod tests { .await; assert!(result.is_none(), "oversized image must be rejected"); } + + #[test] + fn parses_http_endpoint_url_top_level_envelope() { + let envelope: GoogleChatEnvelope = serde_json::from_value(serde_json::json!({ + "message": { + "name": "spaces/AAAA/messages/BBBB", + "text": "hello", + "attachment": [] + }, + "user": { + "name": "users/123", + "displayName": "Test User", + "type": "HUMAN" + }, + "space": { + "name": "spaces/AAAA", + "type": "DM" + } + })) + .unwrap(); + assert!(envelope.chat.is_none()); + assert!(envelope.message.is_some()); + assert_eq!(envelope.message.unwrap().name, "spaces/AAAA/messages/BBBB"); + assert!(envelope.user.is_some()); + assert_eq!(envelope.user.unwrap().name, "users/123"); + assert!(envelope.space.is_some()); + assert_eq!(envelope.space.unwrap().name, "spaces/AAAA"); + } } From 7ac97d94ac0d7189d4462071df0c7f6ac3bd035c Mon Sep 17 00:00:00 2001 From: chaodufashi Date: Mon, 25 May 2026 21:03:36 -0400 Subject: [PATCH 089/100] feat(pi): support Pi coding agent via Dockerfile.pi (#920) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(pi): support Pi coding agent via Dockerfile.pi * fix(pi): install git in Dockerfile.pi * chore: fix CI matrices and Dockerfile count * docs(pi): remove standalone Pi guide * chore: remove redundant Anthropic API key env from config-reference.md * chore: correct Helm NOTES.txt instructions for Pi agent authentication * docs(pi): add docs/pi.md with advantages over other native coding agents - No auth proxy required (native subscription support like Codex/Copilot) - Minimal tool surface (4 tools) maximizes context window - Multi-model support (15+ providers, switchable mid-session) - Branching session trees for code exploration * docs(pi): add Pi agent to README.md --------- Co-authored-by: chaodu-agent Co-authored-by: 小喬 --- .github/workflows/build-operator.yml | 3 + .github/workflows/docker-smoke-test.yml | 1 + AGENTS.md | 2 +- Dockerfile.pi | 38 +++++++++ README.md | 9 +- charts/openab/templates/NOTES.txt | 4 + charts/openab/values.yaml | 22 +++++ docs/config-reference.md | 7 +- docs/pi.md | 109 ++++++++++++++++++++++++ docs/steering-design-guide.md | 5 +- 10 files changed, 192 insertions(+), 8 deletions(-) create mode 100644 Dockerfile.pi create mode 100644 docs/pi.md diff --git a/.github/workflows/build-operator.yml b/.github/workflows/build-operator.yml index 165cc58d3..1339ea824 100644 --- a/.github/workflows/build-operator.yml +++ b/.github/workflows/build-operator.yml @@ -75,6 +75,7 @@ jobs: - { suffix: "-hermes", dockerfile: "Dockerfile.hermes", artifact: "hermes" } - { suffix: "-grok", dockerfile: "Dockerfile.grok", artifact: "grok" } - { suffix: "-antigravity", dockerfile: "Dockerfile.antigravity", artifact: "antigravity" } + - { suffix: "-pi", dockerfile: "Dockerfile.pi", artifact: "pi" } platform: - { os: linux/amd64, runner: ubuntu-latest } - { os: linux/arm64, runner: ubuntu-24.04-arm } @@ -141,6 +142,7 @@ jobs: - { suffix: "-hermes", artifact: "hermes" } - { suffix: "-grok", artifact: "grok" } - { suffix: "-antigravity", artifact: "antigravity" } + - { suffix: "-pi", artifact: "pi" } runs-on: ubuntu-latest permissions: contents: read @@ -195,6 +197,7 @@ jobs: - { suffix: "-hermes" } - { suffix: "-grok" } - { suffix: "-antigravity" } + - { suffix: "-pi" } runs-on: ubuntu-latest permissions: contents: read diff --git a/.github/workflows/docker-smoke-test.yml b/.github/workflows/docker-smoke-test.yml index 62cc58c7e..da5cc2980 100644 --- a/.github/workflows/docker-smoke-test.yml +++ b/.github/workflows/docker-smoke-test.yml @@ -23,6 +23,7 @@ jobs: - { dockerfile: Dockerfile.hermes, suffix: "-hermes", agent: "hermes-acp", agent_args: "" } - { dockerfile: Dockerfile.grok, suffix: "-grok", agent: "grok", agent_args: "agent stdio" } - { dockerfile: Dockerfile.antigravity, suffix: "-antigravity", agent: "agy-acp", agent_args: "" } + - { dockerfile: Dockerfile.pi, suffix: "-pi", agent: "pi-acp", agent_args: "" } runs-on: ubuntu-latest steps: - uses: actions/checkout@v6 diff --git a/AGENTS.md b/AGENTS.md index 666c44162..b61901c68 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -75,7 +75,7 @@ Never leak `DISCORD_BOT_TOKEN` or other OAB credentials to the agent. ### 4. Dockerfile Discipline -There are 7 Dockerfiles: `Dockerfile`, `Dockerfile.claude`, `Dockerfile.codex`, `Dockerfile.copilot`, `Dockerfile.cursor`, `Dockerfile.gemini`, `Dockerfile.opencode`. +There are 11 Dockerfiles: `Dockerfile`, `Dockerfile.antigravity`, `Dockerfile.claude`, `Dockerfile.codex`, `Dockerfile.copilot`, `Dockerfile.cursor`, `Dockerfile.gemini`, `Dockerfile.grok`, `Dockerfile.hermes`, `Dockerfile.opencode`, `Dockerfile.pi`. A change to one MUST be evaluated against ALL. Common layers (base image, openab binary, tini) are shared — update all or explain why not. diff --git a/Dockerfile.pi b/Dockerfile.pi new file mode 100644 index 000000000..71268ffe8 --- /dev/null +++ b/Dockerfile.pi @@ -0,0 +1,38 @@ +# --- Build stage --- +FROM rust:1-bookworm AS builder +WORKDIR /build +COPY Cargo.toml Cargo.lock ./ +RUN mkdir src && echo 'fn main() {}' > src/main.rs && cargo build --release && rm -rf src +COPY src/ src/ +RUN touch src/main.rs && cargo build --release + +# --- Runtime stage --- +FROM node:22-bookworm-slim +RUN apt-get update && apt-get install -y --no-install-recommends ca-certificates curl git procps ripgrep tini bubblewrap socat && rm -rf /var/lib/apt/lists/* + +# Install pi-acp adapter and Pi coding agent CLI. +# The Pi coding agent npm package was renamed from @mariozechner/pi-coding-agent to @earendil-works/pi-coding-agent. +ARG PI_ACP_VERSION=0.0.27 +ARG PI_CODING_AGENT_VERSION=0.75.4 +RUN npm install -g pi-acp@${PI_ACP_VERSION} @earendil-works/pi-coding-agent@${PI_CODING_AGENT_VERSION} --retry 3 + +# Install gh CLI (matches other Dockerfiles) +RUN curl -fsSL https://cli.github.com/packages/githubcli-archive-keyring.gpg \ + -o /usr/share/keyrings/githubcli-archive-keyring.gpg && \ + echo "deb [arch=$(dpkg --print-architecture) signed-by=/usr/share/keyrings/githubcli-archive-keyring.gpg] https://cli.github.com/packages stable main" \ + > /etc/apt/sources.list.d/github-cli.list && \ + apt-get update && apt-get install -y --no-install-recommends gh && \ + rm -rf /var/lib/apt/lists/* + +ENV HOME=/home/node +WORKDIR /home/node + +COPY --from=builder --chown=node:node /build/target/release/openab /usr/local/bin/openab + +RUN mkdir -p /home/node/.pi && chown -R node:node /home/node/.pi + +USER node +HEALTHCHECK --interval=30s --timeout=5s --retries=3 \ + CMD pgrep -x openab || exit 1 +ENTRYPOINT ["tini", "--"] +CMD ["openab", "run", "-c", "/etc/openab/config.toml"] diff --git a/README.md b/README.md index 73da28f2f..a8afbf515 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ ![OpenAB banner](images/banner.jpg) -A lightweight, secure, cloud-native ACP harness that bridges **Discord, Slack**, and any [Agent Client Protocol](https://github.com/anthropics/agent-protocol)-compatible coding CLI (Kiro CLI, Claude Code, Codex, Gemini, OpenCode, Copilot CLI, Hermes, Grok Build, Antigravity, etc.) over stdio JSON-RPC — delivering the next-generation development experience. **Telegram, LINE, Feishu/Lark, Google Chat**, and other webhook-based platforms are supported via the standalone [Custom Gateway](gateway/). +A lightweight, secure, cloud-native ACP harness that bridges **Discord, Slack**, and any [Agent Client Protocol](https://github.com/anthropics/agent-protocol)-compatible coding CLI (Kiro CLI, Claude Code, Codex, Gemini, OpenCode, Copilot CLI, Hermes, Grok Build, Antigravity, Pi, etc.) over stdio JSON-RPC — delivering the next-generation development experience. **Telegram, LINE, Feishu/Lark, Google Chat**, and other webhook-based platforms are supported via the standalone [Custom Gateway](gateway/). 🪼 **Join our community!** Come say hi on Discord — we'd love to have you: **[🪼 OpenAB — Official](https://discord.gg/DmbhfDZjQS)** 🎉 @@ -22,8 +22,8 @@ A lightweight, secure, cloud-native ACP harness that bridges **Discord, Slack**, │ LINE │◄──webhook──┌──────────────────┐ │ opencode acp │ │ User │ │ Custom Gateway │ │ grok agent stdio │ ├──────────────┤ │ (standalone) │ │ agy-acp │ -│ Feishu/Lark │◄───WS──────│ │ └──────────────────┘ -│ User │ │ │ +│ Feishu/Lark │◄───WS──────│ │ │ pi-acp │ +│ User │ │ │ └──────────────────┘ ├──────────────┤ │ │ │ Google Chat │◄──webhook──│ │ │ User │ └──────────────────┘ @@ -38,7 +38,7 @@ A lightweight, secure, cloud-native ACP harness that bridges **Discord, Slack**, - **Multi-platform** — supports Discord and Slack, run one or both simultaneously - **Custom Gateway** — extend to Telegram, LINE, Feishu/Lark, Google Chat, MS Teams via standalone [gateway](gateway/) -- **Pluggable agent backend** — swap between Kiro CLI, Claude Code, Codex, Gemini, OpenCode, Copilot CLI, Hermes, Grok Build, Antigravity via config +- **Pluggable agent backend** — swap between Kiro CLI, Claude Code, Codex, Gemini, OpenCode, Copilot CLI, Hermes, Grok Build, Antigravity, Pi via config - **@mention trigger** — mention the bot in an allowed channel to start a conversation - **Thread-based multi-turn** — auto-creates threads; no @mention needed for follow-ups - **Multi-agent collaboration** — bot-to-bot messaging for coordinated workflows ([docs/multi-agent.md](docs/multi-agent.md)) @@ -171,6 +171,7 @@ The bot creates a thread. After that, just type in the thread — no @mention ne | Hermes Agent | `hermes-acp` | Native | [docs/hermes.md](docs/hermes.md) | | Grok Build | `grok agent stdio` | Native | [docs/grok.md](docs/grok.md) | | Antigravity | `agy-acp` | [agy-acp](agy-acp/) | [docs/antigravity.md](docs/antigravity.md) | +| Pi | `pi-acp` | [pi-acp](https://www.npmjs.com/package/pi-acp) | [docs/pi.md](docs/pi.md) | > 🔧 Running multiple agents? See [docs/multi-agent.md](docs/multi-agent.md) diff --git a/charts/openab/templates/NOTES.txt b/charts/openab/templates/NOTES.txt index 89b01ef65..c18989a07 100644 --- a/charts/openab/templates/NOTES.txt +++ b/charts/openab/templates/NOTES.txt @@ -39,6 +39,10 @@ Agents deployed: {{- else if eq (toString $cfg.command) "opencode" }} Authenticate: kubectl exec -it deployment/{{ include "openab.agentFullname" (dict "ctx" $ "agent" $name) }} -- opencode auth login +{{- else if eq (toString $cfg.command) "pi-acp" }} + Authenticate: + kubectl exec -it deployment/{{ include "openab.agentFullname" (dict "ctx" $ "agent" $name) }} -- pi + (Once inside the interactive interface, type `/login` to authenticate) {{- else if eq (toString $cfg.command) "cursor-agent" }} Authenticate: kubectl exec -it deployment/{{ include "openab.agentFullname" (dict "ctx" $ "agent" $name) }} -- cursor-agent login diff --git a/charts/openab/values.yaml b/charts/openab/values.yaml index 8db333258..049e7df35 100644 --- a/charts/openab/values.yaml +++ b/charts/openab/values.yaml @@ -126,6 +126,28 @@ agents: # agentsMd: "" # resources: {} # image: "ghcr.io/openabdev/openab-opencode:latest" + # pi: + # command: pi-acp + # discord: + # enabled: true + # allowedChannels: + # - "YOUR_CHANNEL_ID" + # allowedUsers: [] + # workingDir: /home/node + # env: {} + # envFrom: [] + # secretEnv: [] + # pool: + # maxSessions: 10 + # sessionTtlHours: 24 + # reactions: + # enabled: true + # removeAfterReply: false + # persistence: + # enabled: true + # storageClass: "" + # size: 1Gi + # image: "ghcr.io/openabdev/openab-pi:latest" # cursor: # command: cursor-agent # args: diff --git a/docs/config-reference.md b/docs/config-reference.md index 520d69987..e93e48314 100644 --- a/docs/config-reference.md +++ b/docs/config-reference.md @@ -95,7 +95,7 @@ The AI agent subprocess that OpenAB spawns to handle messages via ACP. | Key | Type | Default | Description | |-----|------|---------|-------------| -| `command` | string | *required* | Agent binary (e.g. `kiro-cli`, `claude-agent-acp`, `codex`, `gemini`, `copilot`, `opencode`, `cursor-agent`). | +| `command` | string | *required* | Agent binary (e.g. `kiro-cli`, `claude-agent-acp`, `codex`, `gemini`, `copilot`, `opencode`, `pi-acp`, `cursor-agent`). | | `args` | string[] | `[]` | CLI arguments passed to the agent. | | `working_dir` | string | `"/tmp"` | Working directory for the agent process. | | `env` | map | `{}` | Extra environment variables (e.g. `{ OPENAI_API_KEY = "${OPENAI_API_KEY}" }`). | @@ -146,6 +146,11 @@ command = "opencode" args = ["acp"] working_dir = "/home/node" +# Pi Agent +[agent] +command = "pi-acp" +working_dir = "/home/node" + # Cursor Agent [agent] command = "cursor-agent" diff --git a/docs/pi.md b/docs/pi.md new file mode 100644 index 000000000..3ff4a19c8 --- /dev/null +++ b/docs/pi.md @@ -0,0 +1,109 @@ +# Pi Coding Agent + +OpenAB supports the [Pi coding agent](https://github.com/earendil-works/pi-coding-agent) via the `pi-acp` adapter — a Node.js bridge that translates ACP JSON-RPC into Pi CLI invocations. + +## Advantages Over Other Native Coding Agents + +Pi is a native coding agent that supports subscription-based authentication (like Codex, Cloud Code, and GitHub Copilot). Key advantages: + +### No Auth Proxy Required + +Pi natively supports Anthropic (Claude Pro/Max) and ChatGPT Plus/Pro subscriptions via OAuth. Unlike agents that require an `openab-auth-proxy` sidecar for subscription forwarding, Pi handles subscription auth directly — reducing deployment complexity and eliminating a moving part. + +| Agent | Subscription Auth | Auth Proxy Needed? | +|-------|------------------|--------------------| +| Pi | Native OAuth (`pi /login`) | ❌ No | +| Codex | Native device flow | ❌ No | +| GitHub Copilot | Native device flow | ❌ No | +| Claude Code | Native OAuth | ❌ No | +| Kiro | Native OAuth | ❌ No | + +### Minimal Tool Surface (Maximum Context Window) + +Pi exposes only 4 core tools: `read`, `write`, `edit`, `bash`. Combined with a tiny system prompt, this drastically reduces prompt overhead and maximizes the available context window for actual project source files. + +| Agent | Tool Count | System Prompt Size | +|-------|-----------|-------------------| +| Pi | 4 | Minimal | +| Claude Code | 10+ | Large | +| Codex | 8+ | Medium | +| Copilot | 10+ | Large | + +### Multi-Model Support + +Pi is model-agnostic and supports 15+ LLM providers. Developers can switch models mid-session without restarting the agent or changing configuration. + +Supported providers include: +- Anthropic (Claude) — via subscription or API key +- OpenAI (GPT/Codex) — via subscription or API key +- Google (Gemini) — via API key +- Any OpenAI-compatible endpoint + +### Branching Session Trees + +Pi saves session history as trees, enabling clean branching of code exploration. This allows developers to explore multiple approaches from a single decision point without losing context. + +## Configuration + +```toml +[agent] +command = "pi-acp" +working_dir = "/home/node" +``` + +## Docker + +```bash +docker build -f Dockerfile.pi -t openab-pi:latest . +``` + +## Helm + +```yaml +agents: + pi: + discord: + enabled: true + allowedChannels: + - "YOUR_CHANNEL_ID" + command: pi-acp + workingDir: /home/node + image: "ghcr.io/openabdev/openab-pi:latest" +``` + +## Authentication + +```bash +kubectl exec -it deployment/openab-pi -- pi +# Once inside the interactive interface, type /login to authenticate +``` + +Supported authentication methods: + +| Provider | Auth Method | Subscription | +|----------|-------------|-------------| +| Anthropic (Claude Pro/Max) | OAuth via `pi /login` | Claude subscription | +| ChatGPT Plus/Pro | OAuth via `pi /login` | ChatGPT subscription | +| Any API key provider | `env = { OPENAI_API_KEY = "..." }` | Pay-per-token | + +## Steering Files + +Pi reads steering files in this order: + +1. `.pi/SYSTEM.md` — replaces the default system prompt entirely +2. `.pi/APPEND_SYSTEM.md` — appends to the default system prompt +3. `AGENTS.md` — loaded hierarchically (project root → global) for context injection + +Place your steering instructions in `/home/node/AGENTS.md` or `/home/node/.pi/APPEND_SYSTEM.md`. + +## Persisted Paths (PVC) + +| Path | Contents | +|------|----------| +| `/home/node/.pi/` | Pi configuration and auth tokens | +| `/home/node/.pi/sessions/` | Session history trees | + +## Limitations + +- **No streaming**: `pi-acp` returns the full response at once; streamed output is sent as a single `agent_message_chunk` notification. +- **Cancel is best-effort**: Pi CLI runs to completion; `session/cancel` may not interrupt mid-generation. diff --git a/docs/steering-design-guide.md b/docs/steering-design-guide.md index 2b63fdb35..aed5b13ed 100644 --- a/docs/steering-design-guide.md +++ b/docs/steering-design-guide.md @@ -10,13 +10,13 @@ AI coding agents load persistent instructions every session, but without deliber This guide establishes a universal framework for organizing agent memory into layers, so rules are reliably followed, context budgets are respected, and teams can onboard new agents without starting from scratch. -OpenAB is designed to be agent-agnostic — it supports Kiro, Claude Code, Codex, Gemini, Copilot, and OpenCode running side by side. This guide provides a shared memory architecture standard that allows all supported coding agents to maintain consistent behavior, collaborate effectively, and operate from a single source of truth regardless of their underlying platform differences. +OpenAB is designed to be agent-agnostic — it supports Kiro, Claude Code, Codex, Gemini, Copilot, OpenCode, and Pi running side by side. This guide provides a shared memory architecture standard that allows all supported coding agents to maintain consistent behavior, collaborate effectively, and operate from a single source of truth regardless of their underlying platform differences. --- How to organize AI agent memory across three tiers: hot (always loaded), warm (triggered on demand), and cold (searched when needed). -Applies to: Kiro, Claude Code, Codex, Gemini, Copilot, OpenCode — any agent that supports persistent instruction files. +Applies to: Kiro, Claude Code, Codex, Gemini, Copilot, OpenCode, Pi — any agent that supports persistent instruction files. --- @@ -165,6 +165,7 @@ Applies to: Kiro, Claude Code, Codex, Gemini, Copilot, OpenCode — any agent th | Gemini | `GEMINI.md` hierarchical (`~/.gemini/GEMINI.md` global → `./GEMINI.md` project → subdir) + `MEMORY.md` index | Same hierarchical pattern as CC/Codex. Private project memory index is hot; individual memory files are cold | | Copilot | `.github/copilot-instructions.md` (repo-wide) + `.github/instructions/**/*.instructions.md` (path-specific) + `AGENTS.md` (nearest-in-tree, where supported: cloud agent / CLI) | Layered: Personal > Path-specific > Repo-wide > Agent > Organization. No documented hard size cap for Chat/Agent (code review reads first 4K chars only). Keep short (~2 pages recommended) | | OpenCode | `AGENTS.md` or equivalent | Follows repo convention | +| Pi | `AGENTS.md` hierarchical (project root → global) + `SYSTEM.md` or `APPEND_SYSTEM.md` in `.pi/` | Project or global `SYSTEM.md` replaces the default system prompt, while `APPEND_SYSTEM.md` appends to it. `AGENTS.md` is loaded hierarchically for context injection | --- From b57d8cb7efdb788cbaa951de54c065891a1ba985 Mon Sep 17 00:00:00 2001 From: "openab-app[bot]" <274185012+openab-app[bot]@users.noreply.github.com> Date: Mon, 25 May 2026 21:50:49 -0400 Subject: [PATCH 090/100] release: v0.8.4-beta.4 (#921) Co-authored-by: openab-app[bot] <274185012+openab-app[bot]@users.noreply.github.com> --- charts/openab/Chart.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/charts/openab/Chart.yaml b/charts/openab/Chart.yaml index d7db053ba..8d5780985 100644 --- a/charts/openab/Chart.yaml +++ b/charts/openab/Chart.yaml @@ -2,5 +2,5 @@ apiVersion: v2 name: openab description: A lightweight, secure, cloud-native ACP harness that bridges Discord and any ACP-compatible coding CLI. type: application -version: 0.8.4-beta.3 -appVersion: "0.8.4-beta.3" +version: 0.8.4-beta.4 +appVersion: "0.8.4-beta.4" From 6b880e798158a2293e7ca991042a8255707217b9 Mon Sep 17 00:00:00 2001 From: chaodufashi Date: Mon, 25 May 2026 23:44:37 -0400 Subject: [PATCH 091/100] fix(pi): bump pi-coding-agent to 0.75.5 (#923) --- Dockerfile.pi | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Dockerfile.pi b/Dockerfile.pi index 71268ffe8..5e9f3bba9 100644 --- a/Dockerfile.pi +++ b/Dockerfile.pi @@ -13,7 +13,7 @@ RUN apt-get update && apt-get install -y --no-install-recommends ca-certificates # Install pi-acp adapter and Pi coding agent CLI. # The Pi coding agent npm package was renamed from @mariozechner/pi-coding-agent to @earendil-works/pi-coding-agent. ARG PI_ACP_VERSION=0.0.27 -ARG PI_CODING_AGENT_VERSION=0.75.4 +ARG PI_CODING_AGENT_VERSION=0.75.5 RUN npm install -g pi-acp@${PI_ACP_VERSION} @earendil-works/pi-coding-agent@${PI_CODING_AGENT_VERSION} --retry 3 # Install gh CLI (matches other Dockerfiles) From 627808bdf612eef2dbf89fd4bdef5090d216f9a7 Mon Sep 17 00:00:00 2001 From: chaodufashi Date: Tue, 26 May 2026 09:14:56 -0400 Subject: [PATCH 092/100] ci: replace dorny/paths-filter with native git diff (#926) dorny's GitHub account has been suspended, breaking the paths-filter action for all users. Replace with a simple git diff + grep approach that has zero third-party dependencies. Functionally equivalent: detects changes in src/, gateway/, operator/ and conditionally runs the corresponding CI jobs. Co-authored-by: chaodu-agent --- .github/workflows/ci.yml | 20 +++++++++----------- 1 file changed, 9 insertions(+), 11 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 872ebe260..4a2e000e3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -22,18 +22,16 @@ jobs: operator: ${{ steps.filter.outputs.operator }} steps: - uses: actions/checkout@v6 - - uses: dorny/paths-filter@v3 - id: filter with: - filters: | - core: - - 'src/**' - - 'Cargo.toml' - - 'Cargo.lock' - gateway: - - 'gateway/**' - operator: - - 'operator/**' + fetch-depth: 0 + - id: filter + run: | + BASE=${{ github.event.pull_request.base.sha }} + HEAD=${{ github.event.pull_request.head.sha }} + CHANGED=$(git diff --name-only "$BASE" "$HEAD") + echo "core=$(echo "$CHANGED" | grep -qE '^(src/|Cargo\.(toml|lock))' && echo true || echo false)" >> "$GITHUB_OUTPUT" + echo "gateway=$(echo "$CHANGED" | grep -q '^gateway/' && echo true || echo false)" >> "$GITHUB_OUTPUT" + echo "operator=$(echo "$CHANGED" | grep -q '^operator/' && echo true || echo false)" >> "$GITHUB_OUTPUT" check: needs: changes From 6610efde744930cbcc57c0fc5cdfceccaacf42c8 Mon Sep 17 00:00:00 2001 From: chaodufashi Date: Tue, 26 May 2026 17:39:33 -0400 Subject: [PATCH 093/100] =?UTF-8?q?feat:=20openab-agent=20=E2=80=94=20nati?= =?UTF-8?q?ve=20Rust=20coding=20agent=20with=20subscription=20auth=20(#924?= =?UTF-8?q?)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: openab-agent v0.1 — native Rust coding agent with ACP Implements the v0.1 scope from ADR (PR #922): - ACP layer: stdin/stdout JSON-RPC (initialize, session/new, session/prompt, session/cancel) - LLM client: Anthropic provider with non-streaming API (BoxStream deferred to v0.2) - 4 tools: read, write, edit, bash - Path traversal protection (canonicalize + boundary check) - Environment variable filtering (allow-list only) - Process group kill on timeout (setsid + kill(-pgid)) - Agent core: system prompt + tool dispatch loop (max 50 iterations) - Unit tests: hand-written MockLlmProvider, tests for prompt assembly, tool dispatch, error handling, and multi-step tool chains - Flat session (session tree deferred to v0.2) Architecture: openab ──stdio JSON-RPC──► openab-agent ──HTTP──► Anthropic API No external runtime required. Single binary, ~20MB when compiled. * fix: address review findings from 覺渡 (PR #924) - Fix validate_path: remove create_dir_all side-effect that could create directories outside working_dir before boundary check. Now uses parent traversal to find nearest existing ancestor without modifying filesystem. - Fix unit test isolation: mark all FS/process tests with #[ignore] per Unit Test Strategy ADR. Only pure logic tests run by default. - Fix unsafe setsid: check return value, propagate error on failure. - Fix OPENAB_AGENT_BASH_ENV_ALLOW: read comma-separated env var and pass allowed keys to build_env for subprocess inheritance. - Fix streaming capability: set to false in ACP initialize response since v0.1 uses non-streaming Anthropic Messages API. * fix: address review findings from 普渡 (PR #924) - 🔴 F1: Add context window truncation (MAX_CONTEXT_MESSAGES=100), drops oldest messages preserving first user prompt - 🟡 F3: Return error when MAX_TOOL_LOOPS exhausted instead of empty string - 🟡 F5: Fail-fast on missing/empty ANTHROPIC_API_KEY at session creation - 🟡 F6: Add retry with exponential backoff for 429/529 (max 3 retries) - 🟡 F7: Await child process after SIGKILL to prevent zombies - 🟡 F8: Add TODO(v0.2) for session/cancel implementation - 🟡 F9: Validate empty prompt, return -32602 error - 🟡 F10: Add TODO(v0.2) for session TTL/cleanup - 🟡 F11: Remove std::env::set_var from tests (UB in multi-thread) * fix: truncate_context pair-drain + test_session_new CI fix - N1: truncate_context now drains in pairs (assistant+user) to maintain strict role alternation required by Anthropic API - N2: test_session_new sets fake ANTHROPIC_API_KEY for CI environments; added test_session_new_missing_key for error case * feat: add subscription auth via OAuth device flow Add support for Codex/OpenAI subscription authentication: CLI: openab-agent auth codex-oauth # device flow login openab-agent auth status # show stored credentials openab-agent # ACP mode (default) Auth flow: - Device code flow against OpenAI auth endpoints - Prints verification URL + user code for headless environments - Polls until user authorizes in browser - Stores tokens at ~/.openab/agent/auth.json (0600 perms) - Auto-refreshes expired tokens with 120s skew Provider fallback in ACP mode: 1. ANTHROPIC_API_KEY env var → Anthropic provider 2. ~/.openab/agent/auth.json → OpenAI provider (subscription) 3. Error with instructions if neither available OpenAI provider: - Full OpenAI chat completions API format - Tool call translation (Anthropic format ↔ OpenAI format) - Retry with exponential backoff on 429/529 * fix: address auth review findings from 覺渡 - 🔴 save_tokens permission race: use OpenOptions::mode(0o600) to create file with correct permissions atomically (no window for exposure) - 🔴 OPENAB_AGENT_PROVIDER ignored: respect env var to force provider selection (anthropic|openai|codex), auto-detect only when unset - 🟡 Token masking: safe display for any token length (>12 chars shows first 8 + last 4, otherwise shows ****) * fix: address 普渡 findings F2-F8 (auth + OpenAI provider) - F2: Add OPENAB_AGENT_OPENAI_MODEL + OPENAB_AGENT_OPENAI_BASE_URL env vars so OpenAI and Anthropic model namespaces don't conflict - F3: Increase poll interval on 'slow_down' per RFC 8628 Section 3.5 - F4: Add 10-minute wall-clock timeout to device flow polling loop - F5: Enforce minimum 5s polling interval regardless of server response - F6: Retry on 401 with token refresh; fetch fresh token each attempt - F8: Token masking already fixed in previous commit F1 (client_id) and F9 (scope) pending 主人 decision. * fix: make OAuth client_id configurable (F1) Default: 'app_scp_codex_prod_001' (same as Codex CLI, public client) Override: OPENAB_AGENT_OAUTH_CLIENT_ID env var This allows users to register their own OAuth app if needed, while maintaining compatibility with existing Codex subscriptions by default. * fix: address remaining 普渡 findings F6-F8 - F6: Add force_refresh() that bypasses expiry check; 401 handler now calls force_refresh() to guarantee a new token on next retry - F7: Add unit tests for parse_openai_response (text, tool_call, empty) and auth module (is_expired, auth_path, codex_client_id) - F8: Token masking confirmed already fixed (len>12 check at line 244) * ci: add CI workflow + Dockerfile for openab-agent CI (.github/workflows/ci-openab-agent.yml): - cargo fmt --check - cargo clippy -- -D warnings - cargo test (unit tests) - cargo test -- --ignored (integration tests) Dockerfile (Dockerfile.openab-agent): - Multi-stage build: rust:1-bookworm → distroless/cc-debian12 - Final image ~20MB, runs as nonroot - Entrypoint: openab-agent (ACP mode by default) * fix: CI compilation errors - Fix borrow-after-move: capture child pid before wait_with_output - Fix unused import: restructure cfg(unix) block for pre_exec - Fix unused variable: remove dead content Vec in OpenAiProvider - Fix extra closing brace * fix: resolve all clippy warnings - Remove unused imports (anyhow::Result, serde_json::Value, Path) - Mark Agent::new as #[cfg(test)] (only used in tests) - Allow dead_code on LlmEvent::Error variant (reserved for future use) - Move CommandExt import to top-level #[cfg(unix)] - Remove unnecessary mut on child variable - Collapse if-in-match per clippy suggestion * fix: CI errors — cannot move in pattern guard + unused import - Revert collapsed match (cannot move l in guard), add clippy allow - Move CommandExt import inside unsafe block with allow(unused_imports) * fix(openab-agent): correct device code auth flow for OpenAI - Use https://auth.openai.com/api/accounts/deviceauth/usercode endpoint - Use server-provided code_verifier (not client PKCE) - Token exchange at /oauth/token with redirect_uri=https://auth.openai.com/deviceauth/callback - Handle OpenAI's nested error format (error.code) - Correct client_id: app_EMoamEEZ73f0CkXaXp7hrann * refactor(openab-agent): remove unused PKCE deps (base64, sha2, getrandom) * refactor(openab-agent): remove unused PKCE deps from Cargo.toml * ci: add ACP smoke test — verify binary starts and speaks ACP Sends an 'initialize' JSON-RPC request to the built binary and verifies the response contains valid ACP agentInfo. This catches protocol-level regressions without needing LLM credentials. * style: cargo fmt * fix: remove unused constants (CODEX_SCOPES, CODEX_AUDIENCE) * feat(openab-agent): browser PKCE + device code auth flows * feat(openab-agent): switch to Responses API (chatgpt.com/backend-api/codex/responses) * feat(openab-agent): add codex-device subcommand + --no-browser flag * fix(openab-agent): test cleanup for OAuth auth * feat(openab-agent): add deps for browser PKCE flow (base64, sha2, getrandom, urlencoding, open, url) * docs(openab-agent): guide headless users through copy-paste callback flow * feat(openab-agent): paste-based callback for headless auth + simplified flow params * fix(openab-agent): stop tool loop when text response received * feat(openab-agent): Responses API with SSE stream parsing * fix(openab-agent): correct Responses API input format for tool results (function_call_output) * feat(openab-agent): read AGENTS.md from cwd as custom system prompt * feat: add Dockerfile.openab-agent — native Rust agent (~20MB image) * rename: Dockerfile.openab-agent → Dockerfile.native * rename: remove old Dockerfile.openab-agent * docs: add native-agent.md * docs(README): add Native Agent to Other Agents table * docs(native-agent): clarify AGENTS.md support, note Skills not yet supported * docs(native-agent): note MCP not yet supported * docs(native-agent): note MCP not yet supported * style: cargo fmt * style: cargo fmt * style: cargo fmt * style: cargo fmt * fix: resolve clippy warnings (dead_code, is_multiple_of) --------- Co-authored-by: chaodu-agent Co-authored-by: thepagent --- .github/workflows/ci-openab-agent.yml | 39 + Dockerfile.native | 35 + README.md | 1 + docs/native-agent.md | 121 ++ openab-agent/.gitignore | 1 + openab-agent/Cargo.lock | 1960 +++++++++++++++++++++++++ openab-agent/Cargo.toml | 29 + openab-agent/src/acp.rs | 289 ++++ openab-agent/src/agent.rs | 334 +++++ openab-agent/src/auth.rs | 538 +++++++ openab-agent/src/llm.rs | 644 ++++++++ openab-agent/src/main.rs | 73 + openab-agent/src/tools.rs | 435 ++++++ 13 files changed, 4499 insertions(+) create mode 100644 .github/workflows/ci-openab-agent.yml create mode 100644 Dockerfile.native create mode 100644 docs/native-agent.md create mode 100644 openab-agent/.gitignore create mode 100644 openab-agent/Cargo.lock create mode 100644 openab-agent/Cargo.toml create mode 100644 openab-agent/src/acp.rs create mode 100644 openab-agent/src/agent.rs create mode 100644 openab-agent/src/auth.rs create mode 100644 openab-agent/src/llm.rs create mode 100644 openab-agent/src/main.rs create mode 100644 openab-agent/src/tools.rs diff --git a/.github/workflows/ci-openab-agent.yml b/.github/workflows/ci-openab-agent.yml new file mode 100644 index 000000000..c0d5a3727 --- /dev/null +++ b/.github/workflows/ci-openab-agent.yml @@ -0,0 +1,39 @@ +name: CI openab-agent + +on: + push: + paths: + - 'openab-agent/**' + pull_request: + paths: + - 'openab-agent/**' + +jobs: + check: + runs-on: ubuntu-latest + defaults: + run: + working-directory: openab-agent + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + with: + components: rustfmt, clippy + - uses: Swatinem/rust-cache@v2 + with: + workspaces: openab-agent + - run: cargo fmt --check + - run: cargo clippy -- -D warnings + - run: cargo test + - run: cargo test -- --ignored + env: + ANTHROPIC_API_KEY: "fake-key-for-ci" + - name: ACP smoke test + run: | + cargo build --release + # Test: initialize returns valid ACP response + RESP=$(echo '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{}}' | timeout 5 ./target/release/openab-agent 2>/dev/null | head -1) + echo "Response: $RESP" + echo "$RESP" | grep -q '"agentInfo"' || (echo "FAIL: no agentInfo in response" && exit 1) + echo "$RESP" | grep -q '"openab-agent"' || (echo "FAIL: wrong agent name" && exit 1) + echo "✅ ACP initialize OK" diff --git a/Dockerfile.native b/Dockerfile.native new file mode 100644 index 000000000..bcd78acd8 --- /dev/null +++ b/Dockerfile.native @@ -0,0 +1,35 @@ +# --- Build stage --- +FROM rust:1-bookworm AS builder +WORKDIR /build +COPY Cargo.toml Cargo.lock ./ +RUN mkdir src && echo 'fn main() {}' > src/main.rs && cargo build --release && rm -rf src +COPY src/ src/ +COPY openab-agent/ openab-agent/ +RUN touch src/main.rs && cargo build --release --bin openab --bin openab-agent + +# --- Runtime stage --- +FROM debian:bookworm-slim +RUN apt-get update && apt-get install -y --no-install-recommends ca-certificates curl git procps ripgrep tini && rm -rf /var/lib/apt/lists/* + +# Install gh CLI +RUN curl -fsSL https://cli.github.com/packages/githubcli-archive-keyring.gpg \ + -o /usr/share/keyrings/githubcli-archive-keyring.gpg && \ + echo "deb [arch=$(dpkg --print-architecture) signed-by=/usr/share/keyrings/githubcli-archive-keyring.gpg] https://cli.github.com/packages stable main" \ + > /etc/apt/sources.list.d/github-cli.list && \ + apt-get update && apt-get install -y --no-install-recommends gh && \ + rm -rf /var/lib/apt/lists/* + +RUN useradd -m -s /bin/bash -u 1000 agent +ENV HOME=/home/agent +WORKDIR /home/agent + +COPY --from=builder --chown=agent:agent /build/target/release/openab /usr/local/bin/openab +COPY --from=builder --chown=agent:agent /build/target/release/openab-agent /usr/local/bin/openab-agent + +RUN mkdir -p /home/agent/.openab && chown -R agent:agent /home/agent + +USER agent +HEALTHCHECK --interval=30s --timeout=5s --retries=3 \ + CMD pgrep -x openab || exit 1 +ENTRYPOINT ["tini", "--"] +CMD ["openab", "run", "-c", "/etc/openab/config.toml"] diff --git a/README.md b/README.md index a8afbf515..406e7fc91 100644 --- a/README.md +++ b/README.md @@ -172,6 +172,7 @@ The bot creates a thread. After that, just type in the thread — no @mention ne | Grok Build | `grok agent stdio` | Native | [docs/grok.md](docs/grok.md) | | Antigravity | `agy-acp` | [agy-acp](agy-acp/) | [docs/antigravity.md](docs/antigravity.md) | | Pi | `pi-acp` | [pi-acp](https://www.npmjs.com/package/pi-acp) | [docs/pi.md](docs/pi.md) | +| **Native Agent** | `openab-agent` | Built-in (Rust) | [docs/native-agent.md](docs/native-agent.md) | > 🔧 Running multiple agents? See [docs/multi-agent.md](docs/multi-agent.md) diff --git a/docs/native-agent.md b/docs/native-agent.md new file mode 100644 index 000000000..b3baeedfb --- /dev/null +++ b/docs/native-agent.md @@ -0,0 +1,121 @@ +# Native Agent (openab-agent) + +A lightweight, native Rust coding agent with built-in ACP support and ChatGPT subscription authentication. No Node.js, no Python, no adapter layer. + +## Quick Start + +```bash +# Build +cd openab-agent && cargo build --release + +# Authenticate (browser flow — recommended) +openab-agent auth codex-oauth + +# Headless server (paste callback URL) +openab-agent auth codex-oauth --no-browser + +# Run as ACP server (used by openab core) +openab-agent +``` + +## Configuration + +```toml +[agent] +command = "openab-agent" +working_dir = "/home/agent" +env = { OPENAB_AGENT_OPENAI_MODEL = "gpt-5.4-mini" } +``` + +## Environment Variables + +| Variable | Default | Description | +|----------|---------|-------------| +| `OPENAB_AGENT_OPENAI_MODEL` | `gpt-4.1-nano` | Model to use | +| `OPENAB_AGENT_OPENAI_BASE_URL` | `https://chatgpt.com/backend-api` | API base URL | +| `OPENAB_AGENT_PROVIDER` | auto-detect | Force provider (`anthropic`, `openai`, `codex`) | +| `OPENAB_AGENT_MAX_TOKENS` | `8192` | Max output tokens | +| `OPENAB_AGENT_OAUTH_CLIENT_ID` | Pi's client | Custom OAuth client ID | +| `ANTHROPIC_API_KEY` | — | Anthropic API key (alternative to OAuth) | + +## Authentication + +### Browser PKCE Flow (recommended) + +```bash +openab-agent auth codex-oauth +``` + +Opens browser to authenticate with your ChatGPT Plus/Pro subscription. + +### Headless Server (paste flow) + +```bash +openab-agent auth codex-oauth --no-browser +``` + +1. Prints an authorization URL +2. Open it in any browser and approve +3. Browser redirects to `localhost:1455` (fails on remote server) +4. Copy the full URL from the browser address bar +5. Paste it back into the terminal + +### Device Code Flow + +```bash +openab-agent auth codex-device +``` + +Note: Device flow currently has limited scopes and may not work with all models. + +### API Key (Anthropic) + +```bash +export ANTHROPIC_API_KEY=sk-ant-... +``` + +No login needed — set the env var and the agent auto-detects it. + +## Custom System Prompt + +Place an `AGENTS.md` file in the working directory (`cwd`). It will be prepended to the default system prompt at session creation. + +``` +/home/agent/ +├── AGENTS.md ← read at session start +├── .openab/ +│ └── agent/ +│ └── auth.json +└── (your project files) +``` + +> **Note:** Skills and MCP servers are NOT supported yet. Only `AGENTS.md` at `cwd` is read. Skills and MCP support are planned for v0.2. + +## Docker + +```bash +docker build -f Dockerfile.native -t openab-native:latest . +``` + +Image is ~20MB (debian-slim + static Rust binaries). No runtime dependencies. + +## Memory Usage + +~7MB per session — 28x lighter than Pi, 55x lighter than Kiro CLI. + +## Supported Models (ChatGPT Subscription) + +- `gpt-5.2` +- `gpt-5.3-codex` +- `gpt-5.3-codex-spark` +- `gpt-5.4` +- `gpt-5.4-mini` +- `gpt-5.5` + +## Tools + +4 built-in tools: +- `read` — file contents or directory listing +- `write` — create/overwrite file +- `edit` — string replacement +- `bash` — shell execution with process group isolation diff --git a/openab-agent/.gitignore b/openab-agent/.gitignore new file mode 100644 index 000000000..ea8c4bf7f --- /dev/null +++ b/openab-agent/.gitignore @@ -0,0 +1 @@ +/target diff --git a/openab-agent/Cargo.lock b/openab-agent/Cargo.lock new file mode 100644 index 000000000..5f878017f --- /dev/null +++ b/openab-agent/Cargo.lock @@ -0,0 +1,1960 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + +[[package]] +name = "anstream" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" + +[[package]] +name = "anstyle-parse" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys 0.61.2", +] + +[[package]] +name = "anyhow" +version = "1.0.102" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "bitflags" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3" + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "bytes" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" + +[[package]] +name = "cc" +version = "1.2.62" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1dce859f0832a7d088c4f1119888ab94ef4b5d6795d1ce05afb7fe159d79f98" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "cfg_aliases" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" + +[[package]] +name = "clap" +version = "4.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ddb117e43bbf7dacf0a4190fef4d345b9bad68dfc649cb349e7d17d28428e51" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2ce8604710f6733aa641a2b3731eaa1e8b3d9973d5e3565da11800813f997a9" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "clap_lex" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" + +[[package]] +name = "colorchoice" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" + +[[package]] +name = "displaydoc" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "fastrand" +version = "2.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" + +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "futures-channel" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" +dependencies = [ + "futures-core", +] + +[[package]] +name = "futures-core" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" + +[[package]] +name = "futures-task" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" + +[[package]] +name = "futures-util" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +dependencies = [ + "futures-core", + "futures-task", + "pin-project-lite", + "slab", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "wasi", + "wasm-bindgen", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "r-efi 5.3.0", + "wasip2", + "wasm-bindgen", +] + +[[package]] +name = "getrandom" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" +dependencies = [ + "cfg-if", + "libc", + "r-efi 6.0.0", + "wasip2", + "wasip3", +] + +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "foldhash", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "http" +version = "1.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8be7462df143984c4598a256ef469b251d7d7f9e271135073e78fc535414f3d0" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "hyper" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6299f016b246a94207e63da54dbe807655bf9e00044f73ded42c3ac5305fbcca" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "http", + "http-body", + "httparse", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-rustls" +version = "0.27.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f" +dependencies = [ + "http", + "hyper", + "hyper-util", + "rustls", + "tokio", + "tokio-rustls", + "tower-service", + "webpki-roots", +] + +[[package]] +name = "hyper-util" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" +dependencies = [ + "base64", + "bytes", + "futures-channel", + "futures-util", + "http", + "http-body", + "hyper", + "ipnet", + "libc", + "percent-encoding", + "pin-project-lite", + "socket2", + "tokio", + "tower-service", + "tracing", +] + +[[package]] +name = "icu_collections" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38" + +[[package]] +name = "icu_properties" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de" +dependencies = [ + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14" + +[[package]] +name = "icu_provider" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "id-arena" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "indexmap" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown 0.17.1", + "serde", + "serde_core", +] + +[[package]] +name = "ipnet" +version = "2.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" + +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "js-sys" +version = "0.3.99" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "142bc4740e452c1e57ade0cbc129f139c9093e354346f0872ef985f4f5cf5f11" +dependencies = [ + "cfg-if", + "futures-util", + "once_cell", + "wasm-bindgen", +] + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + +[[package]] +name = "leb128fmt" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" + +[[package]] +name = "libc" +version = "0.2.186" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "litemap" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "616ec5685824bcc94416c6d4a7a446eea774a31efd7062c8480ba6fd06d7a6e5" + +[[package]] +name = "lru-slab" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" + +[[package]] +name = "matchers" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9" +dependencies = [ + "regex-automata", +] + +[[package]] +name = "memchr" +version = "2.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" + +[[package]] +name = "mio" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "nu-ansi-term" +version = "0.50.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + +[[package]] +name = "openab-agent" +version = "0.1.0" +dependencies = [ + "anyhow", + "clap", + "libc", + "reqwest", + "serde", + "serde_json", + "tempfile", + "tokio", + "tracing", + "tracing-subscriber", + "uuid", +] + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link", +] + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "potential_utf" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" +dependencies = [ + "zerovec", +] + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn", +] + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quinn" +version = "0.11.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e20a958963c291dc322d98411f541009df2ced7b5a4f2bd52337638cfccf20" +dependencies = [ + "bytes", + "cfg_aliases", + "pin-project-lite", + "quinn-proto", + "quinn-udp", + "rustc-hash", + "rustls", + "socket2", + "thiserror", + "tokio", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-proto" +version = "0.11.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "434b42fec591c96ef50e21e886936e66d3cc3f737104fdb9b737c40ffb94c098" +dependencies = [ + "bytes", + "getrandom 0.3.4", + "lru-slab", + "rand", + "ring", + "rustc-hash", + "rustls", + "rustls-pki-types", + "slab", + "thiserror", + "tinyvec", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-udp" +version = "0.5.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd" +dependencies = [ + "cfg_aliases", + "libc", + "once_cell", + "socket2", + "tracing", + "windows-sys 0.60.2", +] + +[[package]] +name = "quote" +version = "1.0.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "rand" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea" +dependencies = [ + "rand_chacha", + "rand_core", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", +] + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags", +] + +[[package]] +name = "regex-automata" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" + +[[package]] +name = "reqwest" +version = "0.12.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" +dependencies = [ + "base64", + "bytes", + "futures-core", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-util", + "js-sys", + "log", + "percent-encoding", + "pin-project-lite", + "quinn", + "rustls", + "rustls-pki-types", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tokio-rustls", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "webpki-roots", +] + +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + +[[package]] +name = "rustc-hash" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe" + +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls" +version = "0.23.40" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef86cd5876211988985292b91c96a8f2d298df24e75989a43a3c73f2d4d8168b" +dependencies = [ + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-pki-types" +version = "1.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30a7197ae7eb376e574fe940d068c30fe0462554a3ddbe4eca7838e049c937a9" +dependencies = [ + "web-time", + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.150" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "sharded-slab" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" +dependencies = [ + "lazy_static", +] + +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" + +[[package]] +name = "socket2" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "syn" +version = "2.0.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +dependencies = [ + "futures-core", +] + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom 0.4.2", + "once_cell", + "rustix", + "windows-sys 0.61.2", +] + +[[package]] +name = "thiserror" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "thread_local" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "tinystr" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tinyvec" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e61e67053d25a4e82c844e8424039d9745781b3fc4f32b8d55ed50f5f667ef3" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + +[[package]] +name = "tokio" +version = "1.52.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe" +dependencies = [ + "bytes", + "libc", + "mio", + "parking_lot", + "pin-project-lite", + "signal-hook-registry", + "socket2", + "tokio-macros", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-macros" +version = "2.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tokio-rustls" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" +dependencies = [ + "rustls", + "tokio", +] + +[[package]] +name = "tower" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tower-layer", + "tower-service", +] + +[[package]] +name = "tower-http" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" +dependencies = [ + "bitflags", + "bytes", + "futures-util", + "http", + "http-body", + "pin-project-lite", + "tower", + "tower-layer", + "tower-service", + "url", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", + "valuable", +] + +[[package]] +name = "tracing-log" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" +dependencies = [ + "log", + "once_cell", + "tracing-core", +] + +[[package]] +name = "tracing-subscriber" +version = "0.3.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319" +dependencies = [ + "matchers", + "nu-ansi-term", + "once_cell", + "regex-automata", + "sharded-slab", + "smallvec", + "thread_local", + "tracing", + "tracing-core", + "tracing-log", +] + +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + +[[package]] +name = "uuid" +version = "1.23.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd74a9687298c6858e9b88ec8935ec45d22e8fd5e6394fa1bd4e99a87789c76" +dependencies = [ + "getrandom 0.4.2", + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "valuable" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" + +[[package]] +name = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.3+wasi-0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20064672db26d7cdc89c7798c48a0fdfac8213434a1186e5ef29fd560ae223d6" +dependencies = [ + "wit-bindgen 0.57.1", +] + +[[package]] +name = "wasip3" +version = "0.4.0+wasi-0.3.0-rc-2026-01-06" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" +dependencies = [ + "wit-bindgen 0.51.0", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.122" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ed04576f974d2b2fba0f38c51dbc5518011e38c36bf1143164be765528fd409" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.72" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9473dbd2991ae90b6291c3c32c30c6187ac49aa32f9905d1cce280ec1e110b0f" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.122" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "916151b09da36bd82f6615cbf3a419e2f0ba23a03c6160e8e92eb6bd4aa1dec6" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.122" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "299047362ccbfce148b67ab7e73349f77748e00c8296f9542adfad2ad82c5c5e" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.122" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a929b2c61f11ba3e9bc35b50c1f25cb38e0e892c0c231ae2b8cf78d5dad4437" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "wasm-encoder" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" +dependencies = [ + "leb128fmt", + "wasmparser", +] + +[[package]] +name = "wasm-metadata" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" +dependencies = [ + "anyhow", + "indexmap", + "wasm-encoder", + "wasmparser", +] + +[[package]] +name = "wasmparser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" +dependencies = [ + "bitflags", + "hashbrown 0.15.5", + "indexmap", + "semver", +] + +[[package]] +name = "web-sys" +version = "0.3.99" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d621441cfc37b84979402712047321980c178f299193a3589d05b99e8763436" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "webpki-roots" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52f5ee44c96cf55f1b349600768e3ece3a8f26010c05265ab73f945bb1a2eb9d" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" +dependencies = [ + "windows-targets 0.53.5", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm 0.52.6", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", +] + +[[package]] +name = "windows-targets" +version = "0.53.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" +dependencies = [ + "windows-link", + "windows_aarch64_gnullvm 0.53.1", + "windows_aarch64_msvc 0.53.1", + "windows_i686_gnu 0.53.1", + "windows_i686_gnullvm 0.53.1", + "windows_i686_msvc 0.53.1", + "windows_x86_64_gnu 0.53.1", + "windows_x86_64_gnullvm 0.53.1", + "windows_x86_64_msvc 0.53.1", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_i686_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" + +[[package]] +name = "wit-bindgen" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" +dependencies = [ + "wit-bindgen-rust-macro", +] + +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + +[[package]] +name = "wit-bindgen-core" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" +dependencies = [ + "anyhow", + "heck", + "wit-parser", +] + +[[package]] +name = "wit-bindgen-rust" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" +dependencies = [ + "anyhow", + "heck", + "indexmap", + "prettyplease", + "syn", + "wasm-metadata", + "wit-bindgen-core", + "wit-component", +] + +[[package]] +name = "wit-bindgen-rust-macro" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a" +dependencies = [ + "anyhow", + "prettyplease", + "proc-macro2", + "quote", + "syn", + "wit-bindgen-core", + "wit-bindgen-rust", +] + +[[package]] +name = "wit-component" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" +dependencies = [ + "anyhow", + "bitflags", + "indexmap", + "log", + "serde", + "serde_derive", + "serde_json", + "wasm-encoder", + "wasm-metadata", + "wasmparser", + "wit-parser", +] + +[[package]] +name = "wit-parser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" +dependencies = [ + "anyhow", + "id-arena", + "indexmap", + "log", + "semver", + "serde", + "serde_derive", + "serde_json", + "unicode-xid", + "wasmparser", +] + +[[package]] +name = "writeable" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" + +[[package]] +name = "yoke" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "abe8c5fda708d9ca3df187cae8bfb9ceda00dd96231bed36e445a1a48e66f9ca" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zerocopy" +version = "0.8.48" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eed437bf9d6692032087e337407a86f04cd8d6a16a37199ed57949d415bd68e9" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.48" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70e3cd084b1788766f53af483dd21f93881ff30d7320490ec3ef7526d203bad4" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zerofrom" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zeroize" +version = "1.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" + +[[package]] +name = "zerotrie" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" diff --git a/openab-agent/Cargo.toml b/openab-agent/Cargo.toml new file mode 100644 index 000000000..f059cfc6a --- /dev/null +++ b/openab-agent/Cargo.toml @@ -0,0 +1,29 @@ +[package] +name = "openab-agent" +version = "0.1.0" +edition = "2021" +license = "MIT" +description = "Native Rust coding agent with built-in ACP support" + +[dependencies] +tokio = { version = "1", features = ["full"] } +serde = { version = "1", features = ["derive"] } +serde_json = "1" +reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "json"] } +anyhow = "1" +uuid = { version = "1", features = ["v4"] } +clap = { version = "4", features = ["derive"] } +tracing = "0.1" +tracing-subscriber = { version = "0.3", features = ["env-filter"] } +base64 = "0.22.1" +sha2 = "0.11.0" +getrandom = "0.4.2" +urlencoding = "2.1.3" +open = "5.3.5" +url = "2.5.8" + +[target.'cfg(unix)'.dependencies] +libc = "0.2" + +[dev-dependencies] +tempfile = "3" diff --git a/openab-agent/src/acp.rs b/openab-agent/src/acp.rs new file mode 100644 index 000000000..38054f25d --- /dev/null +++ b/openab-agent/src/acp.rs @@ -0,0 +1,289 @@ +use crate::agent::Agent; +use crate::llm::AnthropicProvider; +use serde::{Deserialize, Serialize}; +use serde_json::{json, Value}; +use std::collections::HashMap; +use std::io::{self, BufRead, Write}; +use tokio::sync::mpsc; +use uuid::Uuid; + +#[derive(Debug, Deserialize)] +pub struct JsonRpcRequest { + pub id: Option, + pub method: Option, + pub params: Option, +} + +#[derive(Debug, Serialize)] +pub struct JsonRpcResponse { + pub jsonrpc: &'static str, + pub id: u64, + #[serde(skip_serializing_if = "Option::is_none")] + pub result: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub error: Option, +} + +#[derive(Debug, Serialize)] +pub struct JsonRpcNotification { + pub jsonrpc: &'static str, + pub method: String, + pub params: Value, +} + +pub struct AcpServer { + // TODO(v0.2): add session TTL and periodic cleanup to prevent OOM + sessions: HashMap, + working_dir: String, +} + +impl AcpServer { + pub fn new() -> Self { + Self { + sessions: HashMap::new(), + working_dir: std::env::current_dir() + .map(|p| p.to_string_lossy().to_string()) + .unwrap_or_else(|_| "/tmp".to_string()), + } + } + + pub async fn run(&mut self) { + let (tx, mut rx) = mpsc::unbounded_channel::(); + + std::thread::spawn(move || { + let stdin = io::stdin(); + for line in stdin.lock().lines() { + #[allow(clippy::collapsible_match)] + match line { + Ok(l) if !l.trim().is_empty() => { + if tx.send(l).is_err() { + break; + } + } + Err(_) => break, + _ => {} + } + } + }); + + let mut stdout = io::stdout(); + + while let Some(line) = rx.recv().await { + let req: JsonRpcRequest = match serde_json::from_str(&line) { + Ok(r) => r, + Err(_) => continue, + }; + let id = match req.id { + Some(id) => id, + None => continue, + }; + + let output = match req.method.as_deref() { + Some("initialize") => vec![self.handle_initialize(id)], + Some("session/new") => vec![self.handle_session_new(id)], + Some("session/prompt") => { + let params = req.params.unwrap_or(json!({})); + self.handle_session_prompt(id, ¶ms).await + } + Some("session/cancel") => { + // TODO(v0.2): implement cancellation token to abort in-progress agent.run() + vec![self.ok_response(id, json!({}))] + } + Some(method) => { + vec![self.error_response(id, -32601, &format!("method not found: {method}"))] + } + None => continue, + }; + + for line in output { + let _ = writeln!(stdout, "{}", line); + } + let _ = stdout.flush(); + } + } + + fn handle_initialize(&self, id: u64) -> String { + let resp = JsonRpcResponse { + jsonrpc: "2.0", + id, + result: Some(json!({ + "protocolVersion": 1, + "agentInfo": { + "name": "openab-agent", + "version": env!("CARGO_PKG_VERSION") + }, + "agentCapabilities": { + "streaming": false, + "loadSession": false + } + })), + error: None, + }; + serde_json::to_string(&resp).unwrap() + } + + fn handle_session_new(&mut self, id: u64) -> String { + let session_id = Uuid::new_v4().to_string(); + + // Respect OPENAB_AGENT_PROVIDER if set, otherwise auto-detect + let provider_choice = std::env::var("OPENAB_AGENT_PROVIDER").unwrap_or_default(); + let provider: Box = match provider_choice.as_str() { + "anthropic" => match AnthropicProvider::from_env() { + Ok(p) => Box::new(p), + Err(e) => return self.error_response(id, -32000, &e), + }, + "openai" | "codex" => match crate::llm::OpenAiProvider::from_auth_store() { + Ok(p) => Box::new(p), + Err(e) => return self.error_response(id, -32000, &e), + }, + _ => { + // Auto-detect: try API key first, then OAuth token + match AnthropicProvider::from_env() { + Ok(p) => Box::new(p), + Err(_) => match crate::llm::OpenAiProvider::from_auth_store() { + Ok(p) => Box::new(p), + Err(e) => { + return self.error_response( + id, + -32000, + &format!("No credentials: set ANTHROPIC_API_KEY or run `openab-agent auth codex-oauth`. {e}"), + ) + } + }, + } + } + }; + + let agent = Agent::new_boxed(provider, self.working_dir.clone()); + self.sessions.insert(session_id.clone(), agent); + let resp = JsonRpcResponse { + jsonrpc: "2.0", + id, + result: Some(json!({ "sessionId": session_id })), + error: None, + }; + serde_json::to_string(&resp).unwrap() + } + + async fn handle_session_prompt(&mut self, id: u64, params: &Value) -> Vec { + let session_id = params + .get("sessionId") + .and_then(|v| v.as_str()) + .unwrap_or(""); + + let prompt_text = params + .get("prompt") + .and_then(|v| v.as_array()) + .map(|arr| { + arr.iter() + .filter_map(|b| b.get("text").and_then(|t| t.as_str())) + .collect::>() + .join("\n") + }) + .unwrap_or_default(); + + if prompt_text.trim().is_empty() { + return vec![self.error_response(id, -32602, "prompt is empty")]; + } + + let agent = match self.sessions.get_mut(session_id) { + Some(a) => a, + None => { + return vec![self.error_response(id, -32600, "unknown session")]; + } + }; + + let mut output_lines = Vec::new(); + let session_id_owned = session_id.to_string(); + + match agent.run(&prompt_text).await { + Ok(response_text) => { + let notification = serde_json::to_string(&JsonRpcNotification { + jsonrpc: "2.0", + method: "session/update".to_string(), + params: json!({ + "sessionId": session_id_owned, + "update": { + "sessionUpdate": "agent_message_chunk", + "content": { "type": "text", "text": response_text } + } + }), + }) + .unwrap(); + output_lines.push(notification); + output_lines.push(self.ok_response(id, json!({ "stopReason": "end_turn" }))); + } + Err(e) => { + output_lines.push(self.error_response(id, -32000, &format!("agent error: {e}"))); + } + } + + output_lines + } + + fn ok_response(&self, id: u64, result: Value) -> String { + serde_json::to_string(&JsonRpcResponse { + jsonrpc: "2.0", + id, + result: Some(result), + error: None, + }) + .unwrap() + } + + fn error_response(&self, id: u64, code: i64, message: &str) -> String { + serde_json::to_string(&JsonRpcResponse { + jsonrpc: "2.0", + id, + result: None, + error: Some(json!({ "code": code, "message": message })), + }) + .unwrap() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_initialize_response() { + let server = AcpServer::new(); + let resp_str = server.handle_initialize(1); + let resp: Value = serde_json::from_str(&resp_str).unwrap(); + assert_eq!(resp["jsonrpc"], "2.0"); + assert_eq!(resp["id"], 1); + assert_eq!(resp["result"]["agentInfo"]["name"], "openab-agent"); + assert_eq!(resp["result"]["agentCapabilities"]["streaming"], false); + } + + #[test] + fn test_session_new() { + // Set a fake key so from_env() succeeds in CI + unsafe { std::env::set_var("ANTHROPIC_API_KEY", "test-key") }; + let mut server = AcpServer::new(); + let resp_str = server.handle_session_new(2); + let resp: Value = serde_json::from_str(&resp_str).unwrap(); + assert_eq!(resp["jsonrpc"], "2.0"); + assert_eq!(resp["id"], 2); + assert!(resp["result"]["sessionId"].as_str().unwrap().len() > 0); + } + + #[test] + fn test_session_new_missing_key() { + // Ensure no OAuth token exists either + let auth_path = + std::path::PathBuf::from(std::env::var("HOME").unwrap_or_else(|_| "/tmp".to_string())) + .join(".openab/agent/auth.json"); + let _ = std::fs::remove_file(&auth_path); + unsafe { std::env::remove_var("ANTHROPIC_API_KEY") }; + let mut server = AcpServer::new(); + let resp_str = server.handle_session_new(3); + let resp: Value = serde_json::from_str(&resp_str).unwrap(); + assert!(resp["error"].is_object()); + assert!(resp["error"]["message"] + .as_str() + .unwrap() + .contains("ANTHROPIC_API_KEY")); + } +} diff --git a/openab-agent/src/agent.rs b/openab-agent/src/agent.rs new file mode 100644 index 000000000..01ec99f44 --- /dev/null +++ b/openab-agent/src/agent.rs @@ -0,0 +1,334 @@ +use anyhow::Result; +use std::path::PathBuf; +use tracing::{debug, info}; + +use crate::llm::{ContentBlock, LlmEvent, LlmProvider, Message, ToolDef}; +use crate::tools; + +const SYSTEM_PROMPT: &str = r#"You are openab-agent, a coding assistant. You help users by reading, writing, and editing files, and running shell commands. + +You have 4 tools available: +- read: Read file contents or list a directory +- write: Create or overwrite a file +- edit: Replace a string in a file (first occurrence) +- bash: Execute a shell command + +Be direct and concise. Execute tasks immediately rather than explaining what you would do. When you need to understand code, read the relevant files first."#; + +const MAX_TOOL_LOOPS: usize = 50; +/// Maximum number of messages to keep in context. When exceeded, oldest +/// messages (excluding the first user message) are dropped. +const MAX_CONTEXT_MESSAGES: usize = 100; + +pub struct Agent { + provider: Box, + messages: Vec, + working_dir: PathBuf, + system_prompt: String, + tools: Vec, +} + +impl Agent { + #[cfg(test)] + pub fn new(provider: impl LlmProvider + 'static, working_dir: String) -> Self { + let system_prompt = Self::build_system_prompt(&working_dir); + Self { + provider: Box::new(provider), + messages: Vec::new(), + working_dir: PathBuf::from(working_dir), + system_prompt, + tools: tools::tool_definitions(), + } + } + + pub fn new_boxed(provider: Box, working_dir: String) -> Self { + let system_prompt = Self::build_system_prompt(&working_dir); + Self { + provider, + messages: Vec::new(), + working_dir: PathBuf::from(working_dir), + system_prompt, + tools: tools::tool_definitions(), + } + } + + /// Run the agent with a user prompt, executing tool calls until completion. + /// Returns the final text response. + fn build_system_prompt(working_dir: &str) -> String { + let agents_md = std::path::Path::new(working_dir).join("AGENTS.md"); + let custom = std::fs::read_to_string(&agents_md).unwrap_or_default(); + if custom.is_empty() { + SYSTEM_PROMPT.to_string() + } else { + format!( + "{} + +--- + +{}", + custom.trim(), + SYSTEM_PROMPT + ) + } + } + + pub async fn run(&mut self, prompt: &str) -> Result { + // Add user message + self.messages.push(Message { + role: "user".to_string(), + content: vec![ContentBlock::Text { + text: prompt.to_string(), + }], + }); + + let mut final_text = String::new(); + + for iteration in 0..MAX_TOOL_LOOPS { + debug!("agent loop iteration {iteration}"); + + // Truncate context to prevent unbounded growth / token limit + self.truncate_context(); + + let events = self.call_llm().await?; + + let mut tool_calls = Vec::new(); + let mut text_parts = Vec::new(); + + for event in &events { + match event { + LlmEvent::Text(t) => text_parts.push(t.clone()), + LlmEvent::ToolUse { id, name, input } => { + tool_calls.push((id.clone(), name.clone(), input.clone())); + } + LlmEvent::Stop => {} + LlmEvent::Error(e) => { + return Err(anyhow::anyhow!("LLM error: {e}")); + } + } + } + + // Build assistant message content + let mut assistant_content: Vec = Vec::new(); + if !text_parts.is_empty() { + assistant_content.push(ContentBlock::Text { + text: text_parts.join(""), + }); + } + for (id, name, input) in &tool_calls { + assistant_content.push(ContentBlock::ToolUse { + id: id.clone(), + name: name.clone(), + input: input.clone(), + }); + } + + self.messages.push(Message { + role: "assistant".to_string(), + content: assistant_content, + }); + + if tool_calls.is_empty() || !text_parts.is_empty() { + // No tool calls — we're done + final_text = text_parts.join(""); + break; + } + + // Execute tool calls and add results + let mut tool_results: Vec = Vec::new(); + for (id, name, input) in &tool_calls { + info!("executing tool: {name}"); + let result = tools::execute_tool(name, input, &self.working_dir).await; + match result { + Ok(output) => { + tool_results.push(ContentBlock::ToolResult { + tool_use_id: id.clone(), + content: output, + is_error: None, + }); + } + Err(e) => { + tool_results.push(ContentBlock::ToolResult { + tool_use_id: id.clone(), + content: format!("Error: {e}"), + is_error: Some(true), + }); + } + } + } + + self.messages.push(Message { + role: "user".to_string(), + content: tool_results, + }); + } + + if final_text.is_empty() { + return Err(anyhow::anyhow!( + "agent exceeded maximum tool loop iterations ({MAX_TOOL_LOOPS})" + )); + } + + Ok(final_text) + } + + /// Drop oldest message pairs when context exceeds limit, preserving the + /// first user message and maintaining strict user/assistant alternation. + fn truncate_context(&mut self) { + while self.messages.len() > MAX_CONTEXT_MESSAGES { + // Drain in pairs (assistant + user) from index 1 to maintain alternation + let end = (1 + 2).min(self.messages.len()); + self.messages.drain(1..end); + } + } + + async fn call_llm(&self) -> Result> { + self.provider + .chat(&self.system_prompt, &self.messages, &self.tools) + .await + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::sync::atomic::{AtomicUsize, Ordering}; + use std::sync::Arc; + + /// Hand-written mock LLM provider for unit testing. + struct MockLlmProvider { + responses: Vec>, + call_count: Arc, + } + + impl MockLlmProvider { + fn new(responses: Vec>) -> Self { + Self { + responses, + call_count: Arc::new(AtomicUsize::new(0)), + } + } + } + + impl LlmProvider for MockLlmProvider { + fn chat<'a>( + &'a self, + _system: &'a str, + _messages: &'a [Message], + _tools: &'a [ToolDef], + ) -> std::pin::Pin>> + Send + 'a>> + { + let idx = self.call_count.fetch_add(1, Ordering::SeqCst); + let events = self.responses[idx].clone(); + Box::pin(async move { Ok(events) }) + } + } + + #[tokio::test] + async fn test_agent_simple_text_response() { + let mock = MockLlmProvider::new(vec![vec![ + LlmEvent::Text("Hello!".to_string()), + LlmEvent::Stop, + ]]); + + let tmp = tempfile::TempDir::new().unwrap(); + let mut agent = Agent::new(mock, tmp.path().to_string_lossy().to_string()); + let result = agent.run("hi").await.unwrap(); + assert_eq!(result, "Hello!"); + } + + #[tokio::test] + #[ignore] // Integration test: executes real file tools + async fn test_agent_tool_call_then_response() { + let tmp = tempfile::TempDir::new().unwrap(); + std::fs::write(tmp.path().join("test.txt"), "file content here").unwrap(); + + let mock = MockLlmProvider::new(vec![ + // First call: LLM requests to read a file + vec![LlmEvent::ToolUse { + id: "tu_1".to_string(), + name: "read".to_string(), + input: serde_json::json!({ "path": "test.txt" }), + }], + // Second call: LLM responds with text + vec![ + LlmEvent::Text("The file contains: file content here".to_string()), + LlmEvent::Stop, + ], + ]); + + let mut agent = Agent::new(mock, tmp.path().to_string_lossy().to_string()); + let result = agent.run("read test.txt").await.unwrap(); + assert_eq!(result, "The file contains: file content here"); + } + + #[tokio::test] + #[ignore] // Integration test: executes real file tools + async fn test_agent_tool_error_handling() { + let tmp = tempfile::TempDir::new().unwrap(); + + let mock = MockLlmProvider::new(vec![ + // First call: LLM requests to read a non-existent file + vec![LlmEvent::ToolUse { + id: "tu_1".to_string(), + name: "read".to_string(), + input: serde_json::json!({ "path": "nonexistent.txt" }), + }], + // Second call: LLM acknowledges the error + vec![ + LlmEvent::Text("File not found.".to_string()), + LlmEvent::Stop, + ], + ]); + + let mut agent = Agent::new(mock, tmp.path().to_string_lossy().to_string()); + let result = agent.run("read nonexistent.txt").await.unwrap(); + assert_eq!(result, "File not found."); + + // Verify the tool result was marked as error + assert_eq!(agent.messages.len(), 4); // user, assistant(tool_use), user(tool_result), assistant(text) + let tool_result_msg = &agent.messages[2]; + match &tool_result_msg.content[0] { + ContentBlock::ToolResult { is_error, .. } => { + assert_eq!(*is_error, Some(true)); + } + _ => panic!("expected ToolResult"), + } + } + + #[tokio::test] + #[ignore] // Integration test: executes real file tools + async fn test_agent_multiple_tool_calls() { + let tmp = tempfile::TempDir::new().unwrap(); + + let mock = MockLlmProvider::new(vec![ + // First call: write a file + vec![LlmEvent::ToolUse { + id: "tu_1".to_string(), + name: "write".to_string(), + input: serde_json::json!({ "path": "out.txt", "content": "hello" }), + }], + // Second call: read it back + vec![LlmEvent::ToolUse { + id: "tu_2".to_string(), + name: "read".to_string(), + input: serde_json::json!({ "path": "out.txt" }), + }], + // Third call: done + vec![ + LlmEvent::Text("Done. File contains: hello".to_string()), + LlmEvent::Stop, + ], + ]); + + let mut agent = Agent::new(mock, tmp.path().to_string_lossy().to_string()); + let result = agent + .run("write hello to out.txt then read it") + .await + .unwrap(); + assert_eq!(result, "Done. File contains: hello"); + + // Verify file was actually written + let content = std::fs::read_to_string(tmp.path().join("out.txt")).unwrap(); + assert_eq!(content, "hello"); + } +} diff --git a/openab-agent/src/auth.rs b/openab-agent/src/auth.rs new file mode 100644 index 000000000..385ccede9 --- /dev/null +++ b/openab-agent/src/auth.rs @@ -0,0 +1,538 @@ +use anyhow::{anyhow, Result}; +use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine}; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; +use std::io::{BufRead, Write}; +use std::net::TcpListener; +use std::path::PathBuf; +use std::time::{SystemTime, UNIX_EPOCH}; + +const REFRESH_SKEW_SECONDS: u64 = 120; + +const CODEX_AUTHORIZE_URL: &str = "https://auth.openai.com/oauth/authorize"; +const CODEX_TOKEN_URL: &str = "https://auth.openai.com/oauth/token"; +const CODEX_DEVICE_AUTH_URL: &str = "https://auth.openai.com/api/accounts/deviceauth/usercode"; +const CODEX_DEVICE_TOKEN_URL: &str = "https://auth.openai.com/api/accounts/deviceauth/token"; +const CODEX_DEVICE_REDIRECT_URI: &str = "https://auth.openai.com/deviceauth/callback"; +const REDIRECT_PORT: u16 = 1455; + +fn codex_client_id() -> String { + std::env::var("OPENAB_AGENT_OAUTH_CLIENT_ID") + .unwrap_or_else(|_| "app_EMoamEEZ73f0CkXaXp7hrann".to_string()) +} + +fn redirect_uri() -> String { + format!("http://localhost:{REDIRECT_PORT}/auth/callback") +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct TokenStore { + pub access_token: String, + pub refresh_token: String, + pub expires_at: u64, + pub token_endpoint: String, + pub provider: String, +} + +fn auth_path() -> PathBuf { + let home = std::env::var("HOME").unwrap_or_else(|_| "/tmp".to_string()); + PathBuf::from(home) + .join(".openab") + .join("agent") + .join("auth.json") +} + +pub fn load_tokens() -> Result { + let path = auth_path(); + let data = std::fs::read_to_string(&path).map_err(|_| { + anyhow!( + "No credentials found at {}. Run `openab-agent auth codex-oauth` first.", + path.display() + ) + })?; + serde_json::from_str(&data).map_err(|e| anyhow!("Invalid auth.json: {e}")) +} + +fn save_tokens(store: &TokenStore) -> Result<()> { + let path = auth_path(); + if let Some(dir) = path.parent() { + std::fs::create_dir_all(dir)?; + } + let data = serde_json::to_string_pretty(store)?; + #[cfg(unix)] + { + use std::fs::OpenOptions; + use std::io::Write as _; + use std::os::unix::fs::OpenOptionsExt; + let mut file = OpenOptions::new() + .write(true) + .create(true) + .truncate(true) + .mode(0o600) + .open(&path)?; + file.write_all(data.as_bytes())?; + } + #[cfg(not(unix))] + { + std::fs::write(&path, &data)?; + } + Ok(()) +} + +fn is_expired(store: &TokenStore) -> bool { + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_secs(); + now + REFRESH_SKEW_SECONDS >= store.expires_at +} + +pub async fn get_valid_token() -> Result { + let mut store = load_tokens()?; + if is_expired(&store) { + store = refresh_token(&store).await?; + save_tokens(&store)?; + } + Ok(store.access_token) +} + +pub async fn force_refresh() -> Result { + let store = load_tokens()?; + let new_store = refresh_token(&store).await?; + save_tokens(&new_store)?; + Ok(new_store.access_token) +} + +async fn refresh_token(store: &TokenStore) -> Result { + let client_id = codex_client_id(); + let client = reqwest::Client::new(); + let resp = client + .post(&store.token_endpoint) + .form(&[ + ("grant_type", "refresh_token"), + ("refresh_token", store.refresh_token.as_str()), + ("client_id", client_id.as_str()), + ]) + .send() + .await?; + if !resp.status().is_success() { + let status = resp.status(); + let body = resp.text().await.unwrap_or_default(); + return Err(anyhow!("Token refresh failed (HTTP {status}): {body}. Run `openab-agent auth codex-oauth` again.")); + } + let payload: serde_json::Value = resp.json().await?; + let access_token = payload["access_token"] + .as_str() + .ok_or_else(|| anyhow!("No access_token in refresh response"))?; + let new_refresh = payload["refresh_token"] + .as_str() + .unwrap_or(&store.refresh_token); + let expires_in = payload["expires_in"].as_u64().unwrap_or(3600); + let now = SystemTime::now().duration_since(UNIX_EPOCH)?.as_secs(); + Ok(TokenStore { + access_token: access_token.to_string(), + refresh_token: new_refresh.to_string(), + expires_at: now + expires_in, + token_endpoint: store.token_endpoint.clone(), + provider: store.provider.clone(), + }) +} + +fn generate_pkce() -> (String, String) { + let mut buf = [0u8; 32]; + getrandom::fill(&mut buf).expect("getrandom failed"); + let verifier = URL_SAFE_NO_PAD.encode(buf); + let challenge = URL_SAFE_NO_PAD.encode(Sha256::digest(verifier.as_bytes())); + (verifier, challenge) +} + +// Browser PKCE flow +pub async fn login_browser_flow(no_browser: bool) -> Result<()> { + let client_id = codex_client_id(); + let (code_verifier, code_challenge) = generate_pkce(); + let mut state_buf = [0u8; 16]; + getrandom::fill(&mut state_buf).expect("getrandom failed"); + let state = URL_SAFE_NO_PAD.encode(state_buf); + let redir_str = redirect_uri(); + let redir = urlencoding::encode(&redir_str); + let auth_url = format!("{CODEX_AUTHORIZE_URL}?client_id={client_id}&redirect_uri={redir}&response_type=code&scope=openid+profile+email+offline_access&code_challenge={code_challenge}&code_challenge_method=S256&state={state}&id_token_add_organizations=true&codex_cli_simplified_flow=true&originator=openab-agent"); + + let listener = TcpListener::bind(format!("127.0.0.1:{REDIRECT_PORT}")).map_err(|e| { + anyhow!("Failed to bind port {REDIRECT_PORT}: {e}. Is another instance running?") + })?; + + if no_browser { + println!("Open this URL in your browser:\n"); + println!(" {auth_url}\n"); + println!("After approving, your browser will redirect to a localhost URL."); + println!("Copy the full URL from the browser address bar and paste it here:\n"); + + let mut input = String::new(); + std::io::stdin() + .read_line(&mut input) + .map_err(|e| anyhow!("Failed to read input: {e}"))?; + let input = input.trim(); + if input.is_empty() { + return Err(anyhow!("No URL provided")); + } + let url = url::Url::parse(input).map_err(|_| anyhow!("Invalid URL: {input}"))?; + + // Skip TCP listener for paste flow + let code = url + .query_pairs() + .find(|(k, _)| k == "code") + .map(|(_, v)| v.to_string()) + .ok_or_else(|| { + let error = url + .query_pairs() + .find(|(k, _)| k == "error") + .map(|(_, v)| v.to_string()); + anyhow!( + "No code in URL. Error: {}", + error.unwrap_or_else(|| "unknown".into()) + ) + })?; + let cb_state = url + .query_pairs() + .find(|(k, _)| k == "state") + .map(|(_, v)| v.to_string()); + if cb_state.as_deref() != Some(&state) { + return Err(anyhow!("State mismatch")); + } + + // Exchange code for tokens + let client = reqwest::Client::new(); + let resp = client + .post(CODEX_TOKEN_URL) + .form(&[ + ("grant_type", "authorization_code"), + ("client_id", client_id.as_str()), + ("code", code.as_str()), + ("code_verifier", code_verifier.as_str()), + ("redirect_uri", redirect_uri().as_str()), + ]) + .send() + .await?; + if !resp.status().is_success() { + let body = resp.text().await.unwrap_or_default(); + return Err(anyhow!("Token exchange failed: {body}")); + } + let payload: serde_json::Value = resp.json().await?; + let access_token = payload["access_token"] + .as_str() + .ok_or_else(|| anyhow!("No access_token"))?; + let refresh_token_val = payload["refresh_token"] + .as_str() + .ok_or_else(|| anyhow!("No refresh_token"))?; + let expires_in = payload["expires_in"].as_u64().unwrap_or(3600); + let now = SystemTime::now().duration_since(UNIX_EPOCH)?.as_secs(); + let store = TokenStore { + access_token: access_token.to_string(), + refresh_token: refresh_token_val.to_string(), + expires_at: now + expires_in, + token_endpoint: CODEX_TOKEN_URL.to_string(), + provider: "codex".to_string(), + }; + save_tokens(&store)?; + println!( + "\n\u{2705} Login successful! Token saved to {:?}", + auth_path() + ); + return Ok(()); + } else { + println!("Opening browser for authentication...\n"); + if open::that(&auth_url).is_err() { + println!("Could not open browser. Open this URL manually:\n"); + println!(" {auth_url}\n"); + } + println!("Waiting for callback..."); + } + + listener.set_nonblocking(false)?; + let (mut stream, _) = listener + .accept() + .map_err(|e| anyhow!("Failed to accept callback: {e}"))?; + let mut reader = std::io::BufReader::new(&stream); + let mut request_line = String::new(); + reader.read_line(&mut request_line)?; + + let path = request_line.split_whitespace().nth(1).unwrap_or(""); + let url = url::Url::parse(&format!("http://localhost{path}")) + .map_err(|_| anyhow!("Invalid callback URL"))?; + let code = url + .query_pairs() + .find(|(k, _)| k == "code") + .map(|(_, v)| v.to_string()) + .ok_or_else(|| { + let error = url + .query_pairs() + .find(|(k, _)| k == "error") + .map(|(_, v)| v.to_string()); + anyhow!( + "No code in callback. Error: {}", + error.unwrap_or_else(|| "unknown".into()) + ) + })?; + let cb_state = url + .query_pairs() + .find(|(k, _)| k == "state") + .map(|(_, v)| v.to_string()); + if cb_state.as_deref() != Some(&state) { + return Err(anyhow!("State mismatch in callback")); + } + + let response = "HTTP/1.1 200 OK\r\nContent-Type: text/html\r\n\r\n

Authentication successful!

You can close this tab.

"; + let _ = stream.write_all(response.as_bytes()); + + let client = reqwest::Client::new(); + let resp = client + .post(CODEX_TOKEN_URL) + .form(&[ + ("grant_type", "authorization_code"), + ("client_id", client_id.as_str()), + ("code", code.as_str()), + ("code_verifier", code_verifier.as_str()), + ("redirect_uri", redirect_uri().as_str()), + ]) + .send() + .await?; + if !resp.status().is_success() { + let body = resp.text().await.unwrap_or_default(); + return Err(anyhow!("Token exchange failed: {body}")); + } + let payload: serde_json::Value = resp.json().await?; + let access_token = payload["access_token"] + .as_str() + .ok_or_else(|| anyhow!("No access_token"))?; + let refresh_token_val = payload["refresh_token"] + .as_str() + .ok_or_else(|| anyhow!("No refresh_token"))?; + let expires_in = payload["expires_in"].as_u64().unwrap_or(3600); + let now = SystemTime::now().duration_since(UNIX_EPOCH)?.as_secs(); + let store = TokenStore { + access_token: access_token.to_string(), + refresh_token: refresh_token_val.to_string(), + expires_at: now + expires_in, + token_endpoint: CODEX_TOKEN_URL.to_string(), + provider: "codex".to_string(), + }; + save_tokens(&store)?; + println!( + "\n\u{2705} Login successful! Token saved to {:?}", + auth_path() + ); + Ok(()) +} + +// Device code flow +pub async fn login_codex_device_flow() -> Result<()> { + println!("Starting OpenAI Codex device-code login...\n"); + let client = reqwest::Client::new(); + let client_id = codex_client_id(); + + let resp = client + .post(CODEX_DEVICE_AUTH_URL) + .header("Content-Type", "application/json") + .json(&serde_json::json!({"client_id": client_id})) + .send() + .await?; + if !resp.status().is_success() { + let body = resp.text().await.unwrap_or_default(); + return Err(anyhow!("Device authorization request failed: {body}")); + } + let device_resp: serde_json::Value = resp.json().await?; + let device_auth_id = device_resp["device_auth_id"] + .as_str() + .ok_or_else(|| anyhow!("No device_auth_id"))?; + let user_code = device_resp["user_code"] + .as_str() + .ok_or_else(|| anyhow!("No user_code"))?; + let interval = device_resp["interval"] + .as_str() + .and_then(|s| s.parse::().ok()) + .or_else(|| device_resp["interval"].as_u64()) + .unwrap_or(5) + .max(5); + + println!(" Go to: https://auth.openai.com/codex/device"); + println!(" Enter code: {}\n", user_code); + println!("Waiting for authorization..."); + + let deadline = tokio::time::Instant::now() + tokio::time::Duration::from_secs(600); + let mut poll_interval = interval; + loop { + if tokio::time::Instant::now() >= deadline { + return Err(anyhow!("Device flow timed out after 10 minutes.")); + } + tokio::time::sleep(tokio::time::Duration::from_secs(poll_interval)).await; + let resp = client.post(CODEX_DEVICE_TOKEN_URL) + .json(&serde_json::json!({"client_id": client_id, "device_auth_id": device_auth_id, "user_code": user_code})) + .send().await?; + let status = resp.status(); + let payload: serde_json::Value = resp.json().await?; + if status.is_success() { + let auth_code = payload["authorization_code"] + .as_str() + .ok_or_else(|| anyhow!("No authorization_code: {payload}"))?; + let code_verifier = payload["code_verifier"] + .as_str() + .ok_or_else(|| anyhow!("No code_verifier: {payload}"))?; + let token_resp = client + .post(CODEX_TOKEN_URL) + .form(&[ + ("grant_type", "authorization_code"), + ("client_id", client_id.as_str()), + ("code", auth_code), + ("code_verifier", code_verifier), + ("redirect_uri", CODEX_DEVICE_REDIRECT_URI), + ]) + .send() + .await?; + if !token_resp.status().is_success() { + let body = token_resp.text().await.unwrap_or_default(); + return Err(anyhow!("Token exchange failed: {body}")); + } + let token_payload: serde_json::Value = token_resp.json().await?; + let access_token = token_payload["access_token"] + .as_str() + .ok_or_else(|| anyhow!("No access_token: {token_payload}"))?; + let refresh_token_val = token_payload["refresh_token"] + .as_str() + .ok_or_else(|| anyhow!("No refresh_token"))?; + let expires_in = token_payload["expires_in"].as_u64().unwrap_or(3600); + let now = SystemTime::now().duration_since(UNIX_EPOCH)?.as_secs(); + let store = TokenStore { + access_token: access_token.to_string(), + refresh_token: refresh_token_val.to_string(), + expires_at: now + expires_in, + token_endpoint: CODEX_TOKEN_URL.to_string(), + provider: "codex".to_string(), + }; + save_tokens(&store)?; + println!( + "\n\u{2705} Login successful! Token saved to {:?}", + auth_path() + ); + return Ok(()); + } + let error_code = payload["error"]["code"] + .as_str() + .or_else(|| payload["error"].as_str()) + .unwrap_or_default(); + match error_code { + "authorization_pending" | "deviceauth_authorization_pending" => continue, + "slow_down" => { + poll_interval += 5; + continue; + } + "expired_token" | "deviceauth_expired" => return Err(anyhow!("Device code expired.")), + "access_denied" => return Err(anyhow!("Authorization denied by user.")), + _ => { + if status.as_u16() == 403 || status.as_u16() == 404 { + continue; + } + return Err(anyhow!( + "Device-code error: {error_code} \u{2014} {payload}" + )); + } + } + } +} + +pub fn show_status() { + match load_tokens() { + Ok(store) => { + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_secs(); + let expired = now + REFRESH_SKEW_SECONDS >= store.expires_at; + let masked = if store.access_token.len() > 12 { + format!( + "{}...{}", + &store.access_token[..8], + &store.access_token[store.access_token.len() - 4..] + ) + } else { + "****".to_string() + }; + println!("Provider: {}", store.provider); + println!("Token: {}", masked); + println!( + "Expires: {} ({})", + store.expires_at, + if expired { "EXPIRED" } else { "valid" } + ); + println!("File: {:?}", auth_path()); + } + Err(e) => { + println!("Not authenticated: {e}\nRun: openab-agent auth codex-oauth"); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn make_store(expires_at: u64) -> TokenStore { + TokenStore { + access_token: "test_access_token_value".to_string(), + refresh_token: "test_refresh".to_string(), + expires_at, + token_endpoint: "https://example.com/token".to_string(), + provider: "codex".to_string(), + } + } + + #[test] + fn test_is_expired_future_token() { + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_secs(); + assert!(!is_expired(&make_store(now + 3600))); + } + + #[test] + fn test_is_expired_past_token() { + assert!(is_expired(&make_store(0))); + } + + #[test] + fn test_is_expired_within_skew() { + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_secs(); + assert!(is_expired(&make_store(now + 60))); + } + + #[test] + fn test_auth_path() { + assert!(auth_path() + .to_string_lossy() + .contains(".openab/agent/auth.json")); + } + + #[test] + fn test_codex_client_id_default() { + unsafe { std::env::remove_var("OPENAB_AGENT_OAUTH_CLIENT_ID") }; + assert_eq!(codex_client_id(), "app_EMoamEEZ73f0CkXaXp7hrann"); + } + + #[test] + fn test_codex_client_id_override() { + unsafe { std::env::set_var("OPENAB_AGENT_OAUTH_CLIENT_ID", "custom_id") }; + assert_eq!(codex_client_id(), "custom_id"); + unsafe { std::env::remove_var("OPENAB_AGENT_OAUTH_CLIENT_ID") }; + } + + #[test] + fn test_generate_pkce() { + let (verifier, challenge) = generate_pkce(); + assert!(!verifier.is_empty()); + let expected = URL_SAFE_NO_PAD.encode(Sha256::digest(verifier.as_bytes())); + assert_eq!(challenge, expected); + } +} diff --git a/openab-agent/src/llm.rs b/openab-agent/src/llm.rs new file mode 100644 index 000000000..429b7875b --- /dev/null +++ b/openab-agent/src/llm.rs @@ -0,0 +1,644 @@ +use anyhow::{anyhow, Result}; +use base64::Engine; +use serde::{Deserialize, Serialize}; +use serde_json::{json, Value}; +use std::pin::Pin; + +/// A message in the conversation. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Message { + pub role: String, + pub content: Vec, +} + +/// A content block within a message. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(tag = "type")] +pub enum ContentBlock { + #[serde(rename = "text")] + Text { text: String }, + #[serde(rename = "tool_use")] + ToolUse { + id: String, + name: String, + input: Value, + }, + #[serde(rename = "tool_result")] + ToolResult { + tool_use_id: String, + content: String, + #[serde(skip_serializing_if = "Option::is_none")] + is_error: Option, + }, +} + +/// Tool definition sent to the LLM. +#[derive(Debug, Clone, Serialize)] +pub struct ToolDef { + pub name: String, + pub description: String, + pub input_schema: Value, +} + +/// Events streamed back from the LLM. +#[derive(Debug, Clone)] +pub enum LlmEvent { + Text(String), + ToolUse { + id: String, + name: String, + input: Value, + }, + Stop, + #[allow(dead_code)] + Error(String), +} + +/// Trait for LLM providers. +pub trait LlmProvider: Send + Sync { + fn chat<'a>( + &'a self, + system: &'a str, + messages: &'a [Message], + tools: &'a [ToolDef], + ) -> Pin>> + Send + 'a>>; +} + +/// Anthropic Claude provider. +pub struct AnthropicProvider { + api_key: String, + model: String, + #[allow(dead_code)] + max_tokens: u32, + client: reqwest::Client, +} + +impl AnthropicProvider { + pub fn from_env() -> Result { + let api_key = std::env::var("ANTHROPIC_API_KEY") + .map_err(|_| "ANTHROPIC_API_KEY not set".to_string())?; + if api_key.is_empty() { + return Err("ANTHROPIC_API_KEY is empty".to_string()); + } + Ok(Self { + api_key, + model: std::env::var("OPENAB_AGENT_MODEL") + .unwrap_or_else(|_| "claude-sonnet-4-20250514".to_string()), + max_tokens: std::env::var("OPENAB_AGENT_MAX_TOKENS") + .ok() + .and_then(|v| v.parse().ok()) + .unwrap_or(8192), + client: reqwest::Client::new(), + }) + } + + fn build_request_body(&self, system: &str, messages: &[Message], tools: &[ToolDef]) -> Value { + let msgs: Vec = messages + .iter() + .map(|m| { + let content: Vec = m + .content + .iter() + .map(|b| match b { + ContentBlock::Text { text } => json!({ "type": "text", "text": text }), + ContentBlock::ToolUse { id, name, input } => { + json!({ "type": "tool_use", "id": id, "name": name, "input": input }) + } + ContentBlock::ToolResult { + tool_use_id, + content, + is_error, + } => { + let mut v = json!({ + "type": "tool_result", + "tool_use_id": tool_use_id, + "content": content + }); + if let Some(true) = is_error { + v["is_error"] = json!(true); + } + v + } + }) + .collect(); + json!({ "role": &m.role, "content": content }) + }) + .collect(); + + let mut body = json!({ + "model": &self.model, + "max_tokens": self.max_tokens, + "messages": msgs, + "system": system, + }); + + if !tools.is_empty() { + let tool_defs: Vec = tools + .iter() + .map(|t| { + json!({ + "name": &t.name, + "description": &t.description, + "input_schema": &t.input_schema + }) + }) + .collect(); + body["tools"] = json!(tool_defs); + } + + body + } +} + +impl LlmProvider for AnthropicProvider { + fn chat<'a>( + &'a self, + system: &'a str, + messages: &'a [Message], + tools: &'a [ToolDef], + ) -> Pin>> + Send + 'a>> { + Box::pin(async move { + let body = self.build_request_body(system, messages, tools); + let max_retries = 3u32; + + for attempt in 0..=max_retries { + let resp = self + .client + .post("https://api.anthropic.com/v1/messages") + .header("x-api-key", &self.api_key) + .header("anthropic-version", "2023-06-01") + .header("content-type", "application/json") + .json(&body) + .send() + .await + .map_err(|e| anyhow!("HTTP request failed: {e}"))?; + + let status = resp.status(); + + // Retry on 429 (rate limit) or 529 (overloaded) + if (status.as_u16() == 429 || status.as_u16() == 529) && attempt < max_retries { + let delay = std::time::Duration::from_millis(1000 * 2u64.pow(attempt)); + tokio::time::sleep(delay).await; + continue; + } + + if !status.is_success() { + let text = resp.text().await.unwrap_or_default(); + return Err(anyhow!("Anthropic API error {status}: {text}")); + } + + let response: Value = resp + .json() + .await + .map_err(|e| anyhow!("Failed to parse response: {e}"))?; + + return parse_anthropic_response(&response); + } + + Err(anyhow!("Anthropic API: max retries exceeded")) + }) + } +} + +fn parse_anthropic_response(response: &Value) -> Result> { + let mut events = Vec::new(); + + let content = response + .get("content") + .and_then(|c| c.as_array()) + .ok_or_else(|| anyhow!("missing content in response"))?; + + for block in content { + match block.get("type").and_then(|t| t.as_str()) { + Some("text") => { + if let Some(text) = block.get("text").and_then(|t| t.as_str()) { + events.push(LlmEvent::Text(text.to_string())); + } + } + Some("tool_use") => { + let id = block + .get("id") + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(); + let name = block + .get("name") + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(); + let input = block.get("input").cloned().unwrap_or(json!({})); + events.push(LlmEvent::ToolUse { id, name, input }); + } + _ => {} + } + } + + let stop_reason = response + .get("stop_reason") + .and_then(|s| s.as_str()) + .unwrap_or("end_turn"); + + if stop_reason != "tool_use" { + events.push(LlmEvent::Stop); + } + + Ok(events) +} + +// === OpenAI-compatible Provider (for Codex subscription via OAuth) === + +pub struct OpenAiProvider { + base_url: String, + model: String, + #[allow(dead_code)] + max_tokens: u32, + client: reqwest::Client, +} + +impl OpenAiProvider { + /// Create provider using stored OAuth token from ~/.openab/agent/auth.json + pub fn from_auth_store() -> Result { + // Just verify tokens exist; actual token is fetched at call time + crate::auth::load_tokens().map_err(|e| e.to_string())?; + Ok(Self { + base_url: std::env::var("OPENAB_AGENT_OPENAI_BASE_URL") + .unwrap_or_else(|_| "https://chatgpt.com/backend-api".to_string()), + model: std::env::var("OPENAB_AGENT_OPENAI_MODEL") + .or_else(|_| std::env::var("OPENAB_AGENT_MODEL")) + .unwrap_or_else(|_| "gpt-4.1-nano".to_string()), + max_tokens: std::env::var("OPENAB_AGENT_MAX_TOKENS") + .ok() + .and_then(|v| v.parse().ok()) + .unwrap_or(8192), + client: reqwest::Client::new(), + }) + } +} + +impl LlmProvider for OpenAiProvider { + fn chat<'a>( + &'a self, + system: &'a str, + messages: &'a [Message], + tools: &'a [ToolDef], + ) -> Pin>> + Send + 'a>> { + Box::pin(async move { + // Build Responses API input format + let mut oai_messages: Vec = vec![]; + for m in messages { + if m.role == "user" { + // User text messages + let texts: Vec<&str> = m + .content + .iter() + .filter_map(|b| { + if let ContentBlock::Text { text } = b { + Some(text.as_str()) + } else { + None + } + }) + .collect(); + if !texts.is_empty() { + oai_messages.push(json!({"role": "user", "content": [{"type": "input_text", "text": texts.join("")}]})); + } + // Tool results as function_call_output + for b in &m.content { + if let ContentBlock::ToolResult { + tool_use_id, + content, + .. + } = b + { + oai_messages.push(json!({"type": "function_call_output", "call_id": tool_use_id, "output": content})); + } + } + } else if m.role == "assistant" { + for b in &m.content { + match b { + ContentBlock::Text { text } => { + oai_messages.push(json!({"type": "message", "role": "assistant", "content": [{"type": "output_text", "text": text, "annotations": []}]})); + } + ContentBlock::ToolUse { id, name, input } => { + oai_messages.push(json!({"type": "function_call", "call_id": id, "name": name, "arguments": input.to_string()})); + } + _ => {} + } + } + } + } + + // Build Responses API body + let mut body = json!({ + "model": &self.model, + "store": false, + "stream": true, + "instructions": system, + "input": oai_messages, + "tool_choice": "auto", + "parallel_tool_calls": true, + }); + + if !tools.is_empty() { + let resp_tools: Vec = tools + .iter() + .map(|t| { + json!({ + "type": "function", + "name": &t.name, + "description": &t.description, + "parameters": &t.input_schema + }) + }) + .collect(); + body["tools"] = json!(resp_tools); + } + + let max_retries = 3u32; + for attempt in 0..=max_retries { + let token = crate::auth::get_valid_token().await?; + // Extract account ID from JWT for chatgpt backend API + let account_id = extract_account_id_from_jwt(&token); + let mut req = self + .client + .post(format!("{}/codex/responses", self.base_url)) + .header("Authorization", format!("Bearer {token}")) + .header("Content-Type", "application/json") + .header("originator", "openab-agent"); + if let Some(ref aid) = account_id { + req = req.header("chatgpt-account-id", aid); + } + let resp = req + .json(&body) + .send() + .await + .map_err(|e| anyhow!("HTTP request failed: {e}"))?; + + let status = resp.status(); + if (status.as_u16() == 429 || status.as_u16() == 529) && attempt < max_retries { + let delay = std::time::Duration::from_millis(1000 * 2u64.pow(attempt)); + tokio::time::sleep(delay).await; + continue; + } + + // 401: token may have expired mid-request, force refresh and retry + if status.as_u16() == 401 && attempt < max_retries { + let _ = crate::auth::force_refresh().await; + continue; + } + + if !status.is_success() { + let text = resp.text().await.unwrap_or_default(); + return Err(anyhow!("OpenAI API error {status}: {text}")); + } + + // Parse SSE stream - collect output items from response.output_item.done events + let text = resp + .text() + .await + .map_err(|e| anyhow!("Failed to read response: {e}"))?; + let mut output_items: Vec = Vec::new(); + for line in text.lines() { + if let Some(data) = line.strip_prefix("data: ") { + if data == "[DONE]" { + break; + } + if let Ok(event) = serde_json::from_str::(data) { + let event_type = + event.get("type").and_then(|t| t.as_str()).unwrap_or(""); + if event_type == "response.output_item.done" { + if let Some(item) = event.get("item") { + output_items.push(item.clone()); + } + } + } + } + } + if output_items.is_empty() { + return Err(anyhow!( + "No output items in SSE stream. Raw: {}", + &text[..text.len().min(500)] + )); + } + let response = json!({"output": output_items}); + return parse_openai_response(&response); + } + Err(anyhow!("OpenAI API: max retries exceeded")) + }) + } +} + +fn extract_account_id_from_jwt(token: &str) -> Option { + let parts: Vec<&str> = token.split('.').collect(); + if parts.len() != 3 { + return None; + } + let mut payload = parts[1].to_string(); + while !payload.len().is_multiple_of(4) { + payload.push('='); + } + let decoded = base64::engine::general_purpose::URL_SAFE + .decode(&payload) + .ok() + .or_else(|| { + base64::engine::general_purpose::STANDARD + .decode(&payload) + .ok() + })?; + let claims: Value = serde_json::from_slice(&decoded).ok()?; + claims["https://api.openai.com/auth"]["chatgpt_account_id"] + .as_str() + .map(|s| s.to_string()) +} + +fn parse_openai_response(response: &Value) -> Result> { + let mut events = Vec::new(); + + // Handle Responses API format (output array) + if let Some(output) = response.get("output").and_then(|o| o.as_array()) { + for item in output { + match item.get("type").and_then(|t| t.as_str()) { + Some("message") => { + if let Some(content) = item.get("content").and_then(|c| c.as_array()) { + for block in content { + if block.get("type").and_then(|t| t.as_str()) == Some("output_text") { + if let Some(text) = block.get("text").and_then(|t| t.as_str()) { + events.push(LlmEvent::Text(text.to_string())); + } + } + } + } + } + Some("function_call") => { + let id = item + .get("call_id") + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(); + let name = item + .get("name") + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(); + let args_str = item + .get("arguments") + .and_then(|v| v.as_str()) + .unwrap_or("{}"); + let input: Value = serde_json::from_str(args_str).unwrap_or(json!({})); + events.push(LlmEvent::ToolUse { id, name, input }); + } + _ => {} + } + } + events.push(LlmEvent::Stop); + return Ok(events); + } + + // Fallback: Chat Completions format + let choice = response + .get("choices") + .and_then(|c| c.as_array()) + .and_then(|a| a.first()) + .ok_or_else(|| anyhow!("No choices in response"))?; + + let message = choice.get("message").ok_or_else(|| anyhow!("No message"))?; + + // Text content + if let Some(content) = message.get("content").and_then(|c| c.as_str()) { + if !content.is_empty() { + events.push(LlmEvent::Text(content.to_string())); + } + } + + // Tool calls + if let Some(tool_calls) = message.get("tool_calls").and_then(|t| t.as_array()) { + for tc in tool_calls { + let id = tc + .get("id") + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(); + let name = tc + .get("function") + .and_then(|f| f.get("name")) + .and_then(|n| n.as_str()) + .unwrap_or("") + .to_string(); + let args_str = tc + .get("function") + .and_then(|f| f.get("arguments")) + .and_then(|a| a.as_str()) + .unwrap_or("{}"); + let input: Value = serde_json::from_str(args_str).unwrap_or(json!({})); + events.push(LlmEvent::ToolUse { id, name, input }); + } + } + + let finish_reason = choice + .get("finish_reason") + .and_then(|f| f.as_str()) + .unwrap_or("stop"); + if finish_reason != "tool_calls" { + events.push(LlmEvent::Stop); + } + + Ok(events) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_parse_text_response() { + let resp = json!({ + "content": [{"type": "text", "text": "Hello world"}], + "stop_reason": "end_turn" + }); + let events = parse_anthropic_response(&resp).unwrap(); + assert_eq!(events.len(), 2); + match &events[0] { + LlmEvent::Text(t) => assert_eq!(t, "Hello world"), + _ => panic!("expected Text event"), + } + assert!(matches!(events[1], LlmEvent::Stop)); + } + + #[test] + fn test_parse_tool_use_response() { + let resp = json!({ + "content": [ + {"type": "tool_use", "id": "tu_1", "name": "read", "input": {"path": "/tmp/x"}} + ], + "stop_reason": "tool_use" + }); + let events = parse_anthropic_response(&resp).unwrap(); + assert_eq!(events.len(), 1); + match &events[0] { + LlmEvent::ToolUse { id, name, input } => { + assert_eq!(id, "tu_1"); + assert_eq!(name, "read"); + assert_eq!(input["path"], "/tmp/x"); + } + _ => panic!("expected ToolUse event"), + } + } + + #[test] + fn test_build_request_body() { + let provider = AnthropicProvider { + api_key: "test".to_string(), + model: "claude-sonnet-4-20250514".to_string(), + max_tokens: 4096, + client: reqwest::Client::new(), + }; + let messages = vec![Message { + role: "user".to_string(), + content: vec![ContentBlock::Text { + text: "hello".to_string(), + }], + }]; + let body = provider.build_request_body("system prompt", &messages, &[]); + assert_eq!(body["model"], "claude-sonnet-4-20250514"); + assert_eq!(body["max_tokens"], 4096); + assert_eq!(body["system"], "system prompt"); + assert_eq!(body["messages"][0]["role"], "user"); + } + + #[test] + fn test_parse_openai_text_response() { + let resp = json!({ + "choices": [{"message": {"content": "Hello"}, "finish_reason": "stop"}] + }); + let events = parse_openai_response(&resp).unwrap(); + assert_eq!(events.len(), 2); + assert!(matches!(&events[0], LlmEvent::Text(t) if t == "Hello")); + assert!(matches!(events[1], LlmEvent::Stop)); + } + + #[test] + fn test_parse_openai_tool_call_response() { + let resp = json!({ + "choices": [{"message": { + "content": null, + "tool_calls": [{"id": "call_1", "type": "function", "function": {"name": "read", "arguments": "{\"path\":\"x.txt\"}"}}] + }, "finish_reason": "tool_calls"}] + }); + let events = parse_openai_response(&resp).unwrap(); + assert_eq!(events.len(), 1); + match &events[0] { + LlmEvent::ToolUse { id, name, input } => { + assert_eq!(id, "call_1"); + assert_eq!(name, "read"); + assert_eq!(input["path"], "x.txt"); + } + _ => panic!("expected ToolUse"), + } + } + + #[test] + fn test_parse_openai_empty_choices() { + let resp = json!({"choices": []}); + assert!(parse_openai_response(&resp).is_err()); + } +} diff --git a/openab-agent/src/main.rs b/openab-agent/src/main.rs new file mode 100644 index 000000000..f3cc2cd75 --- /dev/null +++ b/openab-agent/src/main.rs @@ -0,0 +1,73 @@ +mod acp; +mod agent; +mod auth; +mod llm; +mod tools; + +use clap::{Parser, Subcommand}; +use tracing_subscriber::EnvFilter; + +#[derive(Parser)] +#[command(name = "openab-agent", about = "Native Rust coding agent with ACP")] +struct Cli { + #[command(subcommand)] + command: Option, +} + +#[derive(Subcommand)] +enum Commands { + /// Authenticate with an LLM provider + Auth { + #[command(subcommand)] + provider: AuthProvider, + }, +} + +#[derive(Subcommand)] +enum AuthProvider { + /// OpenAI Codex via browser PKCE flow (recommended, full scopes) + CodexOauth { + /// Print URL instead of opening browser + #[arg(long)] + no_browser: bool, + }, + /// OpenAI Codex via device code (headless servers) + CodexDevice, + /// Show stored credentials + Status, +} + +#[tokio::main] +async fn main() { + tracing_subscriber::fmt() + .with_env_filter(EnvFilter::from_default_env()) + .with_writer(std::io::stderr) + .init(); + + let cli = Cli::parse(); + + match cli.command { + None => { + // Default: run ACP server + let mut server = acp::AcpServer::new(); + server.run().await; + } + Some(Commands::Auth { provider }) => match provider { + AuthProvider::CodexOauth { no_browser } => { + if let Err(e) = auth::login_browser_flow(no_browser).await { + eprintln!("❌ Authentication failed: {e}"); + std::process::exit(1); + } + } + AuthProvider::CodexDevice => { + if let Err(e) = auth::login_codex_device_flow().await { + eprintln!("❌ Authentication failed: {e}"); + std::process::exit(1); + } + } + AuthProvider::Status => { + auth::show_status(); + } + }, + } +} diff --git a/openab-agent/src/tools.rs b/openab-agent/src/tools.rs new file mode 100644 index 000000000..e0f898f4f --- /dev/null +++ b/openab-agent/src/tools.rs @@ -0,0 +1,435 @@ +use anyhow::{anyhow, Result}; +use serde_json::{json, Value}; +use std::collections::HashMap; +use std::path::{Path, PathBuf}; +use tokio::process::Command; +use tracing::debug; + +use crate::llm::ToolDef; + +/// Validate that a path is within the allowed working directory. +/// This function has NO side-effects — it never creates directories or files. +fn validate_path(path: &str, working_dir: &Path) -> Result { + let target = if Path::new(path).is_absolute() { + PathBuf::from(path) + } else { + working_dir.join(path) + }; + + // For existing paths, canonicalize directly + if target.exists() { + let canonical = target.canonicalize()?; + let canonical_working = working_dir.canonicalize()?; + if !canonical.starts_with(&canonical_working) { + return Err(anyhow!( + "path traversal denied: {} is outside working directory", + path + )); + } + return Ok(canonical); + } + + // For non-existent paths, walk up to find the nearest existing ancestor + let mut ancestor = target.parent(); + while let Some(p) = ancestor { + if p.exists() { + let canonical_ancestor = p.canonicalize()?; + let canonical_working = working_dir.canonicalize()?; + if !canonical_ancestor.starts_with(&canonical_working) { + return Err(anyhow!( + "path traversal denied: {} is outside working directory", + path + )); + } + // Reconstruct the full path relative to the canonicalized ancestor + let remainder = target.strip_prefix(p).unwrap_or(target.as_path()); + return Ok(canonical_ancestor.join(remainder)); + } + ancestor = p.parent(); + } + + Err(anyhow!( + "path traversal denied: no valid ancestor for {}", + path + )) +} + +/// Build a filtered environment for bash tool execution. +fn build_env(allow_list: &[String]) -> HashMap { + let mut env = HashMap::new(); + for key in &["PATH", "HOME", "USER", "LANG", "TERM", "SHELL"] { + if let Ok(val) = std::env::var(key) { + env.insert(key.to_string(), val); + } + } + for key in allow_list { + if let Ok(val) = std::env::var(key) { + env.insert(key.to_string(), val); + } + } + env +} + +/// Execute a tool call and return the result as a string. +pub async fn execute_tool(name: &str, input: &Value, working_dir: &Path) -> Result { + match name { + "read" => tool_read(input, working_dir), + "write" => tool_write(input, working_dir), + "edit" => tool_edit(input, working_dir), + "bash" => tool_bash(input, working_dir).await, + _ => Err(anyhow!("unknown tool: {name}")), + } +} + +/// Read file contents or list directory. +fn tool_read(input: &Value, working_dir: &Path) -> Result { + let path_str = input + .get("path") + .and_then(|v| v.as_str()) + .ok_or_else(|| anyhow!("read: missing 'path' parameter"))?; + + let path = validate_path(path_str, working_dir)?; + + if path.is_dir() { + let mut entries = Vec::new(); + for entry in std::fs::read_dir(&path)? { + let entry = entry?; + let name = entry.file_name().to_string_lossy().to_string(); + let ft = entry.file_type()?; + if ft.is_dir() { + entries.push(format!("{name}/")); + } else { + entries.push(name); + } + } + entries.sort(); + Ok(entries.join("\n")) + } else { + let content = + std::fs::read_to_string(&path).map_err(|e| anyhow!("read {}: {e}", path.display()))?; + + // Apply optional line range + let offset = input.get("offset").and_then(|v| v.as_u64()).unwrap_or(0) as usize; + let limit = input.get("limit").and_then(|v| v.as_u64()); + + let lines: Vec<&str> = content.lines().collect(); + let start = offset.min(lines.len()); + let end = match limit { + Some(l) => (start + l as usize).min(lines.len()), + None => lines.len(), + }; + + Ok(lines[start..end].join("\n")) + } +} + +/// Create or overwrite a file. +fn tool_write(input: &Value, working_dir: &Path) -> Result { + let path_str = input + .get("path") + .and_then(|v| v.as_str()) + .ok_or_else(|| anyhow!("write: missing 'path' parameter"))?; + let content = input + .get("content") + .and_then(|v| v.as_str()) + .ok_or_else(|| anyhow!("write: missing 'content' parameter"))?; + + let path = validate_path(path_str, working_dir)?; + + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent)?; + } + std::fs::write(&path, content)?; + + Ok(format!( + "wrote {} bytes to {}", + content.len(), + path.display() + )) +} + +/// Replace an exact string in a file. +fn tool_edit(input: &Value, working_dir: &Path) -> Result { + let path_str = input + .get("path") + .and_then(|v| v.as_str()) + .ok_or_else(|| anyhow!("edit: missing 'path' parameter"))?; + let old_str = input + .get("old_str") + .and_then(|v| v.as_str()) + .ok_or_else(|| anyhow!("edit: missing 'old_str' parameter"))?; + let new_str = input + .get("new_str") + .and_then(|v| v.as_str()) + .ok_or_else(|| anyhow!("edit: missing 'new_str' parameter"))?; + + let path = validate_path(path_str, working_dir)?; + let content = std::fs::read_to_string(&path) + .map_err(|e| anyhow!("edit: cannot read {}: {e}", path.display()))?; + + let count = content.matches(old_str).count(); + if count == 0 { + return Err(anyhow!("edit: old_str not found in {}", path.display())); + } + + let new_content = content.replacen(old_str, new_str, 1); + std::fs::write(&path, &new_content)?; + + Ok(format!( + "replaced 1 occurrence in {} ({count} total matches)", + path.display() + )) +} + +/// Execute a shell command with process group isolation and env filtering. +async fn tool_bash(input: &Value, working_dir: &Path) -> Result { + let command = input + .get("command") + .and_then(|v| v.as_str()) + .ok_or_else(|| anyhow!("bash: missing 'command' parameter"))?; + + let cmd_working_dir = input + .get("working_dir") + .and_then(|v| v.as_str()) + .map(|p| { + if Path::new(p).is_absolute() { + PathBuf::from(p) + } else { + working_dir.join(p) + } + }) + .unwrap_or_else(|| working_dir.to_path_buf()); + + let timeout_secs = std::env::var("OPENAB_AGENT_TIMEOUT_SECS") + .ok() + .and_then(|v| v.parse::().ok()) + .unwrap_or(120); + + let env_allow: Vec = std::env::var("OPENAB_AGENT_BASH_ENV_ALLOW") + .unwrap_or_default() + .split(',') + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()) + .collect(); + + let env = build_env(&env_allow); + + debug!("bash: executing '{}' in {:?}", command, cmd_working_dir); + + let mut cmd = Command::new("sh"); + cmd.arg("-c") + .arg(command) + .current_dir(&cmd_working_dir) + .env_clear() + .envs(&env) + .stdin(std::process::Stdio::null()) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::piped()); + + // Create new process group on Unix for clean cleanup + #[cfg(unix)] + unsafe { + #[allow(unused_imports)] + use std::os::unix::process::CommandExt; + cmd.pre_exec(|| { + if libc::setsid() == -1 { + return Err(std::io::Error::last_os_error()); + } + Ok(()) + }); + } + + let child = cmd + .spawn() + .map_err(|e| anyhow!("bash: spawn failed: {e}"))?; + + // Capture pid before wait_with_output takes ownership + #[cfg(unix)] + let child_pid = child.id(); + + let result = tokio::time::timeout( + std::time::Duration::from_secs(timeout_secs), + child.wait_with_output(), + ) + .await; + + match result { + Ok(Ok(output)) => { + let stdout = String::from_utf8_lossy(&output.stdout); + let stderr = String::from_utf8_lossy(&output.stderr); + let code = output.status.code().unwrap_or(-1); + + let mut result = String::new(); + if !stdout.is_empty() { + result.push_str(&stdout); + } + if !stderr.is_empty() { + if !result.is_empty() { + result.push('\n'); + } + result.push_str("[stderr]\n"); + result.push_str(&stderr); + } + if code != 0 { + result.push_str(&format!("\n[exit code: {code}]")); + } + Ok(result) + } + Ok(Err(e)) => Err(anyhow!("bash: execution error: {e}")), + Err(_) => { + // Timeout — kill the process group + #[cfg(unix)] + if let Some(pid) = child_pid { + unsafe { + libc::kill(-(pid as i32), libc::SIGKILL); + } + } + Err(anyhow!("bash: command timed out after {timeout_secs}s")) + } + } +} + +/// Return tool definitions for the LLM. +pub fn tool_definitions() -> Vec { + vec![ + ToolDef { + name: "read".to_string(), + description: "Read file contents or list a directory.".to_string(), + input_schema: json!({ + "type": "object", + "properties": { + "path": { "type": "string", "description": "File or directory path" }, + "offset": { "type": "integer", "description": "Line offset to start reading from (0-indexed)" }, + "limit": { "type": "integer", "description": "Number of lines to read" } + }, + "required": ["path"] + }), + }, + ToolDef { + name: "write".to_string(), + description: "Create or overwrite a file with the given content.".to_string(), + input_schema: json!({ + "type": "object", + "properties": { + "path": { "type": "string", "description": "File path to write" }, + "content": { "type": "string", "description": "Content to write" } + }, + "required": ["path", "content"] + }), + }, + ToolDef { + name: "edit".to_string(), + description: "Replace the first occurrence of old_str with new_str in a file." + .to_string(), + input_schema: json!({ + "type": "object", + "properties": { + "path": { "type": "string", "description": "File path to edit" }, + "old_str": { "type": "string", "description": "Exact string to find" }, + "new_str": { "type": "string", "description": "Replacement string" } + }, + "required": ["path", "old_str", "new_str"] + }), + }, + ToolDef { + name: "bash".to_string(), + description: "Execute a shell command and return stdout/stderr.".to_string(), + input_schema: json!({ + "type": "object", + "properties": { + "command": { "type": "string", "description": "Shell command to execute" }, + "working_dir": { "type": "string", "description": "Working directory (optional, defaults to agent working dir)" } + }, + "required": ["command"] + }), + }, + ] +} + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::TempDir; + + #[test] + fn test_validate_path_within_working_dir() { + let tmp = TempDir::new().unwrap(); + let result = validate_path("test.txt", tmp.path()); + assert!(result.is_ok()); + } + + #[test] + fn test_validate_path_traversal_denied() { + let tmp = TempDir::new().unwrap(); + let result = validate_path("../../etc/passwd", tmp.path()); + assert!(result.is_err()); + assert!(result.unwrap_err().to_string().contains("path traversal")); + } + + #[test] + #[ignore] // Integration test: filesystem access + fn test_tool_write_and_read() { + let tmp = TempDir::new().unwrap(); + let input = json!({ "path": "hello.txt", "content": "hello world" }); + let result = tool_write(&input, tmp.path()).unwrap(); + assert!(result.contains("11 bytes")); + + let read_input = json!({ "path": "hello.txt" }); + let content = tool_read(&read_input, tmp.path()).unwrap(); + assert_eq!(content, "hello world"); + } + + #[test] + #[ignore] // Integration test: filesystem access + fn test_tool_edit() { + let tmp = TempDir::new().unwrap(); + let file_path = tmp.path().join("test.rs"); + std::fs::write(&file_path, "fn main() {\n println!(\"old\");\n}\n").unwrap(); + + let input = json!({ + "path": "test.rs", + "old_str": "println!(\"old\")", + "new_str": "println!(\"new\")" + }); + let result = tool_edit(&input, tmp.path()).unwrap(); + assert!(result.contains("replaced 1 occurrence")); + + let content = std::fs::read_to_string(&file_path).unwrap(); + assert!(content.contains("println!(\"new\")")); + } + + #[test] + #[ignore] // Integration test: filesystem access + fn test_tool_read_directory() { + let tmp = TempDir::new().unwrap(); + std::fs::write(tmp.path().join("a.txt"), "").unwrap(); + std::fs::write(tmp.path().join("b.txt"), "").unwrap(); + std::fs::create_dir(tmp.path().join("subdir")).unwrap(); + + let input = json!({ "path": "." }); + let result = tool_read(&input, tmp.path()).unwrap(); + assert!(result.contains("a.txt")); + assert!(result.contains("b.txt")); + assert!(result.contains("subdir/")); + } + + #[tokio::test] + #[ignore] // Integration test: subprocess execution + async fn test_tool_bash_simple() { + let tmp = TempDir::new().unwrap(); + let input = json!({ "command": "echo hello" }); + let result = tool_bash(&input, tmp.path()).await.unwrap(); + assert_eq!(result.trim(), "hello"); + } + + #[tokio::test] + #[ignore] // Integration test: subprocess execution + async fn test_tool_bash_env_filtered() { + let tmp = TempDir::new().unwrap(); + // Verify that arbitrary env vars are NOT passed through (env is cleared) + let input = json!({ "command": "env | grep -c ANTHROPIC || true" }); + let result = tool_bash(&input, tmp.path()).await.unwrap(); + // With env_clear(), no ANTHROPIC vars should exist in child + assert!(result.trim() == "0" || result.trim().is_empty() || result.contains("[exit code:")); + } +} From b7cd85fa38e63d525d9f679d1d0f3ae808c1c379 Mon Sep 17 00:00:00 2001 From: "openab-app[bot]" <274185012+openab-app[bot]@users.noreply.github.com> Date: Tue, 26 May 2026 17:41:38 -0400 Subject: [PATCH 094/100] release: v0.8.4-beta.5 (#929) Co-authored-by: openab-app[bot] <274185012+openab-app[bot]@users.noreply.github.com> --- charts/openab/Chart.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/charts/openab/Chart.yaml b/charts/openab/Chart.yaml index 8d5780985..df8a512a6 100644 --- a/charts/openab/Chart.yaml +++ b/charts/openab/Chart.yaml @@ -2,5 +2,5 @@ apiVersion: v2 name: openab description: A lightweight, secure, cloud-native ACP harness that bridges Discord and any ACP-compatible coding CLI. type: application -version: 0.8.4-beta.4 -appVersion: "0.8.4-beta.4" +version: 0.8.4-beta.5 +appVersion: "0.8.4-beta.5" From 7dfefb29795cbf9c639ec6d54947c98614475309 Mon Sep 17 00:00:00 2001 From: chaodufashi Date: Tue, 26 May 2026 18:11:03 -0400 Subject: [PATCH 095/100] ci: add Dockerfile.native to build matrix (#930) --- .github/workflows/build-operator.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/build-operator.yml b/.github/workflows/build-operator.yml index 1339ea824..8bc88bedc 100644 --- a/.github/workflows/build-operator.yml +++ b/.github/workflows/build-operator.yml @@ -76,6 +76,7 @@ jobs: - { suffix: "-grok", dockerfile: "Dockerfile.grok", artifact: "grok" } - { suffix: "-antigravity", dockerfile: "Dockerfile.antigravity", artifact: "antigravity" } - { suffix: "-pi", dockerfile: "Dockerfile.pi", artifact: "pi" } + - { suffix: "-native", dockerfile: "Dockerfile.native", artifact: "native" } platform: - { os: linux/amd64, runner: ubuntu-latest } - { os: linux/arm64, runner: ubuntu-24.04-arm } From 4818364c09dae0c53d8f4e28053b9efc52817a3e Mon Sep 17 00:00:00 2001 From: chaodufashi Date: Tue, 26 May 2026 18:19:25 -0400 Subject: [PATCH 096/100] fix(Dockerfile.native): build openab-agent from its own crate directory (#931) --- Dockerfile.native | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/Dockerfile.native b/Dockerfile.native index bcd78acd8..3879f70e4 100644 --- a/Dockerfile.native +++ b/Dockerfile.native @@ -2,10 +2,11 @@ FROM rust:1-bookworm AS builder WORKDIR /build COPY Cargo.toml Cargo.lock ./ +COPY openab-agent/ openab-agent/ RUN mkdir src && echo 'fn main() {}' > src/main.rs && cargo build --release && rm -rf src COPY src/ src/ -COPY openab-agent/ openab-agent/ -RUN touch src/main.rs && cargo build --release --bin openab --bin openab-agent +RUN touch src/main.rs && cargo build --release +RUN cd openab-agent && cargo build --release # --- Runtime stage --- FROM debian:bookworm-slim @@ -24,7 +25,7 @@ ENV HOME=/home/agent WORKDIR /home/agent COPY --from=builder --chown=agent:agent /build/target/release/openab /usr/local/bin/openab -COPY --from=builder --chown=agent:agent /build/target/release/openab-agent /usr/local/bin/openab-agent +COPY --from=builder --chown=agent:agent /build/openab-agent/target/release/openab-agent /usr/local/bin/openab-agent RUN mkdir -p /home/agent/.openab && chown -R agent:agent /home/agent From 5e451fbfaa88c9fe2566f60f5b9c08f2a98e7707 Mon Sep 17 00:00:00 2001 From: chaodufashi Date: Tue, 26 May 2026 18:38:58 -0400 Subject: [PATCH 097/100] ci: add native to merge-manifests matrix (#932) --- .github/workflows/build-operator.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/build-operator.yml b/.github/workflows/build-operator.yml index 8bc88bedc..195891c37 100644 --- a/.github/workflows/build-operator.yml +++ b/.github/workflows/build-operator.yml @@ -144,6 +144,7 @@ jobs: - { suffix: "-grok", artifact: "grok" } - { suffix: "-antigravity", artifact: "antigravity" } - { suffix: "-pi", artifact: "pi" } + - { suffix: "-native", artifact: "native" } runs-on: ubuntu-latest permissions: contents: read From 41a065c013ce923565f18909a44f089bbdd8e70f Mon Sep 17 00:00:00 2001 From: chaodufashi Date: Tue, 26 May 2026 21:25:55 -0400 Subject: [PATCH 098/100] =?UTF-8?q?docs(adr):=20openab-agent=20=E2=80=94?= =?UTF-8?q?=20native=20Rust=20coding=20agent=20(#922)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * docs(adr): propose openab-agent — native Rust coding agent with built-in ACP Single binary, no external runtime, no adapter wrapper needed. Inspired by Pi's minimal design (4 tools, tiny prompt, session trees) but implemented in Rust for zero-overhead ACP integration with openab core. * docs(adr): add required crates and key advantage sections * docs(adr): address review findings from 普渡 and 覺渡 - Fix LlmProvider trait: use BoxStream instead of bare Stream trait (compile error: Stream is a trait, not a concrete type) - Fix tokio-process deprecation: use tokio::process (merged since 0.2) - Add futures crate for Stream/BoxStream - Add Testing Strategy section (unit test boundaries, hand-written mocks, integration test tags, CI pipeline) - Remove deprecated sandbox-exec reliance on macOS - Add explanatory notes on trait object safety design decisions --------- Co-authored-by: chaodu-agent --- docs/adr/openab-agent.md | 462 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 462 insertions(+) create mode 100644 docs/adr/openab-agent.md diff --git a/docs/adr/openab-agent.md b/docs/adr/openab-agent.md new file mode 100644 index 000000000..b1b7bb870 --- /dev/null +++ b/docs/adr/openab-agent.md @@ -0,0 +1,462 @@ +# ADR: openab-agent — Native Rust Coding Agent with Built-in ACP + +- **Status:** Proposed +- **Date:** 2026-05-26 +- **Author:** @chaodu-agent + +--- + +## 1. Context & Motivation + +Today, every coding agent in OpenAB follows the same pattern: + +``` +openab (Rust) ──stdio JSON-RPC──► adapter (Node/Rust) ──spawns──► CLI agent ──HTTP──► LLM API +``` + +This introduces 3–4 layers of indirection, each with its own runtime, dependencies, and failure modes. Every agent requires: + +- A separate Dockerfile (300–800MB images) +- A Node.js or Python runtime +- An ACP adapter wrapper (pi-acp, codex-acp, agy-acp, etc.) +- npm/pip supply-chain management + +Meanwhile, the actual work an agent does is simple: + +1. Receive a prompt +2. Call an LLM API (HTTP POST + SSE) +3. Execute tool calls (read/write/edit/bash) +4. Return the result + +**Proposal:** Build `openab-agent` — a single Rust binary that is both the ACP server and the coding agent, with no external runtime, no wrapper, and no adapter layer. + +--- + +## 2. Design + +### Architecture + +``` +┌─ openab-agent (single Rust binary) ──────────────┐ +│ │ +│ ┌─────────────────────────────────────────────┐ │ +│ │ ACP Layer (stdin/stdout JSON-RPC) │ │ +│ │ - session/new, session/prompt, cancel │ │ +│ └──────────────────┬──────────────────────────┘ │ +│ │ │ +│ ┌──────────────────▼──────────────────────────┐ │ +│ │ Agent Core │ │ +│ │ - Prompt assembly (system + user + tools) │ │ +│ │ - Tool dispatch loop │ │ +│ │ - Session tree (branching history) │ │ +│ └──────────────────┬──────────────────────────┘ │ +│ │ │ +│ ┌──────────────────▼──────────────────────────┐ │ +│ │ LLM Client (reqwest + SSE) │ │ +│ │ - OpenAI-compatible (GPT, Codex, DeepSeek) │ │ +│ │ - Anthropic (Claude) │ │ +│ │ - Google (Gemini) │ │ +│ └─────────────────────────────────────────────┘ │ +│ │ +│ ┌─────────────────────────────────────────────┐ │ +│ │ Tools (4 only) │ │ +│ │ - read: file/directory reading │ │ +│ │ - write: file creation │ │ +│ │ - edit: string replacement in files │ │ +│ │ - bash: command execution (sandboxed) │ │ +│ └─────────────────────────────────────────────┘ │ +│ │ +└───────────────────────────────────────────────────┘ +``` + +### Comparison with Existing Agents + +| Aspect | Existing (e.g. Pi, Codex) | openab-agent | +|--------|---------------------------|--------------| +| Layers | openab → adapter → CLI → LLM | openab → **agent** → LLM | +| Runtime | Node.js / Python | None (static binary) | +| Image size | 300–800MB | ~20MB (distroless) | +| Cold start | 1–3s | <50ms | +| ACP support | Requires wrapper | Native, zero overhead | +| Dependencies | npm ecosystem | Minimal crates | +| Supply-chain risk | High (node_modules) | Low (cargo audit) | + +### Required Crates + +Only four crates are needed beyond what openab core already uses: + +- `reqwest` — HTTP client (LLM API calls) +- `serde` / `serde_json` — JSON serialization +- `tokio` — async runtime + process management (`tokio::process`) (already used in openab) +- `futures` — `Stream` trait and `BoxStream` for async streaming + +> **Note:** `tokio-process` was merged into `tokio::process` in tokio 0.2 and the standalone crate is deprecated. All process spawning uses `tokio::process::Command` directly. + +Nothing else. Can share code with openab core (ACP types, session pool logic). + +### Key Advantage: Deep Integration + +Because we own the agent and it shares the same language as openab core, deep integration is possible — a future library mode can bypass stdio entirely, using in-process function calls to eliminate all IPC overhead. + +### Design Principles (inspired by Pi) + +1. **Minimal tool surface** — 4 tools only (read, write, edit, bash). Maximizes context window for actual code. +2. **Tiny system prompt** — Agent instructions fit in ~500 tokens. No bloated tool descriptions. +3. **Multi-model** — Provider-agnostic. Switch models via config or mid-session command. +4. **Session trees** — Branching conversation history. Explore multiple approaches without losing context. +5. **No SDK dependency** — LLM APIs are just HTTP. A thin `reqwest` client (~300 lines) covers all providers. + +--- + +## 3. LLM Client Design + +The LLM client is intentionally thin — no SDK, just HTTP: + +```rust +use futures::stream::BoxStream; + +// Unified trait for all providers +trait LlmProvider: Send + Sync { + fn chat<'a>( + &'a self, + messages: &'a [Message], + tools: &'a [Tool], + ) -> Pin>> + Send + 'a>>; +} + +// Implementations are ~100 lines each +struct OpenAiProvider { base_url: String, api_key: String, model: String } +struct AnthropicProvider { api_key: String, model: String } +struct GoogleProvider { api_key: String, model: String } +``` + +> **Note:** `Stream` is a trait (from `futures`), not a concrete type. Returning it directly from a trait method would not compile. We use `BoxStream<'a, Event>` (i.e., `Pin + Send + 'a>>`) to provide a type-erased, object-safe return type. The `async fn` in traits is similarly desugared to a boxed future for object safety. + +All three major APIs follow the same pattern: +- POST JSON body with messages + tool definitions +- Stream SSE events back +- Parse tool_use / function_call blocks +- Loop until stop + +Provider differences are minor (header format, JSON schema for tools, SSE event names) and well-contained in ~300 lines per provider. + +### API Change Tracking & Version Pinning + +Without an official SDK, API changes must be tracked deliberately. Strategy: + +- **Pin API versions in headers**: `anthropic-version: 2023-06-01`, `x-api-version` for OpenAI (when available) +- **Model version pinning**: use dated model snapshots (e.g., `claude-sonnet-4-20250514`) not aliases (`claude-sonnet-4`) in default config +- **CI canary job**: weekly integration test against each provider's API with a minimal prompt. Failures trigger alerts, not breakage. +- **Provider feature flags**: new API features (extended thinking, computer use, etc.) are gated behind feature flags, not auto-enabled +- **Changelog tracking**: maintain `PROVIDERS.md` documenting supported API versions, known breaking changes, and migration notes +- **OpenAI-compatible fallback**: providers implementing the OpenAI chat completions spec (DeepSeek, Groq, Together, Ollama, etc.) require zero additional code — only `base_url` changes + +--- + +## 4. Tool Implementation + +| Tool | Input | Behavior | +|------|-------|----------| +| `read` | path, optional line range | Read file contents or list directory | +| `write` | path, content | Create or overwrite file | +| `edit` | path, old_str, new_str | Replace exact string in file | +| `bash` | command, optional working_dir | Execute shell command, return stdout/stderr | + +### Path Security (read/write/edit tools) + +All file tools enforce **path confinement** to prevent path traversal attacks: + +- All paths are canonicalized (`std::fs::canonicalize`) before access +- Resolved path must be within `working_dir` or explicitly allowed directories +- Symlinks are resolved and checked against the boundary +- Attempts to escape (e.g., `../../etc/passwd`) return an error, not file contents + +```rust +fn validate_path(path: &Path, working_dir: &Path) -> Result { + let canonical = path.canonicalize()?; + if !canonical.starts_with(working_dir) { + return Err(Error::PathTraversal(path.display().to_string())); + } + Ok(canonical) +} +``` + +### Sandboxing (bash tool) + +The `bash` tool runs commands via `tokio::process::Command` with: + +- Configurable timeout (default: 120s) +- **Process group kill on timeout** — uses `setsid` + `kill(-pgid)` to ensure all child/grandchild processes are terminated, preventing orphan process leaks +- Optional `bubblewrap` (bwrap) sandboxing on Linux; falls back to basic process isolation on macOS (see Cross-Platform Sandboxing below) +- Working directory scoped to agent's `working_dir` +- No network access restriction by default (agent needs to call APIs, git, etc.) + +### Environment Variable Filtering + +The `bash` tool does **NOT** inherit the agent's full environment. Instead: + +- **Deny-list by default**: sensitive variables are stripped before spawning child processes: + - `ANTHROPIC_API_KEY`, `OPENAI_API_KEY`, `GOOGLE_API_KEY`, `OPENAB_AGENT_*` (all provider keys) + - Any variable matching `*_SECRET`, `*_TOKEN`, `*_KEY` patterns (configurable) +- **Allow-list passthrough**: only explicitly declared safe variables are inherited: + - `PATH`, `HOME`, `USER`, `LANG`, `TERM`, `SHELL` + - Variables listed in `OPENAB_AGENT_BASH_ENV_ALLOW` (comma-separated) +- This prevents prompt injection attacks from exfiltrating API keys via `curl`/`wget` + +```rust +fn build_env(config: &AgentConfig) -> HashMap { + let mut env = HashMap::new(); + // Only pass safe defaults + for key in &["PATH", "HOME", "USER", "LANG", "TERM", "SHELL"] { + if let Ok(val) = std::env::var(key) { + env.insert(key.to_string(), val); + } + } + // Add user-configured allow-list + for key in &config.bash_env_allow { + if let Ok(val) = std::env::var(key) { + env.insert(key.to_string(), val); + } + } + env +} +``` + +### Cross-Platform Sandboxing + +| Platform | Sandboxing | Notes | +|----------|-----------|-------| +| Linux | `bubblewrap` (bwrap) | Full filesystem/network namespace isolation | +| macOS | Process group isolation + env filtering | Primary mechanism for local dev. `sandbox-exec` is deprecated by Apple and may be removed in future macOS versions — not relied upon. | +| Fallback | Process group isolation + env filtering | Minimum viable security without platform-specific tools | + +For production (Linux containers), `bubblewrap` is the primary mechanism. For local macOS development, the env filtering + path confinement provides baseline security without requiring bwrap. + +--- + +## 5. Session Tree + +Sessions are stored as a tree structure, not a flat list: + +``` +root +├── turn-1 (user: "explain this code") +│ └── turn-2 (assistant: "This code does...") +│ ├── turn-3a (user: "refactor it") ← branch A +│ │ └── turn-4a (assistant: "Here's the refactored...") +│ └── turn-3b (user: "add tests instead") ← branch B +│ └── turn-4b (assistant: "Here are the tests...") +``` + +Benefits: +- Explore multiple approaches from a decision point +- Rollback without losing the exploration +- Persisted to disk as JSON for session resume + +### Garbage Collection / Pruning + +To prevent unbounded growth in long-running deployments: + +- **Max tree depth**: configurable (default: 200 turns per branch). Oldest turns are summarized when exceeded. +- **Max branches**: configurable (default: 20 per session). Least-recently-used branches are pruned on overflow. +- **Inactive branch TTL**: branches not accessed for N hours (default: 24h) are eligible for pruning. +- **Disk persistence cap**: per-session JSON file capped at 10MB. Exceeding triggers forced summarization of oldest branches. +- **GC trigger**: runs on every Nth turn (default: 10) or when memory pressure is detected. + +--- + +## 5a. Context Window Management + +The agent must operate within LLM context limits (typically 128K–200K tokens). Strategy: + +### Token Counting + +- Use `tiktoken-rs` for OpenAI models, character-based estimation (×0.3) for others +- Track cumulative token usage per session branch + +### Window Overflow Strategy (ordered by priority) + +1. **Tool output truncation** — large `bash` stdout or `read` results are truncated to configurable max (default: 30K tokens) with a "truncated, showing first/last N lines" indicator +2. **Oldest turn summarization** — when context exceeds 80% of model limit, oldest turns (excluding system prompt and last 4 turns) are replaced with a one-paragraph summary generated by the same LLM +3. **Branch instead of truncate** — if the user explicitly branches, the new branch starts with a compact summary of the parent path, preserving full context in the original branch +4. **Hard cap rejection** — if a single tool output exceeds 50% of context window, reject and ask the user to narrow the request + +### Configuration + +```toml +[agent.context] +max_context_percent = 80 # trigger summarization at 80% of model limit +max_tool_output_tokens = 30000 # truncate individual tool outputs +summarize_after_turns = 20 # summarize turns older than the last 20 +``` + +--- + +## 6. Configuration + +```toml +[agent] +command = "openab-agent" +working_dir = "/home/agent" + +[agent.env] +# Provider selection (one of): +OPENAB_AGENT_PROVIDER = "anthropic" # or "openai", "google", "openai-compatible" +OPENAB_AGENT_MODEL = "claude-sonnet-4-20250514" + +# Auth (provider-specific): +ANTHROPIC_API_KEY = "${ANTHROPIC_API_KEY}" +# or: OPENAI_API_KEY, GOOGLE_API_KEY, etc. + +# Optional: +OPENAB_AGENT_MAX_TOKENS = "8192" +OPENAB_AGENT_TIMEOUT_SECS = "120" +``` + +### Steering Files + +openab-agent reads steering files in the same pattern as other agents: + +- `AGENTS.md` in working directory (hot memory, always loaded) +- `.openab-agent/system.md` — custom system prompt override +- `.openab-agent/append.md` — append to default system prompt + +--- + +## 7. Future: Library Mode (Deferred — Not in v1 Scope) + +> **Note:** This section documents a potential future optimization. It is explicitly **out of scope** for v0.1–v0.3 and will require its own ADR if pursued. + +Because openab-agent is Rust (same as openab core), a future optimization is **in-process mode** — no stdio, no JSON-RPC serialization: + +```rust +// Current: IPC over stdio (v0.1–v0.3) +openab::spawn_process("openab-agent", &["--acp"]) // stdin/stdout JSON-RPC + +// Future: direct function call (requires separate ADR) +let agent = openab_agent::Agent::new(config); +let response = agent.prompt(session_id, messages).await; // zero-copy +``` + +### Known Risks (to be addressed in future ADR) + +- **Panic propagation**: an agent panic (e.g., malformed SSE parse) would crash the entire openab process. Mitigation: `catch_unwind` boundaries or `tokio::task::spawn` with panic hooks. +- **Resource isolation**: in-process mode shares memory/threads with openab core. A runaway agent could starve the session pool. +- **Blast radius**: process isolation (current design) provides natural fault containment. Library mode trades this for performance. + +**Decision**: v1 ships as a standalone binary with stdio ACP. Library mode is a v2+ exploration only if IPC overhead proves to be a measurable bottleneck in production. + +--- + +## 8. Rollout Plan + +| Phase | Scope | Deliverable | +|-------|-------|-------------| +| **v0.1** | Scaffold + ACP layer + single provider (Anthropic) | Working agent, 4 tools, flat session | +| **v0.2** | Multi-provider + session tree + steering files | Feature parity with Pi's core | +| **v0.3** | Dockerfile + Helm chart + CI | Production-ready deployment | +| **v0.4** | Library mode exploration | In-process integration with openab core | + +--- + +## 9. Testing Strategy + +### Unit Test Boundaries + +Following the project's unit test ADR, operations involving network, filesystem, or subprocess are **integration tests only**. Unit tests cover pure logic: + +| Layer | Unit-Testable | How | +|-------|--------------|-----| +| Prompt assembly | ✅ | Hand-written mock `LlmProvider` returning canned `BoxStream` | +| Tool dispatch routing | ✅ | Mock tool implementations (no real FS/process) | +| Session tree operations | ✅ | Pure data structure manipulation | +| Token counting / context management | ✅ | Pure computation | +| SSE event parsing | ✅ | Feed raw bytes, assert parsed `Event` structs | +| LLM HTTP calls | ❌ (integration) | Real HTTP against provider or local mock server | +| File tools (read/write/edit) | ❌ (integration) | Real filesystem in temp dirs | +| Bash tool | ❌ (integration) | Real subprocess execution | + +### Hand-Written Mocks (no `mockall`) + +Per team convention, all mocks are hand-written: + +```rust +struct MockLlmProvider { + responses: Vec>, + call_count: AtomicUsize, +} + +impl LlmProvider for MockLlmProvider { + fn chat<'a>( + &'a self, + _messages: &'a [Message], + _tools: &'a [Tool], + ) -> Pin>> + Send + 'a>> { + let idx = self.call_count.fetch_add(1, Ordering::SeqCst); + let events = self.responses[idx].clone(); + Box::pin(async move { + Ok(Box::pin(futures::stream::iter(events.into_iter())) as BoxStream<'a, Event>) + }) + } +} +``` + +### Integration Tests + +- Tagged with `#[cfg(test)]` + `#[ignore]` for CI gating +- LLM integration tests require `OPENAB_TEST_PROVIDER` env var +- File/bash tool tests use `tempdir` for isolation +- CI runs integration tests in a separate job with real credentials (not on every PR) + +### CI Pipeline + +``` +PR push → cargo fmt --check → cargo clippy → cargo test (unit only) + ↓ +merge to main → cargo test (unit + integration) → canary deploy +``` + +--- + +## 10. Open Questions + +| Question | Options | Notes | +|----------|---------|-------| +| **Crate name** | `openab-agent` as a workspace member vs separate repo | Workspace member keeps it close to openab core | +| **Subscription auth** | Support OAuth flows (Claude Pro, ChatGPT Plus) or API-key only for v1? | API-key only for v1; subscription auth adds complexity | +| **Permission model** | Auto-approve all tool calls vs interactive approval? | Auto-approve for v1 (matches OpenAB's `--trust-all-tools` pattern) | +| **Context window management** | Truncate old turns vs summarize vs session tree branching? | Session tree branching for v1; summarization for v2 | + +--- + +## Consequences + +### Positive + +- **Zero external runtime** — no Node.js, Python, or npm. Single static binary. +- **Minimal attack surface** — no node_modules supply chain, no adapter layer vulnerabilities. +- **Fastest cold start** — <50ms vs 1–3s for Node-based agents. +- **Smallest image** — ~20MB distroless vs 300–800MB for existing agents. +- **Native ACP** — no wrapper overhead, no adapter bugs, no version mismatches. +- **Same language as openab** — shared types, potential library mode, unified toolchain. +- **Full control** — no upstream CLI breaking changes; we own the entire stack. + +### Negative + +- **LLM API maintenance** — must track API changes manually without official SDKs. +- **No subscription auth (v1)** — API key only initially; users with Claude Pro/ChatGPT Plus subscriptions still need Pi or Codex. +- **Feature gap** — v1 will lack features mature agents have (image support, MCP, web search tools). +- **Development effort** — building from scratch vs leveraging existing open-source agents. + +### Risks + +- **API instability** — if providers make breaking changes frequently, maintenance burden grows. Mitigated by pinning API versions and weekly CI canary. +- **Scope creep** — temptation to add more tools/features. Mitigated by the "4 tools only" design principle as a hard constraint for v1. + +--- + +## References + +- [Pi coding agent](https://github.com/earendil-works/pi) — design inspiration (minimal tools, session trees, multi-model) +- [Agent Client Protocol](https://github.com/anthropics/agent-protocol) — ACP spec +- [OpenAB](https://github.com/openabdev/openab) — host runtime From 7ff7547954b7f3497cc1dce64c3994ca62ea99c5 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 27 May 2026 02:15:32 +0000 Subject: [PATCH 099/100] fix(gateway): suppress dead_code warnings in media.rs to pass CI clippy gateway/src/media.rs exports utility functions (resize_and_compress, audio_extension, is_text_extension) and constants not yet used by current adapters. Add #![allow(dead_code)] so CI clippy -D warnings does not treat them as errors. https://claude.ai/code/session_01L2ZNFRhCDX2HxB9QEPJWgK --- gateway/src/media.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/gateway/src/media.rs b/gateway/src/media.rs index f6eb88565..52613e79d 100644 --- a/gateway/src/media.rs +++ b/gateway/src/media.rs @@ -1,3 +1,4 @@ +#![allow(dead_code)] use image::ImageReader; use std::io::Cursor; From ccadffe017773c99c16938002b338d51fdd2a2bc Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 27 May 2026 02:15:47 +0000 Subject: [PATCH 100/100] chore: update gateway/Cargo.lock after media.rs dependency changes https://claude.ai/code/session_01L2ZNFRhCDX2HxB9QEPJWgK --- gateway/Cargo.lock | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gateway/Cargo.lock b/gateway/Cargo.lock index 747c0395c..9a93e1098 100644 --- a/gateway/Cargo.lock +++ b/gateway/Cargo.lock @@ -1112,7 +1112,7 @@ checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" [[package]] name = "openab-gateway" -version = "0.4.0" +version = "0.5.1" dependencies = [ "aes", "anyhow",