diff --git a/CHANGELOG.md b/CHANGELOG.md index 1212aa0a..aa0c30e7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,10 @@ GitHub Releases page; `0.8.0` is the new starting line. ## Unreleased +- **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 ` 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. +- **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. +- **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. + ## 0.41.0 (2026-06-11) - **New `/goal` command — goal-driven execution that loops until verified.** `/goal ` 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. diff --git a/README.md b/README.md index 0e4ff8b9..85318722 100644 --- a/README.md +++ b/README.md @@ -214,6 +214,8 @@ the files and the PATH edit. ```sh # 1. Install brew install Pythoughts-labs/pythinker/pythinker-code +# Homebrew ≥ 5 may refuse the untrusted tap; trust it once, then re-run: +# brew trust pythoughts-labs/pythinker # 2. Verify pythinker --version @@ -229,6 +231,11 @@ latest version. **Upgrade:** `brew upgrade pythinker-code` (Homebrew packages don't auto-update; run this whenever you want the latest). +> **Untrusted-tap refusal** — Homebrew ≥ 5 (with `HOMEBREW_REQUIRE_TAP_TRUST`) +> refuses third-party taps until you trust them once: +> `brew trust pythoughts-labs/pythinker`. The in-app updater detects the +> refusal and offers to run it for you. + **Uninstall:** `brew uninstall pythinker-code && brew untap Pythoughts-labs/pythinker`. > The tap repo is [Pythoughts-labs/homebrew-pythinker](https://github.com/Pythoughts-labs/homebrew-pythinker) diff --git a/src/pythinker_code/ui/shell/__init__.py b/src/pythinker_code/ui/shell/__init__.py index 99d819af..cb913898 100644 --- a/src/pythinker_code/ui/shell/__init__.py +++ b/src/pythinker_code/ui/shell/__init__.py @@ -26,7 +26,9 @@ ) from rich import box from rich.align import Align +from rich.cells import cell_len from rich.console import Group, RenderableType +from rich.control import Control from rich.markup import escape from rich.panel import Panel from rich.table import Table @@ -2150,15 +2152,68 @@ def _cancel_background_tasks(self) -> None: def _logo_text() -> Text: """Robot mark with a glowing antenna ball. - The ball carries the terminal's SGR slow-blink attribute, so terminals - with blinking text enabled blink it indefinitely; everywhere else the - bold light-coral glow reads as "powered on". Reduced motion pins the - ball steady and muted. + The ball renders steady; the boot animation (`_blink_antenna`) blinks it a + fixed number of times after the welcome banner prints, instead of the old + indefinite SGR slow-blink. Reduced motion pins the ball muted. """ - antenna_style = _LOGO_CORAL if motion_disabled() else f"blink bold {_LOGO_CORAL_LIT}" + antenna_style = _LOGO_CORAL if motion_disabled() else f"bold {_LOGO_CORAL_LIT}" return Text.from_markup(_LOGO_TEMPLATE.format(antenna_style=antenna_style)) +# Boot animation: blink the antenna ball this many times once the agent has +# loaded and the welcome banner is on screen, then pin it steady. +_ANTENNA_BLINKS = 7 +_ANTENNA_BLINK_OFF_SECONDS = 0.07 +_ANTENNA_BLINK_ON_SECONDS = 0.09 +_ANTENNA_GLYPH = "●" + + +def _antenna_cell(panel: Panel, panel_width: int) -> tuple[int, int] | None: + """Locate the antenna ball in the rendered panel. + + Returns ``(rows_above_cursor, column)`` valid immediately after the panel + prints (cursor sits on the line below it), or None when no antenna is + rendered. Scans top-down so the first ● found is the antenna, never a + same-glyph chip in the panel subtitle. + """ + options = console.options.update_width(panel_width) + lines = console.render_lines(panel, options, pad=False) + for row, segments in enumerate(lines): + column = 0 + for segment in segments: + found = segment.text.find(_ANTENNA_GLYPH) + if found != -1: + return len(lines) - row, column + cell_len(segment.text[:found]) + column += cell_len(segment.text) + return None + + +def _blink_antenna(rows_up: int, column: int) -> None: + """Blink the antenna ball ``_ANTENNA_BLINKS`` times, then pin it steady. + + Deliberately synchronous: nothing else writes to the terminal while it + runs, so the cursor-relative addressing stays valid. Runs only on the + startup path, bounded to ~1.1s total. + """ + states: list[tuple[Text, float]] = [] + for _ in range(_ANTENNA_BLINKS): + states.append((Text(_ANTENNA_GLYPH, style=_LOGO_NAVY), _ANTENNA_BLINK_OFF_SECONDS)) + states.append( + (Text(_ANTENNA_GLYPH, style=f"bold {_LOGO_CORAL_LIT}"), _ANTENNA_BLINK_ON_SECONDS) + ) + states.append((Text(_ANTENNA_GLYPH, style=f"bold {_LOGO_CORAL_LIT}"), 0.0)) + try: + console.control(Control.show_cursor(False)) + for glyph, delay in states: + console.control(Control.move(y=-rows_up), Control.move_to_column(column)) + console.print(glyph, end="") + console.control(Control.move(y=rows_up), Control.move_to_column(0)) + if delay: + time.sleep(delay) + finally: + console.control(Control.show_cursor(True)) + + # 1:1 ASCII stand-ins for every decorative glyph the welcome banner emits # (mirrors the server-banner fallback in utils/server.py). Welcome copy and # chips pass through this when ascii_glyphs_enabled() is true so legacy code @@ -2481,4 +2536,15 @@ def _panel() -> Panel: padding=(1, 2), ) - console.print(_panel()) + panel = _panel() + console.print(panel) + + # Boot animation: blink the antenna 7 times, then stop. Only when the + # Unicode logo actually rendered, on a real terminal tall enough that the + # antenna row is still on screen, and never under reduced motion. + if logo_rendered and console.is_terminal and not motion_disabled(): + cell = _antenna_cell(panel, panel_width) + if cell is not None: + rows_up, column = cell + if rows_up < console.size.height: + _blink_antenna(rows_up, column) diff --git a/src/pythinker_code/ui/shell/update.py b/src/pythinker_code/ui/shell/update.py index a5619d02..9a64f0ee 100644 --- a/src/pythinker_code/ui/shell/update.py +++ b/src/pythinker_code/ui/shell/update.py @@ -648,6 +648,56 @@ def _is_homebrew_upgrade_command(command: list[str]) -> bool: return len(command) >= 3 and command[:2] == ["brew", "upgrade"] +# Homebrew >= 5 refuses to read formulas from third-party taps until the user +# runs `brew trust ` (gated on $HOMEBREW_REQUIRE_TAP_TRUST). The hard +# refusal names the tap our formula lives in and is authoritative; the soft +# "Skipping " warning is also emitted for unrelated taps during +# `brew update`, so it only counts when it names our tap. +_BREW_REFUSED_UNTRUSTED_TAP_RE = re.compile(r"Refusing to load .+ from untrusted tap (\S+)") +_BREW_SKIPPED_UNTRUSTED_TAP_RE = re.compile(r"Skipping (\S+) because it is not trusted") + + +def _homebrew_untrusted_tap(output_lines: list[str]) -> str | None: + """Tap named in Homebrew's untrusted-tap refusal/skip output, or None.""" + for line in output_lines: + match = _BREW_REFUSED_UNTRUSTED_TAP_RE.search(line) + if match: + return match.group(1).rstrip(".") + for line in output_lines: + match = _BREW_SKIPPED_UNTRUSTED_TAP_RE.search(line) + if match and "pythinker" in match.group(1): + return match.group(1).rstrip(".") + return None + + +def _can_prompt_to_trust_tap(print_output: bool) -> bool: + """Consent to `brew trust` needs a real terminal; logs/callbacks cannot answer.""" + return print_output and sys.stdout.isatty() + + +async def _confirm_brew_trust(tap: str) -> bool: + """One-keypress consent to trust the tap. Declines on EOF/interrupt.""" + from prompt_toolkit.shortcuts.choice_input import ChoiceInput + + _t = _get_tui_tokens() + console.print( + f"[{_t.warning}]Homebrew now requires a one-time 'brew trust' before " + "installing from third-party taps.[/]" + ) + try: + selection = await ChoiceInput( + message=f"Trust the tap {tap} and retry the update?", + options=[ + ("trust", f"Run: brew trust {tap}"), + ("skip", "Not now"), + ], + default="trust", + ).prompt_async() + except (EOFError, KeyboardInterrupt): + return False + return selection == "trust" + + def _installed_homebrew_version() -> str | None: """Return the highest pythinker-code version Homebrew reports as installed. @@ -1346,7 +1396,19 @@ def _print(message: str) -> None: _print(f"[{_t.muted}]The upgrade will continue in a new process.[/]") sys.exit(0) - if _is_homebrew_upgrade_command(upgrade_command): + # Brew failure diagnosis (untrusted tap, silent no-op) needs the upgrade + # output; capture it while still forwarding every line to the caller. + captured_output: list[str] = [] + + def _run_streamed(command: list[str]) -> int: + def _capture(line: str) -> None: + captured_output.append(line) + if output_callback is not None: + output_callback(line) + + return _run_upgrade_command(command, print_output=print_output, output_callback=_capture) + + def _refresh_brew_metadata() -> None: # `brew upgrade ` resolves against the locally-cloned tap # formula; a stale clone pins the old version and the upgrade silently # no-ops ("already installed"). Refresh the tap first. Best-effort: if @@ -1357,11 +1419,7 @@ def _print(message: str) -> None: # --quiet keeps the refresh from dumping the host's full outdated # formula/cask list (often dozens of unrelated lines) before our # upgrade output. - refresh_code = _run_upgrade_command( - ["brew", "update", "--quiet"], - print_output=print_output, - output_callback=output_callback, - ) + refresh_code = _run_streamed(["brew", "update", "--quiet"]) except OSError: logger.exception("brew update failed to launch:") else: @@ -1370,36 +1428,84 @@ def _print(message: str) -> None: "brew update exited {code}; continuing with upgrade", code=refresh_code ) - try: - returncode = _run_upgrade_command( - upgrade_command, - print_output=print_output, - output_callback=output_callback, + def _print_brew_trust_hint(tap: str) -> None: + _print(f"[{_t.warning}]Trust the tap once, then update:[/]") + _print(f" brew trust {shlex_quote(tap)}") + _print(f" {upgrade_command_text}") + + if _is_homebrew_upgrade_command(upgrade_command): + _refresh_brew_metadata() + + brew_trust_attempted = False + while True: + try: + returncode = _run_streamed(upgrade_command) + except OSError as e: + logger.exception("Upgrade failed:") + _print(f"[{_t.error}]Upgrade failed:[/] {e}") + _print(f"Please run manually: {upgrade_command_text}") + return UpdateResult.FAILED + + if returncode == 0: + break + + untrusted_tap = ( + _homebrew_untrusted_tap(captured_output) + if _is_homebrew_upgrade_command(upgrade_command) + else None ) - except OSError as e: - logger.exception("Upgrade failed:") - _print(f"[{_t.error}]Upgrade failed:[/] {e}") - _print(f"Please run manually: {upgrade_command_text}") + if untrusted_tap is None: + _print(f"[{_t.error}]Upgrade failed. Please try running manually:[/]") + _print(f" {upgrade_command_text}") + return UpdateResult.FAILED + + logger.warning("Homebrew refused untrusted tap {tap}", tap=untrusted_tap) + _print( + f"[{_t.error}]Upgrade failed: Homebrew refuses to load formulas " + f"from the untrusted tap {untrusted_tap}.[/]" + ) + if ( + not brew_trust_attempted + and _can_prompt_to_trust_tap(print_output) + and await _confirm_brew_trust(untrusted_tap) + ): + brew_trust_attempted = True + try: + trust_code = _run_streamed(["brew", "trust", untrusted_tap]) + except OSError: + logger.exception("brew trust failed to launch:") + trust_code = 1 + if trust_code == 0: + # `brew update` skipped the untrusted tap above, so its clone + # may still be stale — refresh again before retrying. + captured_output.clear() + _print(f"[{_t.muted}]Tap trusted. Retrying the upgrade...[/]") + _refresh_brew_metadata() + continue + _print(f"[{_t.error}]'brew trust {untrusted_tap}' failed.[/]") + _print_brew_trust_hint(untrusted_tap) return UpdateResult.FAILED - if returncode == 0: - if _is_homebrew_upgrade_command(upgrade_command): - installed = _installed_homebrew_version() - if installed is not None and semver_tuple(installed) < semver_tuple(latest_version): - # brew exited 0 without changing anything (stale tap / no-op). - # Reporting success here is the bug we are fixing: don't. - _print( - f"[{_t.error}]Homebrew exited cleanly but pythinker-code is " - f"still {installed}, not {latest_version}.[/]" - ) + if _is_homebrew_upgrade_command(upgrade_command): + installed = _installed_homebrew_version() + if installed is not None and semver_tuple(installed) < semver_tuple(latest_version): + # brew exited 0 without changing anything (stale tap / no-op). + # Reporting success here is the bug we are fixing: don't. + _print( + f"[{_t.error}]Homebrew exited cleanly but pythinker-code is " + f"still {installed}, not {latest_version}.[/]" + ) + untrusted_tap = _homebrew_untrusted_tap(captured_output) + if untrusted_tap is not None: + # The no-op happened because `brew update` skipped our + # untrusted tap, pinning the old formula. + _print_brew_trust_hint(untrusted_tap) + else: _print( f"[{_t.warning}]The Homebrew tap metadata looks stale. " "Run 'brew update' and try '/update' again.[/]" ) - return UpdateResult.FAILED - _print(f"[{_t.success}]Updated successfully![/]") - _print(f"[{_t.warning}]Restart Pythinker CLI to use the new version.[/]") - return UpdateResult.UPDATED - _print(f"[{_t.error}]Upgrade failed. Please try running manually:[/]") - _print(f" {upgrade_command_text}") - return UpdateResult.FAILED + return UpdateResult.FAILED + _print(f"[{_t.success}]Updated successfully![/]") + _print(f"[{_t.warning}]Restart Pythinker CLI to use the new version.[/]") + return UpdateResult.UPDATED diff --git a/src/pythinker_code/utils/export.py b/src/pythinker_code/utils/export.py index 764b1960..50ce039c 100644 --- a/src/pythinker_code/utils/export.py +++ b/src/pythinker_code/utils/export.py @@ -18,6 +18,7 @@ from pythinker_code.utils.message import message_stringify from pythinker_code.utils.path import sanitize_cli_path from pythinker_code.utils.sensitive import is_sensitive_file as is_sensitive_path +from pythinker_code.utils.sensitive import redact_secrets from pythinker_code.utils.string import shorten from pythinker_code.wire.types import ( AudioURLPart, @@ -308,7 +309,10 @@ def build_export_markdown( for idx, turn_messages in enumerate(turns): lines.append(_format_turn_md(turn_messages, idx + 1)) - return "\n".join(lines) + # Defense-in-depth: a tool result (grep/cat over a .env, etc.) can surface a + # secret value into the transcript. Redact KEY=VALUE secrets before the + # export lands on disk so credentials don't leak into shared exports. + return redact_secrets("\n".join(lines)) def _compact_message_record(msg: Message) -> dict[str, object]: @@ -366,7 +370,9 @@ def build_export_yaml( for idx, turn in enumerate(turns, start=1) ], } - return yaml.safe_dump(payload, sort_keys=False, allow_unicode=True) + # See build_export_markdown: strip KEY=VALUE secrets surfaced by tool output + # before the transcript is written. + return redact_secrets(yaml.safe_dump(payload, sort_keys=False, allow_unicode=True)) # --------------------------------------------------------------------------- diff --git a/src/pythinker_code/utils/sensitive.py b/src/pythinker_code/utils/sensitive.py index cb70dbad..31fa69bc 100644 --- a/src/pythinker_code/utils/sensitive.py +++ b/src/pythinker_code/utils/sensitive.py @@ -1,6 +1,7 @@ from __future__ import annotations import fnmatch +import re from pathlib import PurePath # High-confidence sensitive file patterns. @@ -43,6 +44,54 @@ def is_sensitive_file(path: str) -> bool: return False +_REDACTED = "[REDACTED]" + +# Key names (case-insensitive) whose assigned value is a secret. Matched as a +# substring of the key, so PASSWORD covers ADMIN_PASSWORD, DB_PASSWORD, etc. +_SECRET_KEY_HINTS = ( + "password", + "passwd", + "secret", + "token", + "api_key", + "apikey", + "access_key", + "private_key", + "credential", + "auth_token", + "client_secret", +) +_SECRET_KEY_GROUP = "|".join(re.escape(h) for h in _SECRET_KEY_HINTS) + +# A key=value or key: value assignment whose key ENDS with a secret hint. The +# hint must sit immediately before the separator (optionally through a closing +# JSON quote) so benign keys like ``token_count`` / ``access_key_id`` are left +# alone while ``ADMIN_PASSWORD`` / ``SECRET_TOKEN`` / ``"api_key"`` match. A +# leading grep line-number/path prefix ("3ADMIN_PASSWORD=…") is preserved. +_SECRET_ASSIGNMENT_RE = re.compile( + rf"(?im)^(?P.*?(?:{_SECRET_KEY_GROUP})[\"']?\s*[=:]\s*)(?P\S.*?)\s*$" +) + + +def redact_secrets(text: str) -> str: + """Redact secret values from free text (tool output, exported transcripts). + + Conservative and line-oriented: only the value of a ``KEY=VALUE`` / + ``KEY: VALUE`` assignment whose key looks like a secret (password, token, + api_key, …) is replaced with ``[REDACTED]``. Non-secret keys, prose, and + code are left untouched. This is defense-in-depth for places where a tool + result may have surfaced an ``.env`` value — not a guarantee that every + possible secret format is caught. + """ + if not text: + return text + + def _sub(match: re.Match[str]) -> str: + return f"{match.group('prefix')}{_REDACTED}" + + return _SECRET_ASSIGNMENT_RE.sub(_sub, text) + + def sensitive_file_warning(paths: list[str]) -> str: """Generate a warning message for sensitive files that were skipped.""" names = sorted({PurePath(p).name for p in paths}) diff --git a/tests/e2e/shell_pty_helpers.py b/tests/e2e/shell_pty_helpers.py index bf070477..b171936d 100644 --- a/tests/e2e/shell_pty_helpers.py +++ b/tests/e2e/shell_pty_helpers.py @@ -228,6 +228,9 @@ def start_shell_pty( env["TERM"] = "xterm-256color" env["PYTHONUTF8"] = "1" env["PROMPT_TOOLKIT_NO_CPR"] = "1" + # Static affordances only: the antenna boot blink (and any future motion) + # adds startup latency and cursor-control noise these tests don't assert. + env["PYTHINKER_REDUCED_MOTION"] = "1" env.pop("NO_COLOR", None) cmd = [sys.executable, "-m", "pythinker_code.cli"] diff --git a/tests/ui_and_conv/test_shell_update.py b/tests/ui_and_conv/test_shell_update.py index 107df817..9dd2a676 100644 --- a/tests/ui_and_conv/test_shell_update.py +++ b/tests/ui_and_conv/test_shell_update.py @@ -1353,6 +1353,205 @@ def fake_run_upgrade_command(command, *, print_output: bool, output_callback): assert ran == [["brew", "update", "--quiet"], ["brew", "upgrade", "pythinker-code"]] +_BREW_UNTRUSTED_TAP_ERROR = ( + "Error: Refusing to load formula pythoughts-labs/pythinker/pythinker-code " + "from untrusted tap pythoughts-labs/pythinker." +) + + +def test_homebrew_untrusted_tap_parses_refusal(): + """The hard refusal names the tap our formula lives in; trailing period + and surrounding lines must not leak into the tap name.""" + lines = [ + "==> Updating Homebrew...", + _BREW_UNTRUSTED_TAP_ERROR, + "Run `brew trust pythoughts-labs/pythinker` to trust it.", + ] + assert update._homebrew_untrusted_tap(lines) == "pythoughts-labs/pythinker" + + +def test_homebrew_untrusted_tap_ignores_unrelated_skip_warnings(): + """`brew update` warns about every untrusted tap on the machine; warnings + for taps that are not ours must not trigger the trust hint.""" + lines = [ + "Warning: Skipping mongodb/brew because it is not trusted. " + + "Run `brew trust mongodb/brew` to trust it.", + "Warning: Skipping oven-sh/bun because it is not trusted.", + ] + assert update._homebrew_untrusted_tap(lines) is None + + +def test_homebrew_untrusted_tap_accepts_own_tap_skip_warning(): + lines = [ + "Warning: Skipping pythoughts-labs/pythinker because it is not trusted. " + + "Run `brew trust pythoughts-labs/pythinker` to trust it.", + ] + assert update._homebrew_untrusted_tap(lines) == "pythoughts-labs/pythinker" + + +def test_homebrew_untrusted_tap_none_on_unrelated_output(): + assert update._homebrew_untrusted_tap(["==> Upgrading pythinker-code"]) is None + + +@pytest.mark.asyncio +async def test_do_update_brew_untrusted_tap_prints_trust_hint(monkeypatch, tmp_path): + """Homebrew >= 5 refuses formulas from untrusted taps. Non-interactively the + updater must surface the exact `brew trust` remediation, not the generic + 'run manually' failure.""" + messages: list[str] = [] + + def fake_run_upgrade_command(command, *, print_output: bool, output_callback): + if command == ["brew", "update", "--quiet"]: + return 0 + assert command == ["brew", "upgrade", "pythinker-code"] + output_callback(_BREW_UNTRUSTED_TAP_ERROR) + return 1 + + _brew_upgrade_do_update_env(monkeypatch, tmp_path) + monkeypatch.setattr(update, "_run_upgrade_command", fake_run_upgrade_command) + monkeypatch.setattr(update, "_can_prompt_to_trust_tap", lambda print_output: False) + + result = await update.do_update(print_output=False, output_callback=messages.append) + + assert result is update.UpdateResult.FAILED + assert any("brew trust pythoughts-labs/pythinker" in m for m in messages) + # The raw brew error still reaches the caller's callback unmodified. + assert _BREW_UNTRUSTED_TAP_ERROR in messages + + +@pytest.mark.asyncio +async def test_do_update_brew_untrusted_tap_trusts_and_retries_on_consent(monkeypatch, tmp_path): + """With an interactive console and user consent, the updater runs + `brew trust `, refreshes the tap, retries once, and succeeds.""" + ran: list[list[str]] = [] + + def fake_run_upgrade_command(command, *, print_output: bool, output_callback): + ran.append(command) + if ( + command == ["brew", "upgrade", "pythinker-code"] + and [ + "brew", + "trust", + "pythoughts-labs/pythinker", + ] + not in ran + ): + output_callback(_BREW_UNTRUSTED_TAP_ERROR) + return 1 + return 0 + + async def fake_confirm(tap: str) -> bool: + assert tap == "pythoughts-labs/pythinker" + return True + + _brew_upgrade_do_update_env(monkeypatch, tmp_path) + monkeypatch.setattr(update, "_run_upgrade_command", fake_run_upgrade_command) + monkeypatch.setattr(update, "_can_prompt_to_trust_tap", lambda print_output: True) + monkeypatch.setattr(update, "_confirm_brew_trust", fake_confirm) + monkeypatch.setattr(update, "_installed_homebrew_version", lambda: "999.0.0") + + result = await update.do_update(print_output=False) + + assert result is update.UpdateResult.UPDATED + assert ran == [ + ["brew", "update", "--quiet"], + ["brew", "upgrade", "pythinker-code"], + ["brew", "trust", "pythoughts-labs/pythinker"], + ["brew", "update", "--quiet"], + ["brew", "upgrade", "pythinker-code"], + ] + + +@pytest.mark.asyncio +async def test_do_update_brew_untrusted_tap_declined_consent_fails_with_hint(monkeypatch, tmp_path): + """Declining the trust prompt must not run `brew trust`; the manual + remediation is printed and the update reports failure.""" + messages: list[str] = [] + ran: list[list[str]] = [] + + def fake_run_upgrade_command(command, *, print_output: bool, output_callback): + ran.append(command) + if command == ["brew", "upgrade", "pythinker-code"]: + output_callback(_BREW_UNTRUSTED_TAP_ERROR) + return 1 + return 0 + + async def fake_confirm(tap: str) -> bool: + return False + + _brew_upgrade_do_update_env(monkeypatch, tmp_path) + monkeypatch.setattr(update, "_run_upgrade_command", fake_run_upgrade_command) + monkeypatch.setattr(update, "_can_prompt_to_trust_tap", lambda print_output: True) + monkeypatch.setattr(update, "_confirm_brew_trust", fake_confirm) + + result = await update.do_update(print_output=False, output_callback=messages.append) + + assert result is update.UpdateResult.FAILED + assert ["brew", "trust", "pythoughts-labs/pythinker"] not in ran + assert ran.count(["brew", "upgrade", "pythinker-code"]) == 1 + assert any("brew trust pythoughts-labs/pythinker" in m for m in messages) + + +@pytest.mark.asyncio +async def test_do_update_brew_untrusted_tap_trust_failure_degrades_to_hint(monkeypatch, tmp_path): + """If `brew trust` itself fails, no retry happens and the manual + remediation is still printed.""" + messages: list[str] = [] + ran: list[list[str]] = [] + + def fake_run_upgrade_command(command, *, print_output: bool, output_callback): + ran.append(command) + if command == ["brew", "upgrade", "pythinker-code"]: + output_callback(_BREW_UNTRUSTED_TAP_ERROR) + return 1 + if command[:2] == ["brew", "trust"]: + return 1 + return 0 + + async def fake_confirm(tap: str) -> bool: + return True + + _brew_upgrade_do_update_env(monkeypatch, tmp_path) + monkeypatch.setattr(update, "_run_upgrade_command", fake_run_upgrade_command) + monkeypatch.setattr(update, "_can_prompt_to_trust_tap", lambda print_output: True) + monkeypatch.setattr(update, "_confirm_brew_trust", fake_confirm) + + result = await update.do_update(print_output=False, output_callback=messages.append) + + assert result is update.UpdateResult.FAILED + assert ran.count(["brew", "upgrade", "pythinker-code"]) == 1 + assert any("brew trust pythoughts-labs/pythinker" in m for m in messages) + + +@pytest.mark.asyncio +async def test_do_update_brew_silent_noop_with_untrusted_tap_prints_trust_hint( + monkeypatch, tmp_path +): + """When `brew update` skips our untrusted tap and `brew upgrade` exits 0 + without advancing the version, the trust hint beats the generic + stale-metadata hint.""" + messages: list[str] = [] + + def fake_run_upgrade_command(command, *, print_output: bool, output_callback): + if command == ["brew", "update", "--quiet"]: + output_callback( + "Warning: Skipping pythoughts-labs/pythinker because it is not " + "trusted. Run `brew trust pythoughts-labs/pythinker` to trust it." + ) + return 0 + + _brew_upgrade_do_update_env(monkeypatch, tmp_path) + monkeypatch.setattr(update, "_run_upgrade_command", fake_run_upgrade_command) + monkeypatch.setattr(update, "_can_prompt_to_trust_tap", lambda print_output: False) + monkeypatch.setattr(update, "_installed_homebrew_version", lambda: "0.40.1") + + result = await update.do_update(print_output=False, output_callback=messages.append) + + assert result is update.UpdateResult.FAILED + assert not any("Updated successfully" in m for m in messages) + assert any("brew trust pythoughts-labs/pythinker" in m for m in messages) + + def test_installed_homebrew_version_returns_max_installed(monkeypatch): """Parses `brew list --versions`, returning the highest installed version.""" diff --git a/tests/ui_and_conv/test_shell_welcome_info.py b/tests/ui_and_conv/test_shell_welcome_info.py index 0c79eda4..dc38fd05 100644 --- a/tests/ui_and_conv/test_shell_welcome_info.py +++ b/tests/ui_and_conv/test_shell_welcome_info.py @@ -1,3 +1,5 @@ +import io + from rich.console import Console from rich.text import Text @@ -222,14 +224,86 @@ def test_welcome_chip_degrades_to_ascii(monkeypatch): assert "Update available" in chip.plain -def test_logo_antenna_blinks_unless_motion_disabled(monkeypatch): +def test_logo_antenna_never_uses_sgr_blink(monkeypatch): + """The boot animation owns blinking now; the logo itself must not carry the + terminal's infinite slow-blink attribute in either motion mode.""" + for disabled in (False, True): + monkeypatch.setattr(shell_module, "motion_disabled", lambda d=disabled: d) + spans = shell_module._logo_text().spans + assert not any("blink" in str(span.style) for span in spans) + + +def _terminal_console(width: int = 100, height: int = 50) -> tuple[Console, io.StringIO]: + buffer = io.StringIO() + console = Console( + file=buffer, + force_terminal=True, + width=width, + height=height, + color_system="truecolor", + ) + return console, buffer + + +def test_welcome_banner_blinks_antenna_seven_times_then_stops(monkeypatch): + """On an interactive terminal the antenna ball blinks exactly + _ANTENNA_BLINKS times after the banner prints, then pins steady: each + blink is an off+on rewrite of the single antenna cell, plus one final + steady write.""" + console, buffer = _terminal_console() + monkeypatch.setattr(shell_module, "console", console) + monkeypatch.setattr(shell_module, "get_version", lambda: "9.9.9") + monkeypatch.setattr(shell_module, "ascii_glyphs_enabled", lambda: False) monkeypatch.setattr(shell_module, "motion_disabled", lambda: False) - spans = shell_module._logo_text().spans - assert any("blink" in str(span.style) for span in spans) + monkeypatch.setattr(shell_module, "_ANTENNA_BLINK_OFF_SECONDS", 0.0) + monkeypatch.setattr(shell_module, "_ANTENNA_BLINK_ON_SECONDS", 0.0) + + shell_module._print_welcome_info("Pythinker CLI", []) + + output = buffer.getvalue() + # 1 antenna in the printed logo + (off + on) per blink + 1 final steady. + assert output.count("●") == 1 + 2 * shell_module._ANTENNA_BLINKS + 1 + # The animation hides and restores the cursor around the rewrites. + assert "\x1b[?25l" in output + assert "\x1b[?25h" in output + +def test_welcome_banner_skips_blink_when_motion_disabled(monkeypatch): + console, buffer = _terminal_console() + monkeypatch.setattr(shell_module, "console", console) + monkeypatch.setattr(shell_module, "get_version", lambda: "9.9.9") + monkeypatch.setattr(shell_module, "ascii_glyphs_enabled", lambda: False) monkeypatch.setattr(shell_module, "motion_disabled", lambda: True) - spans = shell_module._logo_text().spans - assert not any("blink" in str(span.style) for span in spans) + + shell_module._print_welcome_info("Pythinker CLI", []) + + assert buffer.getvalue().count("●") == 1 + + +def test_welcome_banner_skips_blink_on_non_terminal(monkeypatch): + console = Console(record=True, width=100, color_system=None) + monkeypatch.setattr(shell_module, "console", console) + monkeypatch.setattr(shell_module, "get_version", lambda: "9.9.9") + monkeypatch.setattr(shell_module, "ascii_glyphs_enabled", lambda: False) + monkeypatch.setattr(shell_module, "motion_disabled", lambda: False) + + shell_module._print_welcome_info("Pythinker CLI", []) + + assert console.export_text().count("●") == 1 + + +def test_welcome_banner_skips_blink_when_terminal_too_short(monkeypatch): + """If the panel is taller than the screen the antenna row may have + scrolled off; cursor-relative repaints would land on the wrong line.""" + console, buffer = _terminal_console(height=3) + monkeypatch.setattr(shell_module, "console", console) + monkeypatch.setattr(shell_module, "get_version", lambda: "9.9.9") + monkeypatch.setattr(shell_module, "ascii_glyphs_enabled", lambda: False) + monkeypatch.setattr(shell_module, "motion_disabled", lambda: False) + + shell_module._print_welcome_info("Pythinker CLI", []) + + assert buffer.getvalue().count("●") == 1 def test_welcome_tiny_width_does_not_crash(monkeypatch): diff --git a/tests/utils/test_sensitive.py b/tests/utils/test_sensitive.py index 94841bba..c281c0cf 100644 --- a/tests/utils/test_sensitive.py +++ b/tests/utils/test_sensitive.py @@ -4,7 +4,66 @@ import pytest -from pythinker_code.utils.sensitive import is_sensitive_file, sensitive_file_warning +from pythinker_code.utils.sensitive import ( + is_sensitive_file, + redact_secrets, + sensitive_file_warning, +) + +_REDACTED = "[REDACTED]" + + +def test_redact_env_assignment(): + out = redact_secrets("ADMIN_PASSWORD=cp-zeyLWvKHRh_jDm8guvg") + assert "cp-zeyLWvKHRh_jDm8guvg" not in out + assert _REDACTED in out + # The key name is preserved so the line is still readable. + assert out.startswith("ADMIN_PASSWORD=") + + +def test_redact_preserves_numbered_grep_prefix(): + # A grep result line like "3ADMIN_PASSWORD=secret" keeps its line number. + out = redact_secrets("3ADMIN_PASSWORD=hunter2value") + assert "hunter2value" not in out + assert out == f"3ADMIN_PASSWORD={_REDACTED}" + + +def test_redact_colon_separated_secret(): + out = redact_secrets("api_key: sk-live-abc123def456") + assert "sk-live-abc123def456" not in out + assert _REDACTED in out + + +def test_redact_multiple_lines_only_touches_secret_lines(): + text = "SITE_NAME=Random Pattern\nSECRET_TOKEN=abcdef123456\nPORT=3020" + out = redact_secrets(text) + assert "Random Pattern" in out + assert "3020" in out + assert "abcdef123456" not in out + + +def test_redact_ignores_non_secret_keys(): + text = "USERNAME=rp_editor\nHOST=localhost" + assert redact_secrets(text) == text + + +def test_redact_ignores_keys_with_suffix_after_hint(): + """Keys where the secret hint is NOT immediately before the separator — + token_count (hint 'token' + '_count'), access_key_id (hint 'access_key' + + '_id') — are intentionally left intact, not treated as secrets.""" + text = "token_count=100\naccess_key_id=AKIAIOSFODNN7EXAMPLE" + assert redact_secrets(text) == text + + +def test_redact_handles_quoted_values(): + out = redact_secrets('PASSWORD="s3cr3t value"') + assert "s3cr3t value" not in out + assert _REDACTED in out + + +def test_redact_empty_and_no_secrets(): + assert redact_secrets("") == "" + assert redact_secrets("just some prose about passwords") == ("just some prose about passwords") @pytest.mark.parametrize(