fix: non-interactive per-channel gateway config lifecycle (#6082)

* fix: add non-interactive per-channel gateway config lifecycle (fixes #6079)

Adds 'praisonai gateway channels add|update|disable|remove' and an
importable edit_gateway_channel() helper. Each edit is a surgical
read-modify-write of channels:, validated against GatewayConfigSchema
before an atomic write, so invalid mutations are never persisted.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* fix: honour parent --config, legacy platform formats, env-resolved validation and last-channel guard in gateway channel edits

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* fix: lock gateway channel edits and resolve ${VAR} platform before legacy conversion

Co-Authored-By: Claude Opus 5.5 <[email protected]>

---------

Co-authored-by: MervinPraison <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
This commit is contained in:
authored and GitHub committed 2026-10-27 19:56:43 +01:00
1 parent f9e058f939
commit 59fb20af8e
4 files changed
+431 -6

No files matched your search

@@ -14,6 +14,7 @@ _LAZY_EXPORTS = {
"praisonai_bot.gateway.admin",
"provision_gateway_config",
),
"edit_gateway_channel": ("praisonai_bot.gateway.admin", "edit_gateway_channel"),
}
@@ -34,5 +35,6 @@ __all__ = [
"GatewayRepairResult",
"repair_gateway_config",
"provision_gateway_config",
"edit_gateway_channel",
"__version__",
]
@@ -1553,8 +1553,16 @@ def gateway_test(
raise typer.Exit(1)
@app.command("channels")
channels_app = typer.Typer(
help="List, probe and edit channels in gateway.yaml",
invoke_without_command=True,
)
app.add_typer(channels_app, name="channels")
@channels_app.callback()
def gateway_channels(
ctx: typer.Context,
config: str = typer.Option("gateway.yaml", "--config", "-c", help="Path to gateway.yaml"),
json_output: bool = typer.Option(False, "--json", help="Output JSON"),
probe: bool = typer.Option(False, "--probe", help="Probe each channel's credentials"),
@@ -1574,6 +1582,9 @@ def gateway_channels(
import os
import yaml
if ctx.invoked_subcommand is not None:
return
if available:
try:
from praisonai_bot.bots._registry import list_platforms
@@ -1662,6 +1673,125 @@ def gateway_channels(
print(f"{name:<20} {platform:<12} {has_token:<12}")
def _parse_channel_settings(pairs: Optional[List[str]]) -> dict:
"""Parse repeated ``--set key=value`` flags; values are YAML-typed."""
import yaml
settings = {}
for pair in pairs or []:
key, sep, value = pair.partition("=")
if not sep or not key.strip():
print(f"Error: --set expects key=value, got {pair!r}")
raise typer.Exit(1)
settings[key.strip()] = yaml.safe_load(value) if value else ""
return settings
def _edit_channel(
ctx: typer.Context, config: Optional[str], name: str, settings=None, *, remove=False, must_exist=True
) -> None:
"""Run one validated channel edit, mapping failures to CLI exits (#6079)."""
import os
import yaml
from praisonai_bot.gateway.admin import _editable_channels, edit_gateway_channel
# ``gateway channels --config X <cmd>`` applies to the subcommand too.
config = _resolve_doctor_config(config or ctx.parent.params.get("config") or "gateway.yaml")
if not os.path.exists(config):
print(f"Error: Config file not found: {config}")
raise typer.Exit(1)
with open(config) as fh:
exists = name in _editable_channels(yaml.safe_load(fh) or {})
if must_exist and not exists:
print(f"Error: channel {name!r} is not configured in {config}")
raise typer.Exit(1)
if not must_exist and exists:
print(f"Error: channel {name!r} already exists in {config}; use 'update'")
raise typer.Exit(1)
try:
edit_gateway_channel(config, name, settings, remove=remove)
except ValueError as exc:
print(f"Error: {exc}\nNo changes written to {config}.")
raise typer.Exit(1) from exc
@channels_app.command("add")
def gateway_channels_add(
ctx: typer.Context,
name: str = typer.Argument(..., help="Channel name, e.g. 'telegram'"),
platform: Optional[str] = typer.Option(None, "--platform", help="Platform (default: the channel name)"),
token: Optional[str] = typer.Option(
None, "--token",
help="Channel token (default: ${VAR} reference to the platform's credential env var)",
),
set_: Optional[List[str]] = typer.Option(None, "--set", help="Extra setting key=value (repeatable)"),
config: Optional[str] = typer.Option(None, "--config", "-c", help="Path to gateway.yaml"),
):
"""Add one channel to gateway.yaml without touching the others.
Examples:
praisonai gateway channels add telegram
praisonai gateway channels add support --platform discord --set group_policy=mention_only
"""
from praisonai_bot.bots._config_schema import GatewayConfigSchema
platform = platform or name
settings = {"platform": platform}
settings["token"] = token if token is not None else GatewayConfigSchema._credential_env_ref(platform)
settings.update(_parse_channel_settings(set_))
_edit_channel(ctx, config, name, settings, must_exist=False)
print(f"Added channel {name!r}.")
@channels_app.command("update")
def gateway_channels_update(
ctx: typer.Context,
name: str = typer.Argument(..., help="Channel name to update"),
token: Optional[str] = typer.Option(None, "--token", help="New channel token"),
set_: Optional[List[str]] = typer.Option(None, "--set", help="Setting key=value (repeatable)"),
config: Optional[str] = typer.Option(None, "--config", "-c", help="Path to gateway.yaml"),
):
"""Update settings of one configured channel.
Examples:
praisonai gateway channels update telegram --set group_policy=mention_only
"""
settings = _parse_channel_settings(set_)
if token is not None:
settings["token"] = token
if not settings:
print("Error: nothing to update; pass --token and/or --set key=value")
raise typer.Exit(1)
_edit_channel(ctx, config, name, settings)
print(f"Updated channel {name!r}.")
@channels_app.command("disable")
def gateway_channels_disable(
ctx: typer.Context,
name: str = typer.Argument(..., help="Channel name to disable"),
config: Optional[str] = typer.Option(None, "--config", "-c", help="Path to gateway.yaml"),
):
"""Stop serving a channel but keep its config (sets ``enabled: false``).
Re-enable with ``praisonai gateway channels update <name> --set enabled=true``.
"""
_edit_channel(ctx, config, name, {"enabled": False})
print(f"Disabled channel {name!r}.")
@channels_app.command("remove")
def gateway_channels_remove(
ctx: typer.Context,
name: str = typer.Argument(..., help="Channel name to remove"),
config: Optional[str] = typer.Option(None, "--config", "-c", help="Path to gateway.yaml"),
):
"""Delete a channel block from gateway.yaml."""
_edit_channel(ctx, config, name, remove=True)
print(f"Removed channel {name!r}.")
def _resolve_gateway_rest_url(
url: Optional[str],
host: Optional[str] = None,
@@ -15,6 +15,7 @@ one canonical flow (DRY).
from __future__ import annotations
import contextlib
import os
import sys
from dataclasses import dataclass, field
@@ -201,7 +202,6 @@ def _repair_config_version(config_path: str):
an interruption, full disk, or I/O error during ``doctor --fix`` can never
leave the config truncated or half-written.
"""
import tempfile
import yaml
with open(config_path) as fh:
@@ -210,6 +210,14 @@ def _repair_config_version(config_path: str):
from praisonaiagents.gateway.config import migrate_config_with_doctor
migrated, reasons = migrate_config_with_doctor(cfg)
_atomic_write_yaml(config_path, migrated)
return reasons
def _atomic_write_yaml(config_path: str, data: dict) -> None:
"""Serialise ``data`` to a temp file and ``os.replace`` it over the config."""
import tempfile
import yaml
directory = os.path.dirname(os.path.abspath(config_path))
fd, tmp_path = tempfile.mkstemp(
@@ -217,9 +225,7 @@ def _repair_config_version(config_path: str):
)
try:
with os.fdopen(fd, "w") as fh:
yaml.safe_dump(
migrated, fh, default_flow_style=False, sort_keys=False
)
yaml.safe_dump(data, fh, default_flow_style=False, sort_keys=False)
fh.flush()
os.fsync(fh.fileno())
os.replace(tmp_path, config_path)
@@ -230,7 +236,127 @@ def _repair_config_version(config_path: str):
pass
raise
return reasons
def edit_gateway_channel(
config_path: PathLike,
name: str,
settings: Optional[Dict] = None,
*,
remove: bool = False,
) -> dict:
"""Add, update or remove one ``channels:`` entry in place (Issue #6079).
A surgical read-modify-write: ``settings`` is merged into
``channels[name]`` (created if absent, ``platform`` defaulting to ``name``)
or, with ``remove=True``, the block is deleted. Other channels and
top-level keys are untouched. The edited document is validated against
``GatewayConfigSchema`` before the atomic write, so an invalid mutation is
rejected rather than persisted. Disable a channel with
``settings={"enabled": False}``.
Returns:
The resulting channel block (``{}`` when removed).
Raises:
FileNotFoundError: If ``config_path`` does not exist.
KeyError: If ``remove=True`` and the channel is not configured.
ValueError: If the edited config fails validation.
"""
import yaml
config_path = os.fspath(config_path)
if not os.path.isfile(config_path):
raise FileNotFoundError(config_path)
# Hold a sidecar lock across read-modify-write so concurrent edits of
# different channels cannot overwrite each other.
with _config_edit_lock(config_path):
with open(config_path) as fh:
cfg = yaml.safe_load(fh) or {}
return _apply_channel_edit(config_path, cfg, name, settings, remove)
def _apply_channel_edit(config_path, cfg, name, settings, remove) -> dict:
from praisonai_bot.bots._config_schema import validate_gateway_config
channels = _editable_channels(cfg)
if remove:
if name not in channels:
raise KeyError(f"channel {name!r} is not configured in {config_path}")
channels.pop(name)
block: dict = {}
else:
block = dict(channels.get(name) or {})
block.setdefault("platform", name)
block.update(settings or {})
channels[name] = block
cfg["channels"] = channels
# Check the document itself: the validator may otherwise satisfy its
# "at least one channel" rule from env credentials, which are not saved.
if not any(
not isinstance(b, dict) or b.get("enabled", True) is not False
for b in channels.values()
):
raise ValueError("at least one enabled channel must remain configured")
# Validate an env-resolved copy (as the runtime does; unset ``${VAR}``
# refs are left as-is) while the saved file keeps the references.
validate_gateway_config(cfg)
_atomic_write_yaml(config_path, cfg)
return block
def _editable_channels(cfg: dict) -> dict:
"""Return ``cfg``'s ``channels`` map, moving legacy declarations into it.
Single-bot (top-level ``platform``) and BotOS (``platforms``) files are
only honoured by the loader while ``channels`` is empty, so they are
converted in place before an edit adds a ``channels:`` block. ``${VAR}``
references are copied verbatim.
"""
channels = cfg.get("channels") or {}
if channels:
return channels
platform = cfg.get("platform")
if platform:
# Mirrors the loader's single-bot migration, which wins over BotOS.
from praisonai_bot.bots._config_schema import GatewayConfigSchema
# Resolve a ``${VAR}`` platform first, as the loader does.
raw = str(cfg.pop("platform"))
platform = str(_resolve_env_token(raw) or raw).lower().strip()
block = {"platform": platform}
token = cfg.pop("token", None)
block["token"] = token or GatewayConfigSchema._credential_env_ref(platform)
if platform == "slack" and cfg.get("app_token") is not None:
block["app_token"] = cfg.pop("app_token")
for key in ("unknown_user_policy", "owner_user_id"):
if cfg.get(key) is not None:
block[key] = cfg[key]
channels[platform] = block
elif cfg.get("platforms"):
for pname, pcfg in cfg.pop("platforms").items():
channels[pname] = {"platform": pname, **(pcfg or {})}
return channels
@contextlib.contextmanager
def _config_edit_lock(config_path: str):
"""Exclusive OS advisory lock on ``<config>.lock`` (no-op if unsupported)."""
with open(config_path + ".lock", "a+") as fh:
try:
import fcntl
except ImportError: # pragma: no cover - Windows
fcntl = None
if fcntl is not None:
fcntl.flock(fh.fileno(), fcntl.LOCK_EX)
else: # pragma: no cover - Windows
try:
import msvcrt
msvcrt.locking(fh.fileno(), msvcrt.LK_LOCK, 1)
except Exception:
pass
yield # released when ``fh`` closes
def _repair_gateway_secret(dry_run: bool = False, config_path: str = ""):
@@ -0,0 +1,167 @@
"""Per-channel config lifecycle: add / update / disable / remove (Issue #6079)."""
import pytest
import yaml
from typer.testing import CliRunner
from praisonai_bot.cli.commands.gateway import app
from praisonai_bot.gateway.admin import edit_gateway_channel
BASE = {
"agents": {"assistant": {"instructions": "hi"}},
"channels": {
"telegram": {"platform": "telegram", "token": "${TELEGRAM_BOT_TOKEN}"},
},
}
@pytest.fixture
def config(tmp_path):
path = tmp_path / "gateway.yaml"
path.write_text(yaml.safe_dump(BASE, sort_keys=False))
return path
def _load(path):
return yaml.safe_load(path.read_text())
def _run(*args):
return CliRunner().invoke(app, ["channels", *args])
def test_add_channel_keeps_others_and_defaults_env_token(config):
result = _run("add", "discord", "--config", str(config), "--set", "group_policy=mention_only")
assert result.exit_code == 0, result.output
channels = _load(config)["channels"]
assert channels["telegram"] == BASE["channels"]["telegram"]
assert channels["discord"] == {
"platform": "discord",
"token": "${DISCORD_BOT_TOKEN}",
"group_policy": "mention_only",
}
assert _load(config)["agents"] == BASE["agents"]
def test_add_existing_channel_is_rejected(config):
result = _run("add", "telegram", "--config", str(config))
assert result.exit_code == 1
assert _load(config) == BASE
def test_update_and_disable_channel(config):
assert _run("update", "telegram", "--config", str(config), "--token", "${NEW_TOKEN}").exit_code == 0
assert _run("add", "discord", "--config", str(config)).exit_code == 0
assert _run("disable", "telegram", "--config", str(config)).exit_code == 0
telegram = _load(config)["channels"]["telegram"]
assert telegram["token"] == "${NEW_TOKEN}"
assert telegram["enabled"] is False
def test_invalid_update_is_not_persisted(config):
result = _run("update", "telegram", "--config", str(config), "--set", "allowed_users=[1, 2]")
assert result.exit_code == 1
assert _load(config) == BASE
def test_unknown_platform_is_rejected(config):
assert _run("add", "bogus", "--config", str(config)).exit_code == 1
assert _load(config) == BASE
def test_remove_channel(config):
assert _run("add", "discord", "--config", str(config)).exit_code == 0
assert _run("remove", "discord", "--config", str(config)).exit_code == 0
assert "discord" not in _load(config)["channels"]
assert _run("remove", "discord", "--config", str(config)).exit_code == 1
def test_python_helper_matches_cli(config):
block = edit_gateway_channel(config, "discord", {"token": "${D}"})
assert block == {"platform": "discord", "token": "${D}"}
with pytest.raises(KeyError):
edit_gateway_channel(config, "nope", remove=True)
with pytest.raises(ValueError):
edit_gateway_channel(config, "bogus", {})
assert "bogus" not in _load(config)["channels"]
def test_list_still_works_without_subcommand(config):
result = _run("--config", str(config), "--json")
assert result.exit_code == 0
assert '"telegram"' in result.output
def test_parent_config_option_applies_to_subcommand(config, tmp_path, monkeypatch):
monkeypatch.chdir(tmp_path)
other = tmp_path / "other.yaml"
other.write_text(yaml.safe_dump(BASE, sort_keys=False))
result = _run("--config", str(other), "update", "telegram", "--set", "group_policy=mention_only")
assert result.exit_code == 0, result.output
assert _load(other)["channels"]["telegram"]["group_policy"] == "mention_only"
assert _load(config) == BASE
def test_single_bot_platform_is_converted_not_hidden(tmp_path):
path = tmp_path / "bot.yaml"
path.write_text(yaml.safe_dump({"agent": {"instructions": "hi"}, "platform": "telegram", "token": "${T}"}))
edit_gateway_channel(path, "discord", {"token": "${D}"})
cfg = _load(path)
assert "platform" not in cfg and "token" not in cfg
assert cfg["channels"]["telegram"] == {"platform": "telegram", "token": "${T}"}
assert cfg["channels"]["discord"]["token"] == "${D}"
def test_botos_platforms_are_converted(tmp_path):
path = tmp_path / "bot.yaml"
path.write_text(yaml.safe_dump({"agent": {"instructions": "hi"}, "platforms": {"telegram": {"token": "${T}"}}}))
result = CliRunner().invoke(app, ["channels", "update", "telegram", "--config", str(path), "--set", "group_policy=mention_only"])
assert result.exit_code == 0, result.output
cfg = _load(path)
assert "platforms" not in cfg
assert cfg["channels"]["telegram"] == {"platform": "telegram", "token": "${T}", "group_policy": "mention_only"}
def test_env_placeholder_in_typed_field_does_not_block_edit(config, monkeypatch):
cfg = _load(config)
cfg["channels"]["telegram"]["max_inbound_media_bytes"] = "${MEDIA_LIMIT}"
config.write_text(yaml.safe_dump(cfg, sort_keys=False))
monkeypatch.setenv("MEDIA_LIMIT", "1024")
edit_gateway_channel(config, "telegram", {"token": "${NEW}"})
saved = _load(config)["channels"]["telegram"]
assert saved["max_inbound_media_bytes"] == "${MEDIA_LIMIT}"
assert saved["token"] == "${NEW}"
def test_removing_last_channel_is_rejected_even_with_env_token(config, monkeypatch):
monkeypatch.setenv("TELEGRAM_BOT_TOKEN", "x")
with pytest.raises(ValueError):
edit_gateway_channel(config, "telegram", remove=True)
with pytest.raises(ValueError):
edit_gateway_channel(config, "telegram", {"enabled": False})
assert _load(config) == BASE
def test_env_platform_reference_is_resolved_before_conversion(tmp_path, monkeypatch):
monkeypatch.setenv("BOT_PLATFORM", "telegram")
path = tmp_path / "bot.yaml"
path.write_text(yaml.safe_dump({"agent": {"instructions": "hi"}, "platform": "${BOT_PLATFORM}", "token": "${T}"}))
edit_gateway_channel(path, "discord", {"token": "${D}"})
cfg = _load(path)
assert cfg["channels"]["telegram"] == {"platform": "telegram", "token": "${T}"}
assert cfg["channels"]["discord"]["token"] == "${D}"
def test_concurrent_edits_are_not_lost(config):
from concurrent.futures import ThreadPoolExecutor
names = [f"ch{i}" for i in range(8)]
with ThreadPoolExecutor(len(names)) as pool:
list(pool.map(lambda n: edit_gateway_channel(config, n, {"platform": "telegram", "token": "${X}"}), names))
assert set(_load(config)["channels"]) == {"telegram", *names}
def test_missing_config_raises_without_lock_file(tmp_path):
with pytest.raises(FileNotFoundError):
edit_gateway_channel(tmp_path / "nope.yaml", "discord", {})
assert not list(tmp_path.iterdir())