From 57cc547a0d8fb2b85d42ed701ae7735cea0da300 Mon Sep 17 00:00:00 2001 From: chaodu-agent Date: Sat, 11 Jul 2026 09:27:55 -0400 Subject: [PATCH] feat(line): first-class [line] trust section (Phase 1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a [line] config section for L3 identity trust, replacing the uniform GATEWAY_ALLOW_ALL_USERS/GATEWAY_ALLOWED_USERS env vars for LINE. First slice of #1355, mirroring the [telegram] pattern (#1297). - config.rs: LineConfig { allow_all_users, allowed_users } with LINE_ALLOW_ALL_USERS / LINE_ALLOWED_USERS env fallbacks and deny-all default (identity-trust-none ADR). Trust-only by design — channel credentials stay on LINE_CHANNEL_SECRET / LINE_CHANNEL_ACCESS_TOKEN. - main.rs: [line] (or LINE_* env) overrides the uniform GATEWAY_* seed in the trust registry, exactly like the telegram override. When LINE is active and still driven by the legacy GATEWAY_* env, log a Phase 1 deprecation warning (becomes an error in Phase 2, #1356). - docs: config.toml.example, config-reference.md ([line] section), line.md (User Trust section + env table). - tests: TOML parse + all env resolution scenarios in one test fn (env-race safety, same pattern as telegram_resolve_all_scenarios). Group policy (open/members) and Reply-API deny-echo are follow-ups on the issue. Refs #1355, umbrella #1356, ADR #1291. --- config.toml.example | 12 +++ crates/openab-core/src/config.rs | 155 +++++++++++++++++++++++++++++++ docs/config-reference.md | 19 ++++ docs/line.md | 16 ++++ src/main.rs | 52 +++++++++++ 5 files changed, 254 insertions(+) diff --git a/config.toml.example b/config.toml.example index b9f8344f1..28b8bb70a 100644 --- a/config.toml.example +++ b/config.toml.example @@ -73,6 +73,18 @@ allowed_channels = ["1234567890"] # ↑ omitted + non-empty list → auto- # streaming = true # override; defaults to follow rich_messages # webhook_path = "/webhook/telegram" # default /webhook/telegram +# --- LINE (first-class trust section; replaces uniform GATEWAY_* env for LINE) --- +# L3 identity trust for the LINE adapter (identity-trust-none ADR). Each field +# falls back to its LINE_* env var when unset, then to deny-all. Channel +# credentials stay on LINE_CHANNEL_SECRET / LINE_CHANNEL_ACCESS_TOKEN env vars. +# NOTE: relying on GATEWAY_ALLOW_ALL_USERS / GATEWAY_ALLOWED_USERS for LINE is +# deprecated (warns at startup; becomes an error in Phase 2). +# [line] +# allow_all_users = false # true = any user; omitted/false = deny-all except allowed_users +# # env fallback: LINE_ALLOW_ALL_USERS +# allowed_users = ["U1234567890abcdef0123456789abcdef"] # LINE user IDs (U…, 33 chars) +# # env fallback: LINE_ALLOWED_USERS (comma-separated) + # --- AgentCore Runtime (alternative to [agent]) --- # When [agentcore] is set and [agent].command is not explicitly provided, # OAB auto-spawns the bundled agentcore-acp adapter. No manual wiring needed. diff --git a/crates/openab-core/src/config.rs b/crates/openab-core/src/config.rs index ec3d5a2ee..06c05b597 100644 --- a/crates/openab-core/src/config.rs +++ b/crates/openab-core/src/config.rs @@ -172,6 +172,7 @@ pub struct Config { pub slack: Option, pub gateway: Option, pub telegram: Option, + pub line: Option, pub agentcore: Option, #[serde(default)] pub agent: AgentConfig, @@ -764,6 +765,74 @@ fn env_flag_not_false(key: &str) -> bool { .unwrap_or(true) } +/// First-class `[line]` section — L3 identity trust for the LINE adapter +/// (identity-trust-none ADR, Phase 1). Mirrors [`TelegramConfig`]'s trust +/// fields and resolution order: `[line].field` (with `${}` expansion) → +/// `LINE_*` env var → default. +/// +/// Trust-only by design: LINE channel credentials stay on the gateway env vars +/// (`LINE_CHANNEL_SECRET` / `LINE_CHANNEL_ACCESS_TOKEN`) that the webhook +/// adapter reads. Group policy (`open`/`members` for `"unknown"` senders) is a +/// follow-up on #1355. +#[derive(Debug, Clone, Default, Deserialize)] +#[serde(default)] +pub struct LineConfig { + /// Explicit flag: true = allow all users, false = check `allowed_users`. + /// When not set, defaults to `false` (deny-all, per identity-trust-none ADR). + /// Set `true` explicitly to allow all users. Env fallback: + /// `LINE_ALLOW_ALL_USERS` (empty string treated as unset). + /// + /// **Note:** When this resolves to `true`, the `allowed_users` list is + /// bypassed entirely — all users are permitted regardless of list contents. + pub allow_all_users: Option, + /// LINE user IDs (`U…`, 33 chars) allowed to interact with the bot. Only + /// checked when `allow_all_users` resolves to `false`. Env fallback: + /// `LINE_ALLOWED_USERS` (comma-separated). + /// `None` = not set (fall back to env); `Some([])` = explicit empty (deny all). + pub allowed_users: Option>, +} + +/// Fully resolved LINE trust settings (config → env → default applied). +#[derive(Debug, Clone)] +pub struct ResolvedLine { + pub allow_all_users: bool, + pub allowed_users: Vec, +} + +impl LineConfig { + /// Resolve every field: config value (if set) → `LINE_*` env → default. + /// Same shape as [`TelegramConfig::resolve`] for the shared trust fields. + pub fn resolve(&self) -> ResolvedLine { + let allowed_users: Vec = match &self.allowed_users { + Some(list) => list.clone(), + None => std::env::var("LINE_ALLOWED_USERS") + .unwrap_or_default() + .split(',') + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()) + .collect(), + }; + ResolvedLine { + allow_all_users: self.allow_all_users.unwrap_or_else(|| { + std::env::var("LINE_ALLOW_ALL_USERS") + .ok() + .filter(|v| !v.is_empty()) + .map(|v| v != "0" && !v.eq_ignore_ascii_case("false")) + .unwrap_or(false) + }), + allowed_users, + } + } + + /// Whether any first-class LINE trust configuration exists — a `[line]` + /// section or `LINE_ALLOW_ALL_USERS` / `LINE_ALLOWED_USERS` env. Used by + /// the binary to decide whether LINE trust still rides on the deprecated + /// uniform `GATEWAY_*` seed (and to warn when it does). + pub fn env_trust_present() -> bool { + std::env::var("LINE_ALLOW_ALL_USERS").is_ok() || std::env::var("LINE_ALLOWED_USERS").is_ok() + } +} + /// Raw intermediate struct for serde — uses `Option` to detect explicit fields. #[derive(Debug, Deserialize)] #[serde(default)] @@ -1832,6 +1901,92 @@ webhook_path = "/hook/tg" assert_eq!(tg.webhook_path.as_deref(), Some("/hook/tg")); } + #[test] + fn line_section_parses_from_toml() { + let toml_str = r#" +[discord] +bot_token = "x" + +[line] +allow_all_users = false +allowed_users = ["U1234567890abcdef0123456789abcdef"] +"#; + let cfg = parse_config_str(toml_str, "test").unwrap(); + let line = cfg.line.expect("line section"); + assert_eq!(line.allow_all_users, Some(false)); + assert_eq!( + line.allowed_users.as_deref(), + Some(&["U1234567890abcdef0123456789abcdef".to_string()][..]) + ); + + // Absent section → None (trust falls back to legacy GATEWAY_* seed). + let cfg = parse_config_str("[discord]\nbot_token = \"x\"\n", "test").unwrap(); + assert!(cfg.line.is_none()); + } + + /// All `LINE_*` env scenarios in ONE test — std::env is process-global and + /// cargo runs tests in parallel, so splitting these would race (same + /// pattern as `telegram_resolve_all_scenarios`). + #[test] + fn line_resolve_all_scenarios() { + // --- Scenario 1: defaults — deny-all per identity-trust-none ADR --- + std::env::remove_var("LINE_ALLOW_ALL_USERS"); + std::env::remove_var("LINE_ALLOWED_USERS"); + let r = LineConfig::default().resolve(); + assert!(!r.allow_all_users); + assert!(r.allowed_users.is_empty()); + assert!(!LineConfig::env_trust_present()); + + // --- Scenario 2: config wins over env --- + std::env::set_var("LINE_ALLOW_ALL_USERS", "true"); + std::env::set_var("LINE_ALLOWED_USERS", "Uzzz"); // must be ignored — config list wins + let cfg = LineConfig { + allow_all_users: Some(false), + allowed_users: Some(vec!["Uaaa".into(), "Ubbb".into()]), + }; + let r = cfg.resolve(); + assert!(!r.allow_all_users); + assert_eq!( + r.allowed_users, + vec!["Uaaa".to_string(), "Ubbb".to_string()] + ); + + // --- Scenario 3: env fallback when config unset (comma-separated, + // trimmed, empties dropped) --- + std::env::set_var("LINE_ALLOWED_USERS", " Uaaa , Ubbb,,Uccc "); + let r = LineConfig::default().resolve(); + assert!(r.allow_all_users); // from LINE_ALLOW_ALL_USERS=true + assert_eq!( + r.allowed_users, + vec!["Uaaa".to_string(), "Ubbb".to_string(), "Uccc".to_string()] + ); + assert!(LineConfig::env_trust_present()); + + // --- Scenario 4: empty-string env flag treated as unset → deny-all --- + std::env::set_var("LINE_ALLOW_ALL_USERS", ""); + std::env::remove_var("LINE_ALLOWED_USERS"); + let r = LineConfig::default().resolve(); + assert!(!r.allow_all_users); + + // --- Scenario 5: "0"/"false" env values resolve false --- + std::env::set_var("LINE_ALLOW_ALL_USERS", "0"); + assert!(!LineConfig::default().resolve().allow_all_users); + std::env::set_var("LINE_ALLOW_ALL_USERS", "false"); + assert!(!LineConfig::default().resolve().allow_all_users); + + // --- Scenario 6: explicit empty config list = deny-all, ignores env --- + std::env::set_var("LINE_ALLOWED_USERS", "Uzzz"); + let cfg = LineConfig { + allow_all_users: None, + allowed_users: Some(vec![]), + }; + let r = cfg.resolve(); + assert!(r.allowed_users.is_empty()); + + std::env::remove_var("LINE_ALLOW_ALL_USERS"); + std::env::remove_var("LINE_ALLOWED_USERS"); + } + #[test] fn hooks_any_configured_false_when_empty() { let h = HooksConfig::default(); diff --git a/docs/config-reference.md b/docs/config-reference.md index ff51cd3ff..938c9fe3d 100644 --- a/docs/config-reference.md +++ b/docs/config-reference.md @@ -135,6 +135,25 @@ Custom Gateway adapter for platforms like Telegram, LINE, Feishu/Lark, and Googl --- +## `[line]` + +First-class L3 identity trust for the LINE adapter (identity-trust-none ADR, Phase 1). Replaces the uniform `GATEWAY_ALLOW_ALL_USERS` / `GATEWAY_ALLOWED_USERS` env vars for LINE — relying on those for LINE is deprecated and warns at startup. Channel credentials remain on the `LINE_CHANNEL_SECRET` / `LINE_CHANNEL_ACCESS_TOKEN` env vars. + +Each field resolves: config value → `LINE_*` env var → default (deny-all). + +| Key | Type | Default | Description | +|-----|------|---------|-------------| +| `allow_all_users` | bool \| omit | `false` (deny-all) | `true` = any user may interact (bypasses `allowed_users` entirely); `false`/omitted = only `allowed_users`. Env fallback: `LINE_ALLOW_ALL_USERS`. | +| `allowed_users` | string[] | `[]` | LINE user IDs (`U…`, 33 chars) allowed to interact. Only checked when `allow_all_users` resolves to false. Env fallback: `LINE_ALLOWED_USERS` (comma-separated). | + +```toml +[line] +allowed_users = ["U1234567890abcdef0123456789abcdef"] +# allow_all_users = true # explicit opt-in only — any user can drive the agent +``` + +--- + ## `[agent]` The AI agent subprocess that OpenAB spawns to handle messages via ACP. diff --git a/docs/line.md b/docs/line.md index 559d8cb9b..5054d491c 100644 --- a/docs/line.md +++ b/docs/line.md @@ -102,6 +102,20 @@ platform = "line" > **Tip:** To find a LINE user ID, check the gateway logs — the sender ID is logged for each incoming message. By default all users and channels are allowed. Setting `allowed_users` or `allowed_channels` automatically restricts access to only those listed. +### User Trust (`[line]` section) + +Identity trust defaults to **deny-all** (identity-trust-none ADR): unknown senders are rejected until explicitly admitted. Configure trust with a first-class `[line]` section: + +```toml +[line] +allowed_users = ["U1234567890abcdef0123456789abcdef"] # LINE user IDs (U…, 33 chars) +# allow_all_users = true # explicit opt-in only — any user can drive the agent +``` + +Each field falls back to its `LINE_ALLOW_ALL_USERS` / `LINE_ALLOWED_USERS` env var when unset. This replaces the uniform `GATEWAY_ALLOW_ALL_USERS` / `GATEWAY_ALLOWED_USERS` env vars for LINE: + +> ⚠️ **Deprecated:** driving LINE trust through `GATEWAY_ALLOW_ALL_USERS` / `GATEWAY_ALLOWED_USERS` still works but logs a startup warning; it will become a startup error in a later phase. Migrate to `[line]` (or `LINE_*` env vars). + ## 6. Add the Bot as Friend In the LINE Developers Console → **Messaging API** tab → scan the QR code with your LINE app, or search for the bot by its LINE ID. @@ -140,6 +154,8 @@ In the LINE Developers Console → **Messaging API** tab → scan the QR code wi |---|---|---| | `LINE_CHANNEL_SECRET` | Yes | Channel secret for webhook signature validation | | `LINE_CHANNEL_ACCESS_TOKEN` | Yes | Channel access token for Reply/Push Message API and LINE-hosted image/audio downloads | +| `LINE_ALLOW_ALL_USERS` | No | `true` = any user may interact. Fallback for `[line].allow_all_users`; default deny-all | +| `LINE_ALLOWED_USERS` | No | Comma-separated LINE user IDs. Fallback for `[line].allowed_users` | ## Troubleshooting diff --git a/src/main.rs b/src/main.rs index 43a7a32f4..90fdc1517 100644 --- a/src/main.rs +++ b/src/main.rs @@ -368,6 +368,58 @@ async fn main() -> anyhow::Result<()> { ), ); } + + // LINE: L3 (identity) mirrors the resolved + // [line].allow_all_users/allowed_users, so config.toml can restrict + // who can message the bot without the uniform + // GATEWAY_ALLOW_ALL_USERS/GATEWAY_ALLOWED_USERS env vars (#1355). L2 + // (channels) has no LINE-specific concept distinct from the generic + // gateway model yet (group policy is a follow-up), so it stays on the + // shared GATEWAY_* values set above. + // + // Also resolves when running env-only (no [line] section but + // LINE_ALLOW_ALL_USERS / LINE_ALLOWED_USERS set), matching the + // Telegram pattern. + let line_resolved = if let Some(l) = &cfg.line { + Some(l.resolve()) + } else if config::LineConfig::env_trust_present() { + Some(config::LineConfig::default().resolve()) + } else { + None + }; + match line_resolved { + Some(r) => { + reg.insert( + "line", + TrustConfig::new( + Some(allow_all_channels), + allowed_channels.clone(), + None, + Some(r.allow_all_users), + r.allowed_users, + ), + ); + } + None => { + // Phase 1 deprecation (#1355/#1356): LINE trust still rides on + // the uniform GATEWAY_* seed. Warn when the unified LINE + // adapter is active and the legacy env is what admits users, + // so operators migrate before Phase 2 turns this into an error. + let line_active = + cfg!(feature = "line") && std::env::var("LINE_CHANNEL_SECRET").is_ok(); + let legacy_env_set = std::env::var("GATEWAY_ALLOW_ALL_USERS").is_ok() + || std::env::var("GATEWAY_ALLOWED_USERS").is_ok(); + if line_active && legacy_env_set { + warn!( + "LINE trust is driven by deprecated GATEWAY_ALLOW_ALL_USERS/\ + GATEWAY_ALLOWED_USERS env vars — migrate to a [line] section \ + (allow_all_users/allowed_users) or LINE_ALLOW_ALL_USERS/\ + LINE_ALLOWED_USERS; the uniform GATEWAY_* fallback will \ + become a startup error in Phase 2 (#1356)" + ); + } + } + } reg };