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
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,7 @@ instance_id = "my-wechat" # unique per instance
base_url = "http://127.0.0.1:8000" # your WeChat gateway
token_env = "WECHAT_TOKEN_MY_WECHAT" # env var holding the token (never the token itself)
enabled = true # explicit opt-in
require_approval = false # true = hold tool use for Approve/Deny in the portal

trigger_prefix = "/ask " # prefix to require; "" = free-form (reply to every message)
allowed_senders = ["wxid_your_own_id"] # allowlist; empty = allow all
Expand Down Expand Up @@ -203,6 +204,9 @@ token printed to the console) that talks to your WeChat gateway so you can:
none checked = every group), choose the provider, and enable/disable the instance.
- **sign in by QR** — if the account is signed out, the portal shows the gateway's
login QR and continues automatically once you scan it.
- **approve tool actions live** — when an instance sets `require_approval = true`,
anything the agent wants to run on your machine waits in the portal for your
**Approve / Deny** instead of running unattended.

Saving writes `channels.toml` — restart `channels start` to apply. The gateway
token stays server-side and never reaches the browser. Flags: `--port` (default
Expand Down
137 changes: 137 additions & 0 deletions coding_bridge/channels/approvals.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
"""File-backed tool-approval store shared by the daemon and the portal.

``channels start`` (runs provider turns) and ``channels portal`` (the UI) are
separate processes, so a tool-permission request raised inside a channel turn is
published here as a small JSON file the portal can list; the portal writes a
decision file back and the daemon polls for it. Everything lives under
``<config_dir>/approvals/`` and never leaves the box — same local trust boundary
as the rest of the agent.

Opt-in: nothing writes here unless an instance sets ``require_approval = true``,
so the default daemon path is completely unchanged.
"""

from __future__ import annotations

import contextlib
import json
import os
import re
import tempfile
import time
from pathlib import Path
from typing import Any

# Request ids are provider-generated uuids; validate before using one as a
# filename so a hostile id can't escape the approvals directory.
_ID_RE = re.compile(r"^[A-Za-z0-9_-]{1,128}$")

# A pending request older than this is considered abandoned (the turn that
# raised it has long since timed out) and is neither surfaced nor honored.
_DEFAULT_TTL = 600.0


def _write_atomic(path: Path, body: str) -> None:
fd, tmp_name = tempfile.mkstemp(dir=str(path.parent), prefix=".appr-", suffix=".tmp")
tmp = Path(tmp_name)
try:
with os.fdopen(fd, "w", encoding="utf-8") as f:
f.write(body)
if os.name == "posix":
with contextlib.suppress(OSError):
os.chmod(tmp, 0o600)
os.replace(tmp, path)
except BaseException:
with contextlib.suppress(OSError):
tmp.unlink()
raise


class ApprovalStore:
"""Cross-process pending/decision registry for channel tool approvals."""

def __init__(self, root: Path | str, *, ttl: float = _DEFAULT_TTL) -> None:
self._root = Path(root)
self._ttl = ttl

@property
def root(self) -> Path:
return self._root

def _ensure_root(self) -> None:
self._root.mkdir(parents=True, exist_ok=True)
if os.name == "posix":
with contextlib.suppress(OSError):
os.chmod(self._root, 0o700)

@staticmethod
def valid_id(request_id: str) -> bool:
return bool(isinstance(request_id, str) and _ID_RE.match(request_id))

def _req_path(self, rid: str) -> Path:
return self._root / f"{rid}.request.json"

def _dec_path(self, rid: str) -> Path:
return self._root / f"{rid}.decision.json"

def create(self, request_id: str, descriptor: dict[str, Any]) -> None:
"""Publish a pending request. Descriptor should carry only display fields."""
if not self.valid_id(request_id):
raise ValueError("invalid request id")
self._ensure_root()
payload = {"id": request_id, "created_at": time.time(), **descriptor}
_write_atomic(self._req_path(request_id), json.dumps(payload))

def list_pending(self) -> list[dict[str, Any]]:
"""Every still-undecided, non-stale pending request (oldest first)."""
out: list[dict[str, Any]] = []
if not self._root.exists():
return out
now = time.time()
for p in self._root.glob("*.request.json"):
rid = p.name[: -len(".request.json")]
if self._dec_path(rid).exists():
continue
try:
data = json.loads(p.read_text(encoding="utf-8"))
except (OSError, ValueError):
continue
if now - float(data.get("created_at", 0) or 0) > self._ttl:
continue
out.append(data)
out.sort(key=lambda d: d.get("created_at", 0))
return out

def decide(self, request_id: str, decision: str) -> bool:
"""Record a decision for a pending request. Returns False if unknown."""
if not self.valid_id(request_id) or decision not in ("allow", "deny"):
return False
if not self._req_path(request_id).exists():
return False
_write_atomic(
self._dec_path(request_id),
json.dumps({"decision": decision, "decided_at": time.time()}),
)
return True

def poll_decision(self, request_id: str) -> str | None:
"""Return ``allow`` / ``deny`` once decided, else ``None``."""
if not self.valid_id(request_id):
return None
try:
data = json.loads(self._dec_path(request_id).read_text(encoding="utf-8"))
except (OSError, ValueError):
return None
decision = data.get("decision")
return decision if decision in ("allow", "deny") else None

def cleanup(self, request_id: str) -> None:
"""Remove the request + decision files (best-effort)."""
if not self.valid_id(request_id):
return
for p in (self._req_path(request_id), self._dec_path(request_id)):
with contextlib.suppress(OSError):
p.unlink()


__all__ = ["ApprovalStore"]
10 changes: 10 additions & 0 deletions coding_bridge/channels/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@
"token_file",
"enabled",
"default_provider",
"require_approval",
"trigger_prefix",
"allowed_senders",
"allowed_groups",
Expand Down Expand Up @@ -91,6 +92,9 @@ class WeChatInstanceConfig:
token_env: str | None = None
token_file: str | None = None
enabled: bool = False
#: When true, a tool the agent wants to run in this instance's turns is held
#: for approval via ``channels portal`` instead of running unattended.
require_approval: bool = False
default_provider: str | None = None
#: Message text must start with this to be forwarded. Empty string disables
#: the check. See ``coding_bridge.channels.policy.ChannelPolicy``.
Expand Down Expand Up @@ -240,6 +244,11 @@ def _parse_wechat(block: dict[str, Any], index: int) -> WeChatInstanceConfig:
# (`enabled = 1` in TOML → int → we want a hard error, not a silent truthy).
if not isinstance(enabled, bool):
raise ConfigError(f"[[channels.wechat]] {instance_id!r}: enabled must be a bool")
require_approval = block.get("require_approval", False)
if not isinstance(require_approval, bool):
raise ConfigError(
f"[[channels.wechat]] {instance_id!r}: require_approval must be a bool"
)
default_provider = block.get("default_provider")
if default_provider is not None:
if not isinstance(default_provider, str):
Expand Down Expand Up @@ -318,6 +327,7 @@ def _parse_wechat(block: dict[str, Any], index: int) -> WeChatInstanceConfig:
token_env=token_env,
token_file=token_file,
enabled=enabled,
require_approval=require_approval,
default_provider=default_provider,
trigger_prefix=trigger_prefix,
allowed_senders=tuple(allowed_senders_raw),
Expand Down
80 changes: 80 additions & 0 deletions coding_bridge/channels/dispatcher.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@

import asyncio
import contextlib
import json
import logging
import time
import uuid
Expand All @@ -31,6 +32,7 @@
from ..protocol import Event
from ..providers.base import ProviderFactory
from ..session import Session
from .approvals import ApprovalStore
from .base import ChannelAdapter, ChannelTarget, IncomingMessage, SendResult
from .observability import TurnEvent, TurnOutcome, log_turn

Expand Down Expand Up @@ -104,12 +106,18 @@ def __init__(
reply_sink: ReplySink | None = None,
turn_timeout: float = 300.0,
default_provider: str = "claude",
approval_store: ApprovalStore | None = None,
require_approval: bool = False,
) -> None:
self.settings = settings
self._factory = provider_factory
self._reply_sink = reply_sink or _default_reply_sink
self._turn_timeout = turn_timeout
self._default_provider = default_provider
# Live tool-approval relay (opt-in). When ``require_approval`` is off (the
# default) NONE of the approval code runs, so the daemon path is unchanged.
self._approval_store = approval_store
self._require_approval = bool(require_approval and approval_store is not None)
self._inflight: dict[SessionKey, asyncio.Task[Any]] = {}
self._lock = asyncio.Lock()

Expand Down Expand Up @@ -146,9 +154,17 @@ async def _run_turn(
) -> None:
session_id = uuid.uuid4().hex
turn = _Turn()
session_ref: dict[str, Session] = {}
approval_tasks: list[asyncio.Task[Any]] = []

async def emit(payload: dict[str, Any]) -> None:
turn.on_event(payload)
# Relay tool-permission prompts to the portal when the instance opted
# in; otherwise this branch never runs and behaviour is unchanged.
if self._require_approval and payload.get("event") == Event.PERMISSION_REQUEST:
approval_tasks.append(
asyncio.create_task(self._await_approval(session_ref, adapter, payload))
)

session = Session(
session_id=session_id,
Expand All @@ -160,6 +176,7 @@ async def emit(payload: dict[str, Any]) -> None:
permission_mode="default",
provider=self._default_provider,
)
session_ref["s"] = session
started = time.monotonic()
outcome: TurnOutcome = "ok"
reply = ""
Expand Down Expand Up @@ -213,6 +230,14 @@ async def emit(payload: dict[str, Any]) -> None:
with contextlib.suppress(Exception):
await self._reply_sink(adapter, msg.target, f"(provider error: {exc})")
finally:
# Stop any pending approval pollers first — as they unwind they
# resolve the request (deny) and clean up their store entries.
for t in approval_tasks:
if not t.done():
t.cancel()
for t in approval_tasks:
with contextlib.suppress(BaseException):
await t
# One structured, content-free event per turn (see observability.py).
with contextlib.suppress(Exception):
log_turn(
Expand All @@ -234,6 +259,61 @@ async def emit(payload: dict[str, Any]) -> None:
if self._inflight.get(key) is asyncio.current_task():
self._inflight.pop(key, None)

async def _await_approval(
self,
session_ref: dict[str, Session],
adapter: ChannelAdapter,
payload: dict[str, Any],
) -> None:
"""Publish a pending tool approval, wait for a portal decision, resolve it.

Bounded by ``turn_timeout`` — if no one decides in time the request is
denied (the provider's own permission timeout would deny anyway).
Cancelled when the turn ends; on any exit the request is resolved (deny
by default) and its store files are removed.
"""
store = self._approval_store
request_id = payload.get("request_id")
if store is None or not isinstance(request_id, str) or not store.valid_id(request_id):
return
descriptor = {
"instance_id": adapter.instance_id,
"adapter": adapter.name,
"tool": str(payload.get("tool") or "tool"),
**_approval_summary(payload),
}
with contextlib.suppress(Exception):
store.create(request_id, descriptor)
decision: str | None = None
deadline = time.monotonic() + self._turn_timeout
try:
while time.monotonic() < deadline:
await asyncio.sleep(1.0)
decision = store.poll_decision(request_id)
if decision:
break
finally:
session = session_ref.get("s")
if session is not None:
with contextlib.suppress(Exception):
session.resolve_permission(request_id, decision or "deny")
with contextlib.suppress(Exception):
store.cleanup(request_id)


def _approval_summary(payload: dict[str, Any]) -> dict[str, Any]:
"""A bounded, display-only view of a permission request for the portal."""
title = payload.get("title") or payload.get("description")
raw = payload.get("input")
try:
preview = json.dumps(raw, ensure_ascii=False) if raw is not None else ""
except (TypeError, ValueError):
preview = str(raw)
return {
"title": (str(title)[:160] if title else None),
"input_preview": preview[:400],
}


async def _default_reply_sink(
adapter: ChannelAdapter, target: ChannelTarget, text: str
Expand Down
25 changes: 24 additions & 1 deletion coding_bridge/channels/portal.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@

import httpx

from .approvals import ApprovalStore
from .config import (
ChannelsConfig,
ConfigError,
Expand Down Expand Up @@ -199,6 +200,8 @@ def __init__(self, settings: Settings, *, client: httpx.Client | None = None) ->
# Per-instance single-flight lock so overlapping searches (and the
# background warm) share ONE fetch instead of each paging 4k+ rows.
self._fetch_locks: dict[str, threading.Lock] = {}
# Cross-process tool-approval registry (shared with `channels start`).
self._approvals = ApprovalStore(settings.config_dir / "approvals")

def close(self) -> None:
if self._owns_client:
Expand Down Expand Up @@ -257,6 +260,14 @@ def _instance(self, instance_id: str) -> WeChatInstanceConfig:
return inst
raise PortalError(404, f"no such instance {instance_id!r}")

# ---- tool approvals ------------------------------------------------- #

def list_approvals(self) -> list[dict[str, Any]]:
return self._approvals.list_pending()

def decide_approval(self, request_id: str, decision: str) -> bool:
return self._approvals.decide(request_id, decision)

# ---- WeChat gateway proxy ------------------------------------------- #

def _gateway_request(
Expand Down Expand Up @@ -560,7 +571,8 @@ def do_POST(self) -> None: # noqa: N802
if not self._token_ok(query):
self._send_json(401, {"error": "bad or missing portal token"})
return
if parsed.path != "/api/config":
post_path = parsed.path
if post_path not in ("/api/config", "/api/approvals"):
self._send_json(404, {"error": "not found"})
return
try:
Expand All @@ -577,6 +589,15 @@ def do_POST(self) -> None: # noqa: N802
except ValueError:
self._send_json(400, {"error": "invalid JSON"})
return
if post_path == "/api/approvals":
rid = (payload or {}).get("id")
decision = (payload or {}).get("decision")
if not isinstance(rid, str) or decision not in ("allow", "deny"):
self._send_json(400, {"error": "id and decision (allow|deny) required"})
return
ok = service.decide_approval(rid, decision)
self._send_json(200 if ok else 404, {"ok": ok})
return
try:
result = service.save((payload or {}).get("instances", []))
except PortalError as exc:
Expand All @@ -593,6 +614,8 @@ def _handle_api_get(self, path: str, query: dict[str, list[str]]) -> None:
try:
if path == "/api/config":
self._send_json(200, service.public_config())
elif path == "/api/approvals":
self._send_json(200, {"approvals": service.list_approvals()})
elif path == "/api/wechat/account":
self._send_json(200, service.account(instance))
elif path == "/api/wechat/status":
Expand Down
Loading
Loading