v0.44.0: approval fix, login CSP, update diagnostics, Lucide icons

* fix: approval pending check broken by stale has_pending import (#228)

api/routes.py imported has_pending/pop_pending from tools.approval, but the
agent module renamed has_pending to has_blocking_approval (checks gateway
queue, not _pending dict) and removed pop_pending. The import fell through
to fallback lambdas that always returned False, making GET /api/approval/pending
always return {pending:null} even after a successful inject_test.

Fix: check _pending directly under _lock — same dict submit_pending writes to.
Stale imports removed.

Before: 554 pass, 1 fail | After: 555 pass, 0 fail

* fix: move login JS into external file, remove inline handlers (#226)

Login page used inline onsubmit/onkeydown handlers and an inline <script>
block — all blocked by strict script-src CSP, causing silent login failure.

Fix: extract doLogin() and Enter key listener into static/login.js (served
from /static/, already a public path). Form uses id='login-form' and
data-* attributes for i18n strings instead of injected JS literals.
Also guards res.json() parse with try/catch so non-JSON error bodies
(e.g. HTTP 500) show the password-error fallback instead of 'Connection failed'.

Fixes #222.

* fix: improve update error messages when pull fails (#227)

_apply_update_inner() ran git pull --ff-only and returned only raw stderr
on failure, making all failure modes indistinguishable.

Fix: explicit git fetch before pull; if fetch fails, returns human-readable
network error. Diverged history and missing upstream tracking branch each
get distinct messages with exact recovery commands. Generic fallback
truncates to 300 chars and shows sentinel when git produces no output.

Also adds tests/test_update_checker.py with 13 tests covering all 4 new
diagnostic code paths (0 tests existed before).

Fixes #223.

* fix: stabilize 30s terminal approval prompt visibility (#225)

Adds minimum 30-second visibility guard for the approval card using
_approvalVisibleSince, _approvalHideTimer, and a signature fingerprint
to deduplicate repeated poll ticks.

Fix: respondApproval() and all stream-end paths (done/cancel/apperror/
error/start-error) now call hideApprovalCard(true) so the card hides
immediately when the user responds or the session ends. The 30s guard
only applies to mid-session poll ticks where the approval is still live
but briefly absent.

Adds 11 structural tests covering the new timer variables, force
parameter, force-on-respond, force-on-stream-end, and poll-loop
no-force behavior.

* feat: replace emoji icons with self-hosted Lucide SVG icons (#221)

Replaces all sidebar/button emoji icons with SVG paths from Lucide bundled
in static/icons.js (no CDN dependency). Adds li(name) function returning
inline SVG geometry from a hardcoded whitelist — unknown keys return '' so
dynamic server-supplied names never inject arbitrary SVG.

Changes:
  - static/icons.js: new file with 21 icon paths + li() renderer
  - static/index.html: all nav/action buttons now use li() icons
  - static/ui.js: toolIcon(), fileIcon() use li() for tool/file icons
  - static/messages.js: cancelStream button uses SVG square stop icon
  - .gitignore: adds node_modules/ entry

Verified: all 35 onclick= functions exist in JS, all 21 li() calls
reference defined icons, applyBotName() selectors intact, version
label present, no removed IDs referenced by JS.

* docs: v0.44.0 release notes, bump version, update test counts

---------

Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
This commit is contained in:
nesquena-hermes
2026-04-10 10:02:28 -07:00
committed by GitHub
parent 0df9d4830f
commit 4947a6b0c3
13 changed files with 759 additions and 105 deletions

View File

@@ -280,3 +280,107 @@ class TestApprovalRespondHTTP:
assert status == 200
assert "choice" in result
assert result["choice"] == "always"
class TestApprovalCardTimerLogic:
"""Tests for the 30s minimum visibility guard introduced in PR #225."""
def _get_js(self):
return pathlib.Path(__file__).parent.parent / 'static' / 'messages.js'
def test_approval_min_visible_ms_constant_present(self):
"""APPROVAL_MIN_VISIBLE_MS constant exists and is 30000."""
src = self._get_js().read_text()
assert 'APPROVAL_MIN_VISIBLE_MS' in src
import re
m = re.search(r'APPROVAL_MIN_VISIBLE_MS\s*=\s*(\d+)', src)
assert m is not None, 'APPROVAL_MIN_VISIBLE_MS not assigned'
assert int(m.group(1)) == 30000, f'Expected 30000, got {m.group(1)}'
def test_hide_approval_card_has_force_parameter(self):
"""hideApprovalCard() accepts a force parameter."""
src = self._get_js().read_text()
assert 'hideApprovalCard(force=false)' in src or \
'hideApprovalCard(force = false)' in src, \
'hideApprovalCard must have force=false default parameter'
def test_hide_approval_card_checks_force_flag(self):
"""hideApprovalCard body has a conditional on force."""
src = self._get_js().read_text()
# The guard: if (!force && _approvalVisibleSince)
assert '!force' in src, 'hideApprovalCard must check !force before deferred hide'
def test_approval_hide_timer_variable_present(self):
"""Module-level _approvalHideTimer variable is declared."""
src = self._get_js().read_text()
assert '_approvalHideTimer' in src
def test_approval_visible_since_variable_present(self):
"""Module-level _approvalVisibleSince variable is declared."""
src = self._get_js().read_text()
assert '_approvalVisibleSince' in src
def test_approval_signature_variable_present(self):
"""Module-level _approvalSignature variable is declared."""
src = self._get_js().read_text()
assert '_approvalSignature' in src
def test_respond_approval_calls_hide_with_force(self):
"""respondApproval must call hideApprovalCard(true) — not no-arg."""
src = self._get_js().read_text()
# Extract respondApproval function body
import re
m = re.search(r'async function respondApproval.*?(?=\nasync function|\nfunction |\Z)',
src, re.DOTALL)
assert m, 'respondApproval function not found'
body = m.group(0)
# Must call hideApprovalCard(true), not the bare hideApprovalCard()
assert 'hideApprovalCard(true)' in body, \
'respondApproval must call hideApprovalCard(true) so card hides immediately after user clicks'
# Must NOT have bare hideApprovalCard() without force
bare_calls = re.findall(r'hideApprovalCard\((?!true)', body)
assert not bare_calls, \
f'respondApproval has bare hideApprovalCard() calls (no force=true): {bare_calls}'
def test_stream_done_calls_hide_with_force(self):
"""Done SSE event handler must call hideApprovalCard(true)."""
src = self._get_js().read_text()
# Find the done event handler section (stopApprovalPolling followed by hideApprovalCard)
import re
# Look for pattern: stopApprovalPolling();\n + hideApprovalCard
matches = re.findall(
r'stopApprovalPolling\(\);\s*\n\s*if\(!_approvalSessionId[^)]*\)\s*hideApprovalCard\((\w*)\)',
src
)
# All stopApprovalPolling paths that call hideApprovalCard should use force=true
for match in matches:
assert match == 'true', \
f'After stopApprovalPolling(), hideApprovalCard called without force=true (got: {match!r})'
def test_poll_loop_still_uses_no_force(self):
"""Poll loop hideApprovalCard() (when pending gone) keeps no-force — correct behavior."""
src = self._get_js().read_text()
# Line 446: else { hideApprovalCard(); } — this is the poll-loop path
# The 30s guard should protect this call (don't force from poll ticks)
assert 'else { hideApprovalCard(); }' in src or \
'else {hideApprovalCard();}' in src or \
'else { hideApprovalCard() }' in src, \
'Poll loop should still call hideApprovalCard() without force=true'
def test_show_approval_card_signature_dedup(self):
"""showApprovalCard uses a signature to avoid resetting timer on repeat polls."""
src = self._get_js().read_text()
# The sig computation must use JSON.stringify on card content
import re
m = re.search(r'function showApprovalCard.*?(?=\nfunction |\nasync function |\Z)',
src, re.DOTALL)
assert m, 'showApprovalCard function not found'
body = m.group(0)
assert 'JSON.stringify' in body, 'showApprovalCard must compute a signature via JSON.stringify'
assert '_approvalSignature' in body, 'showApprovalCard must check/set _approvalSignature'
def test_clear_approval_hide_timer_helper_present(self):
"""_clearApprovalHideTimer helper exists to cancel deferred hides."""
src = self._get_js().read_text()
assert '_clearApprovalHideTimer' in src, \
'_clearApprovalHideTimer helper must exist to cancel deferred setTimeout'