Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions config.toml.example
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
155 changes: 155 additions & 0 deletions crates/openab-core/src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -172,6 +172,7 @@ pub struct Config {
pub slack: Option<SlackConfig>,
pub gateway: Option<GatewayConfig>,
pub telegram: Option<TelegramConfig>,
pub line: Option<LineConfig>,
pub agentcore: Option<AgentCoreConfig>,
#[serde(default)]
pub agent: AgentConfig,
Expand Down Expand Up @@ -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<bool>,
/// 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<Vec<String>>,
}

/// Fully resolved LINE trust settings (config → env → default applied).
#[derive(Debug, Clone)]
pub struct ResolvedLine {
pub allow_all_users: bool,
pub allowed_users: Vec<String>,
}

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<String> = 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)]
Expand Down Expand Up @@ -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();
Expand Down
19 changes: 19 additions & 0 deletions docs/config-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
16 changes: 16 additions & 0 deletions docs/line.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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

Expand Down
52 changes: 52 additions & 0 deletions src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
};

Expand Down
Loading