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
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,9 +12,12 @@
- also validate sender on cancel button callback — the cancel handler was routed directly, bypassing the dispatch validation
- **security:** escape release tag name in notify-website CI workflow — use `jq` for proper JSON encoding instead of direct interpolation, preventing JSON injection from crafted tag names [#193](https://github.com/littlebearapps/untether/issues/193)
- **security:** sanitise flag-like prompts in Gemini and AMP runners — prompts starting with `-` are space-prefixed to prevent CLI flag injection; moved `sanitize_prompt()` to base runner class for all engines [#194](https://github.com/littlebearapps/untether/issues/194)
- **security:** redact bot token from structured log URLs — `_redact_event_dict` now strips bot tokens embedded in Telegram API endpoint strings, preventing credential leakage to log files and aggregation systems [#190](https://github.com/littlebearapps/untether/issues/190)
- **security:** cap JSONL line buffer at 10 MB — unbounded `readline()` on engine stdout could consume all available memory if an engine emitted a single very long line (e.g. base64 image in a tool result); now truncates and logs a warning [#191](https://github.com/littlebearapps/untether/issues/191)

- reduce stall warning false positives during Agent subagent work — tree CPU tracking across process descendants, child-aware 15 min threshold when child processes or elevated TCP detected, early diagnostic collection for CPU baseline, total stall warning counter that persists through recovery, improved "Waiting for child processes" notification messages [#264](https://github.com/littlebearapps/untether/issues/264)
- `/ping` uptime now resets on service restart — previously the module-level start time was cached across `/restart` commands; now `reset_uptime()` is called on each service start [#234](https://github.com/littlebearapps/untether/issues/234)
- add 38 missing structlog calls across 13 files — comprehensive logging audit covering auth verification, rate limiting, SSRF validation, codex runner lifecycle, topic state mutations, CLI error paths, and config validation in all engine runners [#299](https://github.com/littlebearapps/untether/issues/299)
- **systemd:** stop Untether being the preferred OOM victim — systemd user services inherit `OOMScoreAdjust=200` and `OOMPolicy=stop` defaults, which made Untether's engine subprocesses preferred earlyoom/kernel OOM killer targets ahead of CLI `claude` (`oom_score_adj=0`) and orphaned grandchildren actually consuming the RAM. `contrib/untether.service` now sets `OOMScoreAdjust=-100` (documents intent; the kernel clamps to the parent baseline for unprivileged users, typically 100) and `OOMPolicy=continue` (a single OOM-killed child no longer tears down the whole unit cgroup, which previously broke every live chat at once). Docs in `docs/reference/dev-instance.md` updated. Existing installs need to copy the unit file and `systemctl --user daemon-reload`; staging picks up the change on the next `scripts/staging.sh install` cycle [#275](https://github.com/littlebearapps/untether/issues/275)

### changes
Expand Down
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -179,7 +179,7 @@ Rules in `.claude/rules/` auto-load when editing matching files:

## Tests

2038 unit tests, 80% coverage threshold. Integration testing against `@untether_dev_bot` is **mandatory before every release** — see `docs/reference/integration-testing.md` for the full playbook with per-release-type tier requirements (patch/minor/major). All integration test tiers are fully automated by Claude Code via Telegram MCP tools and Bash.
2165 unit tests, 80% coverage threshold. Integration testing against `@untether_dev_bot` is **mandatory before every release** — see `docs/reference/integration-testing.md` for the full playbook with per-release-type tier requirements (patch/minor/major). All integration test tiers are fully automated by Claude Code via Telegram MCP tools and Bash.

Key test files:

Expand Down
1,014 changes: 1,014 additions & 0 deletions docs/reference/changelog.md

Large diffs are not rendered by default.

4 changes: 2 additions & 2 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
name = "untether"
authors = [{name = "Little Bear Apps", email = "hello@littlebearapps.com"}]
maintainers = [{name = "Little Bear Apps", email = "hello@littlebearapps.com"}]
version = "0.35.1rc4"
version = "0.35.1rc5"
keywords = ["telegram", "claude-code", "codex", "opencode", "pi", "gemini-cli", "amp", "ai-agents", "coding-assistant", "remote-control", "cli-bridge"]
description = "Run AI coding agents from your phone. Bridges Claude Code, Codex, OpenCode, Pi, Gemini CLI, and Amp to Telegram with interactive permissions, voice input, cost tracking, and live progress."
readme = {file = "README.md", content-type = "text/markdown"}
Expand Down Expand Up @@ -89,7 +89,7 @@ dev = [
"bandit>=1.8.0",
"mutmut>=3.4.0",
"pip-audit>=2.7.0",
"pytest>=9.0.2",
"pytest>=9.0.3",
"pytest-anyio>=0.0.0",
"pytest-cov>=7.0.0",
"ruff>=0.14.10",
Expand Down
3 changes: 3 additions & 0 deletions src/untether/cli/run.py
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@ def acquire_config_lock(config_path: Path, token: str | None) -> LockHandle:
token_fingerprint=fingerprint,
)
except LockError as exc:
logger.error("cli.lock_error", error=str(exc), config_path=str(config_path))
lines = str(exc).splitlines()
if lines:
typer.echo(lines[0], err=True)
Expand Down Expand Up @@ -216,6 +217,7 @@ def _run_auto_router(
transport_id = resolve_transport_id_fn(transport_override)
transport_backend = get_transport_fn(transport_id, allowlist=allowlist)
except ConfigError as exc:
logger.error("cli.config_error", error=str(exc))
typer.echo(f"error: {exc}", err=True)
raise typer.Exit(code=1) from exc
if onboard:
Expand Down Expand Up @@ -307,6 +309,7 @@ def _run_auto_router(
runtime=runtime,
)
except ConfigError as exc:
logger.error("cli.config_error", error=str(exc))
typer.echo(f"error: {exc}", err=True)
raise typer.Exit(code=1) from exc
except KeyboardInterrupt:
Expand Down
20 changes: 20 additions & 0 deletions src/untether/runners/amp.py
Original file line number Diff line number Diff line change
Expand Up @@ -522,16 +522,31 @@ def build_runner(config: EngineConfig, config_path: Path) -> Runner:
"""Build an AmpRunner from configuration."""
model = config.get("model")
if model is not None and not isinstance(model, str):
logger.warning(
"amp.config.invalid",
error="model must be a string",
config_path=str(config_path),
)
raise ConfigError(f"Invalid `amp.model` in {config_path}; expected a string.")

mode = config.get("mode")
if mode is not None and not isinstance(mode, str):
logger.warning(
"amp.config.invalid",
error="mode must be a string",
config_path=str(config_path),
)
raise ConfigError(f"Invalid `amp.mode` in {config_path}; expected a string.")

dangerously_allow_all = config.get("dangerously_allow_all")
if dangerously_allow_all is None:
dangerously_allow_all = True
elif not isinstance(dangerously_allow_all, bool):
logger.warning(
"amp.config.invalid",
error="dangerously_allow_all must be a boolean",
config_path=str(config_path),
)
raise ConfigError(
f"Invalid `amp.dangerously_allow_all` in {config_path}; expected a boolean."
)
Expand All @@ -540,6 +555,11 @@ def build_runner(config: EngineConfig, config_path: Path) -> Runner:
if stream_json_input is None:
stream_json_input = False
elif not isinstance(stream_json_input, bool):
logger.warning(
"amp.config.invalid",
error="stream_json_input must be a boolean",
config_path=str(config_path),
)
raise ConfigError(
f"Invalid `amp.stream_json_input` in {config_path}; expected a boolean."
)
Expand Down
23 changes: 22 additions & 1 deletion src/untether/runners/codex.py
Original file line number Diff line number Diff line change
Expand Up @@ -433,7 +433,7 @@ def translate_codex_event(
) -> list[UntetherEvent]:
match event:
case codex_schema.ThreadStarted(thread_id=thread_id):
logger.debug("codex.session.extracted", session_id=thread_id)
logger.info("codex.session.started", session_id=thread_id)
token = ResumeToken(engine=ENGINE, value=thread_id)
return [factory.started(token, title=title, meta=meta)]
case codex_schema.ItemStarted(item=item):
Expand Down Expand Up @@ -673,6 +673,11 @@ def process_error_events(
if excerpt:
parts.append(excerpt)
message = "\n".join(parts)
logger.error(
"codex.process.failed",
rc=rc,
session_id=found_session.value if found_session else None,
)
resume_for_completed = found_session or resume
return [
self.note_event(
Expand All @@ -695,6 +700,7 @@ def stream_end_events(
state: CodexRunState,
) -> list[UntetherEvent]:
if not found_session:
logger.warning("codex.stream.no_session")
parts = ["codex exec finished but no session_id/thread_id was captured"]
session = _session_label(None, resume)
if session:
Expand Down Expand Up @@ -728,12 +734,22 @@ def build_runner(config: EngineConfig, config_path: Path) -> Runner:
):
extra_args = list(extra_args_value)
else:
logger.warning(
"codex.config.invalid",
error="extra_args must be a list of strings",
config_path=str(config_path),
)
raise ConfigError(
f"Invalid `codex.extra_args` in {config_path}; expected a list of strings."
)

exec_only_flag = find_exec_only_flag(extra_args)
if exec_only_flag:
logger.warning(
"codex.config.invalid",
error=f"exec-only flag {exec_only_flag!r} is managed by Untether",
config_path=str(config_path),
)
raise ConfigError(
f"Invalid `codex.extra_args` in {config_path}; exec-only flag "
f"{exec_only_flag!r} is managed by Untether."
Expand All @@ -743,6 +759,11 @@ def build_runner(config: EngineConfig, config_path: Path) -> Runner:
profile_value = config.get("profile")
if profile_value:
if not isinstance(profile_value, str):
logger.warning(
"codex.config.invalid",
error="profile must be a string",
config_path=str(config_path),
)
raise ConfigError(
f"Invalid `codex.profile` in {config_path}; expected a string."
)
Expand Down
5 changes: 5 additions & 0 deletions src/untether/runners/gemini.py
Original file line number Diff line number Diff line change
Expand Up @@ -526,6 +526,11 @@ def build_runner(config: EngineConfig, config_path: Path) -> Runner:
"""Build a GeminiRunner from configuration."""
model = config.get("model")
if model is not None and not isinstance(model, str):
logger.warning(
"gemini.config.invalid",
error="model must be a string",
config_path=str(config_path),
)
raise ConfigError(
f"Invalid `gemini.model` in {config_path}; expected a string."
)
Expand Down
5 changes: 5 additions & 0 deletions src/untether/runners/opencode.py
Original file line number Diff line number Diff line change
Expand Up @@ -654,6 +654,11 @@ def build_runner(config: EngineConfig, config_path: Path) -> Runner:

model = config.get("model")
if model is not None and not isinstance(model, str):
logger.warning(
"opencode.config.invalid",
error="model must be a string",
config_path=str(config_path),
)
raise ConfigError(
f"Invalid `opencode.model` in {config_path}; expected a string."
)
Expand Down
15 changes: 15 additions & 0 deletions src/untether/runners/pi.py
Original file line number Diff line number Diff line change
Expand Up @@ -588,16 +588,31 @@ def build_runner(config: EngineConfig, config_path: Path) -> Runner:
):
extra_args = list(extra_args_value)
else:
logger.warning(
"pi.config.invalid",
error="extra_args must be a list of strings",
config_path=str(config_path),
)
raise ConfigError(
f"Invalid `pi.extra_args` in {config_path}; expected a list of strings."
)

model = config.get("model")
if model is not None and not isinstance(model, str):
logger.warning(
"pi.config.invalid",
error="model must be a string",
config_path=str(config_path),
)
raise ConfigError(f"Invalid `pi.model` in {config_path}; expected a string.")

provider = config.get("provider")
if provider is not None and not isinstance(provider, str):
logger.warning(
"pi.config.invalid",
error="provider must be a string",
config_path=str(config_path),
)
raise ConfigError(f"Invalid `pi.provider` in {config_path}; expected a string.")

return PiRunner(
Expand Down
6 changes: 6 additions & 0 deletions src/untether/telegram/client_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -496,6 +496,12 @@ async def delete_message(
"deleteMessage",
{"chat_id": chat_id, "message_id": message_id},
)
logger.debug(
"telegram.message.deleted",
chat_id=chat_id,
message_id=message_id,
success=bool(result),
)
return bool(result)

async def set_my_commands(
Expand Down
3 changes: 3 additions & 0 deletions src/untether/telegram/commands/threads.py
Original file line number Diff line number Diff line change
Expand Up @@ -229,6 +229,7 @@ async def _view_thread(
try:
tid = int(tid_str)
except ValueError:
logger.debug("threads.parse.invalid_tid", tid_str=tid_str, action="view")
return CommandResult(text="Invalid thread reference.", notify=True)
thread_id = _resolve_thread(tid)
if thread_id is None:
Expand All @@ -255,6 +256,7 @@ async def _resume_thread(
try:
tid = int(tid_str)
except ValueError:
logger.debug("threads.parse.invalid_tid", tid_str=tid_str, action="resume")
return CommandResult(text="Invalid thread reference.", notify=True)
thread_id = _resolve_thread(tid)
if thread_id is None:
Expand All @@ -273,6 +275,7 @@ async def _archive_thread(
try:
tid = int(tid_str)
except ValueError:
logger.debug("threads.parse.invalid_tid", tid_str=tid_str, action="archive")
return CommandResult(text="Invalid thread reference.", notify=True)
thread_id = _resolve_thread(tid)
if thread_id is None:
Expand Down
22 changes: 22 additions & 0 deletions src/untether/telegram/topic_state.py
Original file line number Diff line number Diff line change
Expand Up @@ -151,6 +151,12 @@ async def set_context(
if topic_title is not None:
thread.topic_title = topic_title
self._save_locked()
logger.debug(
"topic_state.context.set",
chat_id=chat_id,
thread_id=thread_id,
project=context.project,
)

async def clear_context(self, chat_id: int, thread_id: int) -> None:
async with self._lock:
Expand Down Expand Up @@ -263,6 +269,12 @@ async def set_session_resume(
thread = self._ensure_thread_locked(chat_id, thread_id)
thread.sessions[token.engine] = _SessionState(resume=token.value)
self._save_locked()
logger.debug(
"topic_state.session.saved",
chat_id=chat_id,
thread_id=thread_id,
engine=token.engine,
)

async def clear_sessions(self, chat_id: int, thread_id: int) -> None:
async with self._lock:
Expand All @@ -272,6 +284,11 @@ async def clear_sessions(self, chat_id: int, thread_id: int) -> None:
return
thread.sessions = {}
self._save_locked()
logger.debug(
"topic_state.sessions.cleared",
chat_id=chat_id,
thread_id=thread_id,
)

async def clear_engine_session(
self, chat_id: int, thread_id: int, engine: str
Expand All @@ -294,6 +311,11 @@ async def delete_thread(self, chat_id: int, thread_id: int) -> None:
return
self._state.threads.pop(key, None)
self._save_locked()
logger.debug(
"topic_state.thread.deleted",
chat_id=chat_id,
thread_id=thread_id,
)

async def find_thread_for_context(
self, chat_id: int, context: RunContext
Expand Down
8 changes: 8 additions & 0 deletions src/untether/triggers/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,11 @@
from collections.abc import Mapping
from typing import Any

from ..logging import get_logger
from .settings import WebhookConfig

logger = get_logger(__name__)

# HMAC signature headers scoped by algorithm.
_ALGO_HEADERS: dict[str, tuple[str, ...]] = {
"hmac-sha256": ("x-hub-signature-256", "x-signature"),
Expand All @@ -23,8 +26,10 @@ def verify_auth(
) -> bool:
"""Verify a webhook request against its configured auth mode."""
if config.auth == "none":
logger.debug("auth.skipped", auth="none")
return True
if not config.secret:
logger.warning("auth.no_secret", auth=config.auth)
return False

if config.auth == "bearer":
Expand All @@ -35,13 +40,15 @@ def verify_auth(
sig_headers = _ALGO_HEADERS[config.auth]
return _verify_hmac(config.secret, body, headers, algo, sig_headers)

logger.warning("auth.unknown_mode", auth=config.auth)
return False


def _verify_bearer(secret: str, headers: Mapping[str, str]) -> bool:
auth_header = headers.get("authorization", "")
# RFC 6750: scheme keyword is case-insensitive.
if len(auth_header) < 7 or auth_header[:7].lower() != "bearer ":
logger.debug("auth.bearer.missing_header")
return False
token = auth_header[7:]
return hmac.compare_digest(token, secret)
Expand All @@ -66,4 +73,5 @@ def _verify_hmac(
sig = sig.split("=", 1)[1]
if hmac.compare_digest(sig, expected):
return True
logger.debug("auth.hmac.no_match", algo=algo.__name__)
return False
5 changes: 5 additions & 0 deletions src/untether/triggers/rate_limit.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,10 @@

import time

from ..logging import get_logger

logger = get_logger(__name__)


class TokenBucketLimiter:
"""Simple token-bucket rate limiter.
Expand All @@ -26,4 +30,5 @@ def allow(self, key: str) -> bool:
self._buckets[key] = (tokens - 1.0, now)
return True
self._buckets[key] = (tokens, now)
logger.warning("rate_limit.denied", key=key, tokens=tokens)
return False
5 changes: 5 additions & 0 deletions src/untether/triggers/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -260,6 +260,11 @@ async def _process_webhook(

# Rate limit (per-webhook + global)
if not rate_limiter.allow(webhook.id) or not rate_limiter.allow("__global__"):
logger.warning(
"triggers.webhook.rate_limited",
webhook_id=webhook.id,
path=path,
)
return web.Response(status=429, text="rate limited")

# Parse payload — multipart or JSON.
Expand Down
Loading
Loading