Skip to content

Commit e2404b2

Browse files
nathanschramclaude
andauthored
chore: staging 0.35.1rc5 — logging audit, docs, pytest CVE fix (#301)
* fix: add 38 missing structlog calls across 13 files (logging audit) Comprehensive logging audit found gaps in security-critical paths (auth, rate limiting, SSRF), runner lifecycle (codex peer parity), state mutations (topic_state), and CLI error paths. Adds structured log statements at appropriate levels without over-logging. Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> * docs: comprehensive v0.35.1 documentation updates - Expand docs/reference/changelog.md with full v0.35.1 entry (security, fixes, changes) instead of a stub pointing to GitHub - Add #190 (token redaction) and #191 (line buffer cap) to CHANGELOG.md - Add logging audit (#299) to CHANGELOG.md and docs changelog - Update CLAUDE.md test count from 2038 to 2165 Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> * chore: staging 0.35.1rc5 Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> * fix: bump pytest 9.0.2 → 9.0.3 (CVE-2025-71176) Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> --------- Co-authored-by: Claude Opus 4.6 (1M context) <[email protected]>
1 parent 5951851 commit e2404b2

File tree

18 files changed

+1147
-9
lines changed

18 files changed

+1147
-9
lines changed

CHANGELOG.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,9 +12,12 @@
1212
- also validate sender on cancel button callback — the cancel handler was routed directly, bypassing the dispatch validation
1313
- **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)
1414
- **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)
15+
- **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)
16+
- **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)
1517

