Skip to content

Commit bff54f9

Browse files
authored
fix: brew tap-trust recovery, antenna boot blink, export secret redaction (#120)
* fix: brew tap-trust recovery, antenna boot blink, export secret redaction Three independent fixes to the updater, welcome banner, and session export. Homebrew untrusted-tap recovery: - Homebrew 5.0's HOMEBREW_REQUIRE_TAP_TRUST refuses third-party taps until `brew trust <tap>` is run once, so the in-app updater's `brew upgrade` failed with only a generic "run manually" hint. Parse both the hard "Refusing to load ... from untrusted tap" refusal and the soft "Skipping ... not trusted" warning; on an interactive terminal, offer to run `brew trust` and retry the upgrade once, otherwise print the exact remediation. Also handles the silent-no-op case where an untrusted tap is skipped during `brew update`. Documented in both READMEs. Antenna boot animation: - Replace the welcome logo's infinite SGR slow-blink with a bounded boot animation: blink the antenna ball 7 times after the banner prints, then pin it steady. Gated to real terminals tall enough to keep the antenna row on screen; skipped under reduced motion and non-TTY. E2E PTY helper runs with reduced motion so the animation adds no latency there. Export secret redaction: - A tool result (grep/cat over a .env) could surface a secret value into the exported transcript in plaintext. Add a conservative redact_secrets() that replaces the value of secret-named keys (password/token/api_key/secret/...) with [REDACTED], wired as a chokepoint in both the markdown and YAML export builders. Non-secret keys (token_count, usernames, ports) are untouched. * docs: add changelog entries for updater, export redaction, antenna blink * test: document benign-keys behavior of redact_secrets Keys where the secret hint is not immediately before the separator (token_count, access_key_id) must not be redacted. Lock that intentional behavior in per CodeRabbit's suggestion on #120. * test: make brew-warning string concatenation explicit GitHub code-quality flagged implicit string concatenation of adjacent literals inside the list arguments to _homebrew_untrusted_tap (a common missing-comma footgun). Use explicit '+' so the single-line intent is unambiguous to readers and the scanner.
1 parent 7caeca3 commit bff54f9

10 files changed

Lines changed: 619 additions & 46 deletions

File tree

CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,10 @@ GitHub Releases page; `0.8.0` is the new starting line.
1515

1616
## Unreleased
1717

18+
- **The in-app updater recovers from Homebrew's untrusted-tap refusal.** Homebrew 5.0 (`HOMEBREW_REQUIRE_TAP_TRUST`) refuses to load formulas from third-party taps until `brew trust <tap>` is run once, which made the in-app `brew upgrade` fail with only a generic "run manually" hint. The updater now detects both the hard `Refusing to load … from untrusted tap` refusal and the soft `Skipping … not trusted` warning, offers to run `brew trust pythoughts-labs/pythinker` and retry the upgrade once on an interactive terminal, and otherwise prints the exact remediation. It also catches the silent no-op where an untrusted tap is skipped during `brew update` and `brew upgrade` exits 0 without advancing the version.
19+
- **Session exports redact secrets surfaced by tool output.** A tool result (e.g. `grep`/`cat` over a `.env`) could write a secret value into an exported transcript in plaintext. `/export` now redacts the value of secret-named keys (`password`, `token`, `api_key`, `secret`, …) to `[REDACTED]` in both the markdown and YAML formats, while leaving non-secret keys such as `token_count`, usernames, and ports intact.
20+
- **The welcome logo's antenna blinks a fixed number of times on launch, then settles.** Replaces the terminal's indefinite slow-blink with a bounded boot animation — the antenna ball blinks seven times after the banner prints and then holds steady. It is skipped under reduced motion, on non-interactive output, and when the terminal is too short to keep the antenna row on screen.
21+
1822
## 0.41.0 (2026-06-11)
1923

2024
- **New `/goal` command — goal-driven execution that loops until verified.** `/goal <objective>` sets a persistent thread goal the agent pursues across turns, restarts, and context compaction until it is verifiably complete. It kicks off immediately with a success-criteria derivation prompt and is re-injected each turn with fidelity rules (no scope-shrinking, no easier-to-test substitutes) and an evidence-based completion audit — completion may only be claimed after every requirement is proven against current state. The new root-only `UpdateGoal` tool marks the goal `complete` (after that audit) or `blocked` (after a strict three-strike audit) and stops the reminders; opt-in `goal.auto_continue` (new config table, default off, `max_continuations` 1–10, capped at 3) drives automatic continuation turns toward the goal until it is marked, a tool call is rejected, or the cap is reached, with a wrap-up instruction on the final continuation. Subcommands: `view`, `pause`, `resume`, `clear`. Objectives are injected as untrusted data, never as higher-priority instructions.

README.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -214,6 +214,8 @@ the files and the PATH edit.
214214
```sh
215215
# 1. Install
216216
brew install Pythoughts-labs/pythinker/pythinker-code
217+
# Homebrew ≥ 5 may refuse the untrusted tap; trust it once, then re-run:
218+
# brew trust pythoughts-labs/pythinker
217219

218220
# 2. Verify
219221
pythinker --version
@@ -229,6 +231,11 @@ latest version.
229231
**Upgrade:** `brew upgrade pythinker-code` (Homebrew packages don't
230232
auto-update; run this whenever you want the latest).
231233

234+
> **Untrusted-tap refusal** — Homebrew ≥ 5 (with `HOMEBREW_REQUIRE_TAP_TRUST`)
235+
> refuses third-party taps until you trust them once:
236+
> `brew trust pythoughts-labs/pythinker`. The in-app updater detects the
237+
> refusal and offers to run it for you.
238+
232239
**Uninstall:** `brew uninstall pythinker-code && brew untap Pythoughts-labs/pythinker`.
233240

234241
> The tap repo is [Pythoughts-labs/homebrew-pythinker](https://github.com/Pythoughts-labs/homebrew-pythinker)

src/pythinker_code/ui/shell/__init__.py

Lines changed: 72 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,9 @@
2626
)
2727
from rich import box
2828
from rich.align import Align
29+
from rich.cells import cell_len
2930
from rich.console import Group, RenderableType
31+
from rich.control import Control
3032
from rich.markup import escape
3133
from rich.panel import Panel
3234
from rich.table import Table
@@ -2150,15 +2152,68 @@ def _cancel_background_tasks(self) -> None:
21502152
def _logo_text() -> Text:
21512153
"""Robot mark with a glowing antenna ball.
21522154
2153-
The ball carries the terminal's SGR slow-blink attribute, so terminals
2154-
with blinking text enabled blink it indefinitely; everywhere else the
2155-
bold light-coral glow reads as "powered on". Reduced motion pins the
2156-
ball steady and muted.
2155+
The ball renders steady; the boot animation (`_blink_antenna`) blinks it a
2156+
fixed number of times after the welcome banner prints, instead of the old
2157+
indefinite SGR slow-blink. Reduced motion pins the ball muted.
21572158
"""
2158-
antenna_style = _LOGO_CORAL if motion_disabled() else f"blink bold {_LOGO_CORAL_LIT}"
2159+
antenna_style = _LOGO_CORAL if motion_disabled() else f"bold {_LOGO_CORAL_LIT}"
21592160
return Text.from_markup(_LOGO_TEMPLATE.format(antenna_style=antenna_style))
21602161

21612162

2163+
# Boot animation: blink the antenna ball this many times once the agent has
2164+
# loaded and the welcome banner is on screen, then pin it steady.
2165+
_ANTENNA_BLINKS = 7
2166+
_ANTENNA_BLINK_OFF_SECONDS = 0.07
2167+
_ANTENNA_BLINK_ON_SECONDS = 0.09
2168+
_ANTENNA_GLYPH = "●"
2169+
2170+
2171+
def _antenna_cell(panel: Panel, panel_width: int) -> tuple[int, int] | None:
2172+
"""Locate the antenna ball in the rendered panel.
2173+
2174+
Returns ``(rows_above_cursor, column)`` valid immediately after the panel
2175+
prints (cursor sits on the line below it), or None when no antenna is
2176+
rendered. Scans top-down so the first ● found is the antenna, never a
2177+
same-glyph chip in the panel subtitle.
2178+
"""
2179+
options = console.options.update_width(panel_width)
2180+
lines = console.render_lines(panel, options, pad=False)
2181+
for row, segments in enumerate(lines):
2182+
column = 0
2183+
for segment in segments:
2184+
found = segment.text.find(_ANTENNA_GLYPH)
2185+
if found != -1:
2186+
return len(lines) - row, column + cell_len(segment.text[:found])
2187+
column += cell_len(segment.text)
2188+
return None
2189+
2190+
2191+
def _blink_antenna(rows_up: int, column: int) -> None:
2192+
"""Blink the antenna ball ``_ANTENNA_BLINKS`` times, then pin it steady.
2193+
2194+
Deliberately synchronous: nothing else writes to the terminal while it
2195+
runs, so the cursor-relative addressing stays valid. Runs only on the
2196+
startup path, bounded to ~1.1s total.
2197+
"""
2198+
states: list[tuple[Text, float]] = []
2199+
for _ in range(_ANTENNA_BLINKS):
2200+
states.append((Text(_ANTENNA_GLYPH, style=_LOGO_NAVY), _ANTENNA_BLINK_OFF_SECONDS))
2201+
states.append(
2202+
(Text(_ANTENNA_GLYPH, style=f"bold {_LOGO_CORAL_LIT}"), _ANTENNA_BLINK_ON_SECONDS)
2203+
)
2204+
states.append((Text(_ANTENNA_GLYPH, style=f"bold {_LOGO_CORAL_LIT}"), 0.0))
2205+
try:
2206+
console.control(Control.show_cursor(False))
2207+
for glyph, delay in states:
2208+
console.control(Control.move(y=-rows_up), Control.move_to_column(column))
2209+
console.print(glyph, end="")
2210+
console.control(Control.move(y=rows_up), Control.move_to_column(0))
2211+
if delay:
2212+
time.sleep(delay)
2213+
finally:
2214+
console.control(Control.show_cursor(True))
2215+
2216+
21622217
# 1:1 ASCII stand-ins for every decorative glyph the welcome banner emits
21632218
# (mirrors the server-banner fallback in utils/server.py). Welcome copy and
21642219
# chips pass through this when ascii_glyphs_enabled() is true so legacy code
@@ -2481,4 +2536,15 @@ def _panel() -> Panel:
24812536
padding=(1, 2),
24822537
)
24832538

2484-
console.print(_panel())
2539+
panel = _panel()
2540+
console.print(panel)
2541+
2542+
# Boot animation: blink the antenna 7 times, then stop. Only when the
2543+
# Unicode logo actually rendered, on a real terminal tall enough that the
2544+
# antenna row is still on screen, and never under reduced motion.
2545+
if logo_rendered and console.is_terminal and not motion_disabled():
2546+
cell = _antenna_cell(panel, panel_width)
2547+
if cell is not None:
2548+
rows_up, column = cell
2549+
if rows_up < console.size.height:
2550+
_blink_antenna(rows_up, column)

src/pythinker_code/ui/shell/update.py

Lines changed: 138 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -648,6 +648,56 @@ def _is_homebrew_upgrade_command(command: list[str]) -> bool:
648648
return len(command) >= 3 and command[:2] == ["brew", "upgrade"]
649649

650650

651+
# Homebrew >= 5 refuses to read formulas from third-party taps until the user
652+
# runs `brew trust <tap>` (gated on $HOMEBREW_REQUIRE_TAP_TRUST). The hard
653+
# refusal names the tap our formula lives in and is authoritative; the soft
654+
# "Skipping <tap>" warning is also emitted for unrelated taps during
655+
# `brew update`, so it only counts when it names our tap.
656+
_BREW_REFUSED_UNTRUSTED_TAP_RE = re.compile(r"Refusing to load .+ from untrusted tap (\S+)")
657+
_BREW_SKIPPED_UNTRUSTED_TAP_RE = re.compile(r"Skipping (\S+) because it is not trusted")
658+
659+
660+
def _homebrew_untrusted_tap(output_lines: list[str]) -> str | None:
661+
"""Tap named in Homebrew's untrusted-tap refusal/skip output, or None."""
662+
for line in output_lines:
663+
match = _BREW_REFUSED_UNTRUSTED_TAP_RE.search(line)
664+
if match:
665+
return match.group(1).rstrip(".")
666+
for line in output_lines:
667+
match = _BREW_SKIPPED_UNTRUSTED_TAP_RE.search(line)
668+
if match and "pythinker" in match.group(1):
669+
return match.group(1).rstrip(".")
670+
return None
671+
672+
673+
def _can_prompt_to_trust_tap(print_output: bool) -> bool:
674+
"""Consent to `brew trust` needs a real terminal; logs/callbacks cannot answer."""
675+
return print_output and sys.stdout.isatty()
676+
677+
678+
async def _confirm_brew_trust(tap: str) -> bool:
679+
"""One-keypress consent to trust the tap. Declines on EOF/interrupt."""
680+
from prompt_toolkit.shortcuts.choice_input import ChoiceInput
681+
682+
_t = _get_tui_tokens()
683+
console.print(
684+
f"[{_t.warning}]Homebrew now requires a one-time 'brew trust' before "
685+
"installing from third-party taps.[/]"
686+
)
687+
try:
688+
selection = await ChoiceInput(
689+
message=f"Trust the tap {tap} and retry the update?",
690+
options=[
691+
("trust", f"Run: brew trust {tap}"),
692+
("skip", "Not now"),
693+
],
694+
default="trust",
695+
).prompt_async()
696+
except (EOFError, KeyboardInterrupt):
697+
return False
698+
return selection == "trust"
699+
700+
651701
def _installed_homebrew_version() -> str | None:
652702
"""Return the highest pythinker-code version Homebrew reports as installed.
653703
@@ -1346,7 +1396,19 @@ def _print(message: str) -> None:
13461396
_print(f"[{_t.muted}]The upgrade will continue in a new process.[/]")
13471397
sys.exit(0)
13481398

1349-
if _is_homebrew_upgrade_command(upgrade_command):
1399+
# Brew failure diagnosis (untrusted tap, silent no-op) needs the upgrade
1400+
# output; capture it while still forwarding every line to the caller.
1401+
captured_output: list[str] = []
1402+
1403+
def _run_streamed(command: list[str]) -> int:
1404+
def _capture(line: str) -> None:
1405+
captured_output.append(line)
1406+
if output_callback is not None:
1407+
output_callback(line)
1408+
1409+
return _run_upgrade_command(command, print_output=print_output, output_callback=_capture)
1410+
1411+
def _refresh_brew_metadata() -> None:
13501412
# `brew upgrade <formula>` resolves against the locally-cloned tap
13511413
# formula; a stale clone pins the old version and the upgrade silently
13521414
# no-ops ("already installed"). Refresh the tap first. Best-effort: if
@@ -1357,11 +1419,7 @@ def _print(message: str) -> None:
13571419
# --quiet keeps the refresh from dumping the host's full outdated
13581420
# formula/cask list (often dozens of unrelated lines) before our
13591421
# upgrade output.
1360-
refresh_code = _run_upgrade_command(
1361-
["brew", "update", "--quiet"],
1362-
print_output=print_output,
1363-
output_callback=output_callback,
1364-
)
1422+
refresh_code = _run_streamed(["brew", "update", "--quiet"])
13651423
except OSError:
13661424
logger.exception("brew update failed to launch:")
13671425
else:
@@ -1370,36 +1428,84 @@ def _print(message: str) -> None:
13701428
"brew update exited {code}; continuing with upgrade", code=refresh_code
13711429
)
13721430

1373-
try:
1374-
returncode = _run_upgrade_command(
1375-
upgrade_command,
1376-
print_output=print_output,
1377-
output_callback=output_callback,
1431+
def _print_brew_trust_hint(tap: str) -> None:
1432+
_print(f"[{_t.warning}]Trust the tap once, then update:[/]")
1433+
_print(f" brew trust {shlex_quote(tap)}")
1434+
_print(f" {upgrade_command_text}")
1435+
1436+
if _is_homebrew_upgrade_command(upgrade_command):
1437+
_refresh_brew_metadata()
1438+
1439+
brew_trust_attempted = False
1440+
while True:
1441+
try:
1442+
returncode = _run_streamed(upgrade_command)
1443+
except OSError as e:
1444+
logger.exception("Upgrade failed:")
1445+
_print(f"[{_t.error}]Upgrade failed:[/] {e}")
1446+
_print(f"Please run manually: {upgrade_command_text}")
1447+
return UpdateResult.FAILED
1448+
1449+
if returncode == 0:
1450+
break
1451+
1452+
untrusted_tap = (
1453+
_homebrew_untrusted_tap(captured_output)
1454+
if _is_homebrew_upgrade_command(upgrade_command)
1455+
else None
13781456
)
1379-
except OSError as e:
1380-
logger.exception("Upgrade failed:")
1381-
_print(f"[{_t.error}]Upgrade failed:[/] {e}")
1382-
_print(f"Please run manually: {upgrade_command_text}")
1457+
if untrusted_tap is None:
1458+
_print(f"[{_t.error}]Upgrade failed. Please try running manually:[/]")
1459+
_print(f" {upgrade_command_text}")
1460+
return UpdateResult.FAILED
1461+
1462+
logger.warning("Homebrew refused untrusted tap {tap}", tap=untrusted_tap)
1463+
_print(
1464+
f"[{_t.error}]Upgrade failed: Homebrew refuses to load formulas "
1465+
f"from the untrusted tap {untrusted_tap}.[/]"
1466+
)
1467+
if (
1468+
not brew_trust_attempted
1469+
and _can_prompt_to_trust_tap(print_output)
1470+
and await _confirm_brew_trust(untrusted_tap)
1471+
):
1472+
brew_trust_attempted = True
1473+
try:
1474+
trust_code = _run_streamed(["brew", "trust", untrusted_tap])
1475+
except OSError:
1476+
logger.exception("brew trust failed to launch:")
1477+
trust_code = 1
1478+
if trust_code == 0:
1479+
# `brew update` skipped the untrusted tap above, so its clone
1480+
# may still be stale — refresh again before retrying.
1481+
captured_output.clear()
1482+
_print(f"[{_t.muted}]Tap trusted. Retrying the upgrade...[/]")
1483+
_refresh_brew_metadata()
1484+
continue
1485+
_print(f"[{_t.error}]'brew trust {untrusted_tap}' failed.[/]")
1486+
_print_brew_trust_hint(untrusted_tap)
13831487
return UpdateResult.FAILED
13841488

1385-
if returncode == 0:
1386-
if _is_homebrew_upgrade_command(upgrade_command):
1387-
installed = _installed_homebrew_version()
1388-
if installed is not None and semver_tuple(installed) < semver_tuple(latest_version):
1389-
# brew exited 0 without changing anything (stale tap / no-op).
1390-
# Reporting success here is the bug we are fixing: don't.
1391-
_print(
1392-
f"[{_t.error}]Homebrew exited cleanly but pythinker-code is "
1393-
f"still {installed}, not {latest_version}.[/]"
1394-
)
1489+
if _is_homebrew_upgrade_command(upgrade_command):
1490+
installed = _installed_homebrew_version()
1491+
if installed is not None and semver_tuple(installed) < semver_tuple(latest_version):
1492+
# brew exited 0 without changing anything (stale tap / no-op).
1493+
# Reporting success here is the bug we are fixing: don't.
1494+
_print(
1495+
f"[{_t.error}]Homebrew exited cleanly but pythinker-code is "
1496+
f"still {installed}, not {latest_version}.[/]"
1497+
)
1498+
untrusted_tap = _homebrew_untrusted_tap(captured_output)
1499+
if untrusted_tap is not None:
1500+
# The no-op happened because `brew update` skipped our
1501+
# untrusted tap, pinning the old formula.
1502+
_print_brew_trust_hint(untrusted_tap)
1503+
else:
13951504
_print(
13961505
f"[{_t.warning}]The Homebrew tap metadata looks stale. "
13971506
"Run 'brew update' and try '/update' again.[/]"
13981507
)
1399-
return UpdateResult.FAILED
1400-
_print(f"[{_t.success}]Updated successfully![/]")
1401-
_print(f"[{_t.warning}]Restart Pythinker CLI to use the new version.[/]")
1402-
return UpdateResult.UPDATED
1403-
_print(f"[{_t.error}]Upgrade failed. Please try running manually:[/]")
1404-
_print(f" {upgrade_command_text}")
1405-
return UpdateResult.FAILED
1508+
return UpdateResult.FAILED
1509+
_print(f"[{_t.success}]Updated successfully![/]")
1510+
_print(f"[{_t.warning}]Restart Pythinker CLI to use the new version.[/]")
1511+
return UpdateResult.UPDATED

src/pythinker_code/utils/export.py

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
from pythinker_code.utils.message import message_stringify
1919
from pythinker_code.utils.path import sanitize_cli_path
2020
from pythinker_code.utils.sensitive import is_sensitive_file as is_sensitive_path
21+
from pythinker_code.utils.sensitive import redact_secrets
2122
from pythinker_code.utils.string import shorten
2223
from pythinker_code.wire.types import (
2324
AudioURLPart,
@@ -308,7 +309,10 @@ def build_export_markdown(
308309
for idx, turn_messages in enumerate(turns):
309310
lines.append(_format_turn_md(turn_messages, idx + 1))
310311

311-
return "\n".join(lines)
312+
# Defense-in-depth: a tool result (grep/cat over a .env, etc.) can surface a
313+
# secret value into the transcript. Redact KEY=VALUE secrets before the
314+
# export lands on disk so credentials don't leak into shared exports.
315+
return redact_secrets("\n".join(lines))
312316

313317

314318
def _compact_message_record(msg: Message) -> dict[str, object]:
@@ -366,7 +370,9 @@ def build_export_yaml(
366370
for idx, turn in enumerate(turns, start=1)
367371
],
368372
}
369-
return yaml.safe_dump(payload, sort_keys=False, allow_unicode=True)
373+
# See build_export_markdown: strip KEY=VALUE secrets surfaced by tool output
374+
# before the transcript is written.
375+
return redact_secrets(yaml.safe_dump(payload, sort_keys=False, allow_unicode=True))
370376

371377

372378
# ---------------------------------------------------------------------------

0 commit comments

Comments
 (0)