1618
- 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)
1719
- `/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)
20+
- 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)
1821
- **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)
1922

2023
### changes

CLAUDE.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -179,7 +179,7 @@ Rules in `.claude/rules/` auto-load when editing matching files:
179179

180180
## Tests
181181

182-
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.
182+
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.
183183

184184
Key test files:
185185

docs/reference/changelog.md

Lines changed: 1014 additions & 0 deletions
Large diffs are not rendered by default.

pyproject.toml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
name = "untether"
33
authors = [{name = "Little Bear Apps", email = "[email protected]"}]
44
maintainers = [{name = "Little Bear Apps", email = "[email protected]"}]
5-
version = "0.35.1rc4"
5+
version = "0.35.1rc5"
66
keywords = ["telegram", "claude-code", "codex", "opencode", "pi", "gemini-cli", "amp", "ai-agents", "coding-assistant", "remote-control", "cli-bridge"]
77
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."
88
readme = {file = "README.md", content-type = "text/markdown"}
@@ -89,7 +89,7 @@ dev = [
8989
"bandit>=1.8.0",
9090
"mutmut>=3.4.0",
9191
"pip-audit>=2.7.0",
92-
"pytest>=9.0.2",
92+
"pytest>=9.0.3",
9393
"pytest-anyio>=0.0.0",
9494
"pytest-cov>=7.0.0",
9595
"ruff>=0.14.10",

src/untether/cli/run.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,7 @@ def acquire_config_lock(config_path: Path, token: str | None) -> LockHandle:
6767
token_fingerprint=fingerprint,
6868
)
6969
except LockError as exc:
70+
logger.error("cli.lock_error", error=str(exc), config_path=str(config_path))
7071
lines = str(exc).splitlines()
7172
if lines:
7273
typer.echo(lines[0], err=True)
@@ -216,6 +217,7 @@ def _run_auto_router(
216217
transport_id = resolve_transport_id_fn(transport_override)
217218
transport_backend = get_transport_fn(transport_id, allowlist=allowlist)
218219
except ConfigError as exc:
220+
logger.error("cli.config_error", error=str(exc))
219221
typer.echo(f"error: {exc}", err=True)
220222
raise typer.Exit(code=1) from exc
221223
if onboard:
@@ -307,6 +309,7 @@ def _run_auto_router(
307309
runtime=runtime,
308310
)
309311
except ConfigError as exc:
312+
logger.error("cli.config_error", error=str(exc))
310313
typer.echo(f"error: {exc}", err=True)
311314
raise typer.Exit(code=1) from exc
312315
except KeyboardInterrupt:

src/untether/runners/amp.py

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -522,16 +522,31 @@ def build_runner(config: EngineConfig, config_path: Path) -> Runner:
522522
"""Build an AmpRunner from configuration."""
523523
model = config.get("model")
524524
if model is not None and not isinstance(model, str):
525+
logger.warning(
526+
"amp.config.invalid",
527+
error="model must be a string",
528+
config_path=str(config_path),
529+
)
525530
raise ConfigError(f"Invalid `amp.model` in {config_path}; expected a string.")
526531

527532
mode = config.get("mode")
528533
if mode is not None and not isinstance(mode, str):
534+
logger.warning(
535+
"amp.config.invalid",
536+
error="mode must be a string",
537+
config_path=str(config_path),
538+
)
529539
raise ConfigError(f"Invalid `amp.mode` in {config_path}; expected a string.")
530540

531541
dangerously_allow_all = config.get("dangerously_allow_all")
532542
if dangerously_allow_all is None:
533543
dangerously_allow_all = True
534544
elif not isinstance(dangerously_allow_all, bool):
545+
logger.warning(
546+
"amp.config.invalid",
547+
error="dangerously_allow_all must be a boolean",
548+
config_path=str(config_path),
549+
)
535550
raise ConfigError(
536551
f"Invalid `amp.dangerously_allow_all` in {config_path}; expected a boolean."
537552
)
@@ -540,6 +555,11 @@ def build_runner(config: EngineConfig, config_path: Path) -> Runner:
540555
if stream_json_input is None:
541556
stream_json_input = False
542557
elif not isinstance(stream_json_input, bool):
558+
logger.warning(
559+
"amp.config.invalid",
560+
error="stream_json_input must be a boolean",
561+
config_path=str(config_path),
562+
)
543563
raise ConfigError(
544564
f"Invalid `amp.stream_json_input` in {config_path}; expected a boolean."
545565
)

src/untether/runners/codex.py

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -433,7 +433,7 @@ def translate_codex_event(
433433
) -> list[UntetherEvent]:
434434
match event:
435435
case codex_schema.ThreadStarted(thread_id=thread_id):
436-
logger.debug("codex.session.extracted", session_id=thread_id)
436+
logger.info("codex.session.started", session_id=thread_id)
437437
token = ResumeToken(engine=ENGINE, value=thread_id)
438438
return [factory.started(token, title=title, meta=meta)]
439439
case codex_schema.ItemStarted(item=item):
@@ -673,6 +673,11 @@ def process_error_events(
673673
if excerpt:
674674
parts.append(excerpt)
675675
message = "\n".join(parts)
676+
logger.error(
677+
"codex.process.failed",
678+
rc=rc,
679+
session_id=found_session.value if found_session else None,
680+
)
676681
resume_for_completed = found_session or resume
677682
return [
678683
self.note_event(
@@ -695,6 +700,7 @@ def stream_end_events(
695700
state: CodexRunState,
696701
) -> list[UntetherEvent]:
697702
if not found_session:
703+
logger.warning("codex.stream.no_session")
698704
parts = ["codex exec finished but no session_id/thread_id was captured"]
699705
session = _session_label(None, resume)
700706
if session:
@@ -728,12 +734,22 @@ def build_runner(config: EngineConfig, config_path: Path) -> Runner:
728734
):
729735
extra_args = list(extra_args_value)
730736
else:
737+
logger.warning(
738+
"codex.config.invalid",
739+
error="extra_args must be a list of strings",
740+
config_path=str(config_path),
741+
)
731742
raise ConfigError(
732743
f"Invalid `codex.extra_args` in {config_path}; expected a list of strings."
733744
)
734745

735746
exec_only_flag = find_exec_only_flag(extra_args)
736747
if exec_only_flag:
748+
logger.warning(
749+
"codex.config.invalid",
750+
error=f"exec-only flag {exec_only_flag!r} is managed by Untether",
751+
config_path=str(config_path),
752+
)
737753
raise ConfigError(
738754
f"Invalid `codex.extra_args` in {config_path}; exec-only flag "
739755
f"{exec_only_flag!r} is managed by Untether."
@@ -743,6 +759,11 @@ def build_runner(config: EngineConfig, config_path: Path) -> Runner:
743759
profile_value = config.get("profile")
744760
if profile_value:
745761
if not isinstance(profile_value, str):
762+
logger.warning(
763+
"codex.config.invalid",
764+
error="profile must be a string",
765+
config_path=str(config_path),
766+
)
746767
raise ConfigError(
747768
f"Invalid `codex.profile` in {config_path}; expected a string."
748769
)

src/untether/runners/gemini.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -526,6 +526,11 @@ def build_runner(config: EngineConfig, config_path: Path) -> Runner:
526526
"""Build a GeminiRunner from configuration."""
527527
model = config.get("model")
528528
if model is not None and not isinstance(model, str):
529+
logger.warning(
530+
"gemini.config.invalid",
531+
error="model must be a string",
532+
config_path=str(config_path),
533+
)
529534
raise ConfigError(
530535
f"Invalid `gemini.model` in {config_path}; expected a string."
531536
)

src/untether/runners/opencode.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -654,6 +654,11 @@ def build_runner(config: EngineConfig, config_path: Path) -> Runner:
654654

655655
model = config.get("model")
656656
if model is not None and not isinstance(model, str):
657+
logger.warning(
658+
"opencode.config.invalid",
659+
error="model must be a string",
660+
config_path=str(config_path),
661+
)
657662
raise ConfigError(
658663
f"Invalid `opencode.model` in {config_path}; expected a string."
659664
)

src/untether/runners/pi.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -588,16 +588,31 @@ def build_runner(config: EngineConfig, config_path: Path) -> Runner:
588588
):
589589
extra_args = list(extra_args_value)
590590
else:
591+
logger.warning(
592+
"pi.config.invalid",
593+
error="extra_args must be a list of strings",
594+
config_path=str(config_path),
595+
)
591596
raise ConfigError(
592597
f"Invalid `pi.extra_args` in {config_path}; expected a list of strings."
593598
)
594599

595600
model = config.get("model")
596601
if model is not None and not isinstance(model, str):
602+
logger.warning(
603+
"pi.config.invalid",
604+
error="model must be a string",
605+
config_path=str(config_path),
606+
)
597607
raise ConfigError(f"Invalid `pi.model` in {config_path}; expected a string.")
598608

599609
provider = config.get("provider")
600610
if provider is not None and not isinstance(provider, str):
611+
logger.warning(
612+
"pi.config.invalid",
613+
error="provider must be a string",
614+
config_path=str(config_path),
615+
)
601616
raise ConfigError(f"Invalid `pi.provider` in {config_path}; expected a string.")
602617

603618
return PiRunner(

0 commit comments

Comments
 (0)