Skip to content

fix(web): say what actually went wrong instead of "unknown" - #448

Merged
ChuckBuilds merged 4 commits into
mainfrom
fix/surface-web-error-detail
Aug 11, 2026
Merged

fix(web): say what actually went wrong instead of "unknown"#448
ChuckBuilds merged 4 commits into
mainfrom
fix/surface-web-error-detail

Conversation

@ChuckBuilds

@ChuckBuilds ChuckBuilds commented Aug 10, 2026

Copy link
Copy Markdown
Owner

Found while diagnosing a device that returned this from the web UI after an update + restart:

{"error_code":"UNKNOWN_ERROR","message":"An error occurred; see logs for details","status":"error"}

Why the generic message failed

The device's SD card was failing. Every endpoint answered with that same sentence — the restart action, /system/status, and /logs, because journalctl could not be executed either. So the message pointed at logs that the same fault made unreachable, and SSH was down for the same reason (sshd resetting before its banner, unable to read its host keys).

The exception underneath said:

[Errno 5] Input/output error: 'systemctl'

That names the fault outright — EIO on exec is a failing block device, not a missing file or a permissions problem. The only endpoint that helped was /health, and only because it happens to pass a subprocess's stderr through. Diagnosis came down to guessing which endpoint leaked something useful.

The change

describe_exception() returns "TypeName: message" on one line, and it populates the details field that WebInterfaceError.to_dict() and error_response(details=...) have always supported and nothing ever filled. The type alone carries information — a bare PermissionError says more than any generic sentence.

/api/v3/logs under the same failure, before and after:

- {"message": "An error occurred; see logs for details", "status": "error"}
+ {"message": "An error occurred; see logs for details",
+  "details": "OSError: [Errno 5] Input/output error: 'systemctl'",
+  "status": "error"}

Exception text is not automatically safe to echo. A requests error quotes the URL it failed on, and plugins that authenticate by query string put their key there — so returning exceptions verbatim would hand out API keys. Credential values are redacted and the parameter name kept, since knowing which credential was involved is part of the diagnosis:

RuntimeError: failed: https://api.x.com/v1?api_key=<redacted>&city=Tampa

Length is capped at 400 chars and newlines collapsed, so a parser's worth of context can't flood a JSON field.

The nine liars

Of 69 handlers returning the generic message, 60 log it properly. Nine bound the exception and never used it — no log line either, so "see logs for details" was false rather than merely unhelpful. /logs was one of them, which is what nearly dead-ended the diagnosis. Those nine now log with a traceback and carry the detail; a test asserts none are left.

The other 60 are untouched. They already log, so the information exists on a healthy box, and rewriting 60 call sites in the same PR is a large blast radius for a smaller gain — they can adopt the helper as they're touched.

Verification

  • 16 new tests: the reported error string, bare exceptions, credential redaction across five shapes (including the parameter name surviving and non-secret context being preserved), truncation, newline collapsing, and an AST assertion that no api_v3 handler discards its exception.
  • End-to-end against the real /api/v3/logs with subprocess.run patched to raise EIO — returns the string above and logs the traceback.
  • Full suite: 2378 passed, 60 skipped, 1 pre-existing unrelated failure (test_install_lowmem.py::TestDiskBackedTmpdir, which fails identically on a clean tree wherever TMPDIR is set).

🤖 Generated with Claude Code

https://claude.ai/code/session_01Udr6MfaFLUPhX5Fgo67Jf5

Summary by CodeRabbit

  • Bug Fixes
    • Error responses now include concise, sanitized details to help diagnose failures.
    • Credential-like values are automatically redacted from displayed error messages.
    • Exception details are normalized to single lines and limited in length.
    • HTTP errors now preserve their appropriate status codes instead of being reported as server errors.
    • API errors now provide more consistent structured details and improved diagnostic context.

@coderabbitai

coderabbitai Bot commented Aug 10, 2026

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 43448908-0c23-44e3-915c-62009a6c531f

📥 Commits

Reviewing files that changed from the base of the PR and between 83a1f92 and 6ae69df.

📒 Files selected for processing (1)
  • web_interface/blueprints/api_v3.py

📝 Walkthrough

Walkthrough

The change adds describe_exception to sanitize and limit exception details. Global Flask handlers and API v3 handlers now return these details and log affected request paths. Tests cover formatting, redaction, truncation, logging, and responses.

Changes

Exception reporting

Layer / File(s) Summary
Exception description formatter
src/web_interface/error_handler.py, test/test_web_error_detail.py
Adds describe_exception with credential redaction, single-line normalization, and configurable truncation. Tests cover exception formatting and sanitization.
Global Flask error responses
web_interface/app.py, test/test_web_error_detail.py
Global 500 and catch-all handlers include sanitized exception details while preserving HTTP status codes, error codes, and generic messages. Flask tests verify these responses.
API v3 error handling
web_interface/blueprints/api_v3.py, test/test_web_error_detail.py
Configuration, system, display, plugin, authentication, font, asset, log, WiFi, and cache handlers return sanitized exception details. Selected handlers log request paths. An AST test checks error-level traceback logging.

Estimated code review effort: 3 (Moderate) | ~20 minutes

Sequence Diagram(s)

sequenceDiagram
  participant APIv3Endpoint
  participant describe_exception
  participant APIResponse
  APIv3Endpoint->>describe_exception: format and redact exception
  describe_exception-->>APIv3Endpoint: sanitized details
  APIv3Endpoint->>APIResponse: return error details and status
Loading
🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarizes the main change: replacing generic unknown web errors with informative sanitized exception details.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/surface-web-error-detail

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@codacy-production

codacy-production Bot commented Aug 10, 2026

Copy link
Copy Markdown

Up to standards ✅

🟢 Issues 0 issues

Results:
0 new issues

View in Codacy

🟢 Metrics 6 complexity · 0 duplication

Metric Results
Complexity 6
Duplication 0

View in Codacy

NEW Get contextual insights on your PRs based on Codacy's metrics, along with PR and Jira context, without leaving GitHub. Enable AI reviewer
TIP This summary will be updated as you push new changes.

Comment thread web_interface/blueprints/api_v3.py Dismissed

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/web_interface/error_handler.py`:
- Around line 25-29: Extend _REDACT_CREDENTIAL and the sanitizer used by
describe_exception() to redact Authorization header values such as “Bearer
SECRET” and URL userinfo credentials such as “user:password” before the host.
Add regression cases covering both formats, while preserving existing credential
redaction behavior and client-facing error handling.

In `@test/test_web_error_detail.py`:
- Around line 79-98: Strengthen test_no_api_v3_handler_discards_its_exception so
matching exception handlers must use error-level logging with traceback details
and call describe_exception(e). Update the AST checks to reject logger.info and
other non-error levels, require exc_info=True on the error/exception logging
call, and verify the handler’s exception variable is passed to
describe_exception.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 75c4716b-2dbb-4f16-8ec6-b62d4c69807b

📥 Commits

Reviewing files that changed from the base of the PR and between ca26c1b and 8c11714.

📒 Files selected for processing (4)
  • src/web_interface/error_handler.py
  • test/test_web_error_detail.py
  • web_interface/app.py
  • web_interface/blueprints/api_v3.py

Comment thread src/web_interface/error_handler.py
Comment thread test/test_web_error_detail.py Outdated
@ChuckBuilds
ChuckBuilds force-pushed the fix/surface-web-error-detail branch from 471e7a6 to 0b9f5e2 Compare August 10, 2026 15:22
@ChuckBuilds

Copy link
Copy Markdown
Owner Author

All three addressed in 0b9f5e2. The middle one exposed a scoping mistake in my first cut, so thanks for that.

Auth headers and URL userinfo — fixed. Both are real: requests quotes the URL it failed on, and Authorization shows up in its exception text. Neither was matched.

RuntimeError: 401 for https://user:<redacted>@example.com/api
RuntimeError: headers: {'Authorization': 'Bearer <redacted>'}
RuntimeError: Proxy-Authorization: Bearer <redacted>

The scheme (Bearer) and the username are kept deliberately — they say which credential and whose without being the secret. Four regression cases added, plus one asserting the scheme and username survive.

AST test — strengthened, and it caught me out. You're right that it only asked whether something was logged; logger.info("failed") would have satisfied it. It now requires an error-level record carrying exc_info and describe_exception() called on the handler's own bound exception (matching the ExceptHandler.name, so passing some other variable doesn't count).

Enforcing that immediately failed on 60 handlers — the ones I'd deliberately left alone, on the reasoning that they already logged so the detail was at least in the journal. That reasoning was wrong, and the device that prompted this PR proves it: /system/status is one of those 60, and it told me nothing, because the journal was exactly what couldn't be read. Splitting the fix left most of the diagnostic surface useless for the one scenario it exists for.

So all 69 now carry the detail. Two extra shapes needed doing by hand: two handlers bound no exception name (except Exception:), and three passed the message through a variable rather than a literal, so the AST match missed them.

CodeQL also flagged py/stack-trace-exposure on one of these lines, which is the intended behaviour of this PR rather than an oversight, so I've left it for a maintainer call — see the separate note below.

Verification

  • 21 tests (was 16), all passing.
  • End-to-end against the real /api/v3/logs with subprocess.run patched to raise EIO: returns "details": "OSError: [Errno 5] Input/output error: 'systemctl'" and logs the traceback.
  • 670 web/api/error/config tests pass; full suite 2383 passed with the one pre-existing test_install_lowmem failure.

@ChuckBuilds

Copy link
Copy Markdown
Owner Author

Note on the CodeQL alert (py/stack-trace-exposure, #355)

This one needs a maintainer decision rather than a code fix, because the alert describes exactly what the PR sets out to do: exception information now reaches the HTTP response.

What is and isn't exposed:

  • Returned: the exception's type name and its str(), one line, capped at 400 chars.
  • Not returned: the traceback, frame locals, or source context. describe_exception() never touches __traceback__.
  • Sanitised: credential values in query strings, Authorization headers, and URL userinfo.

The trade-off, stated plainly: these endpoints are already unauthenticated on the local network, and an attacker who can reach them can reach far more interesting things than an errno string. Against that, the message is frequently the entire diagnosis — [Errno 5] Input/output error identified a failing SD card in one request, after UNKNOWN_ERROR had made the same device look like a software bug and cost an hour.

If you'd rather not carry the alert, there are two smaller options:

  1. Dismiss it as "used in tests / intended exposure" with a note pointing here.
  2. Gate details behind a config flag (say web_interface.verbose_errors, default on), so a user exposing the UI beyond their LAN can switch it off.

Happy to implement (2) if you'd prefer the alert closed by code rather than dismissed — say the word and I'll push it.

@github-advanced-security github-advanced-security AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

CodeQL found more than 20 potential problems in the proposed changes. Check the Files changed tab for more details.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (2)
src/web_interface/error_handler.py (1)

83-84: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Validate max_length before truncation.

If max_length is zero or negative, Line 84 can return a value longer than the requested limit. Reject values below one before truncating.

Proposed fix
+    if max_length < 1:
+        raise ValueError("max_length must be at least 1")
     if len(text) > max_length:
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/web_interface/error_handler.py` around lines 83 - 84, Validate max_length
before the truncation logic in the error-handling function: reject any value
below one before evaluating the len(text) truncation branch. Preserve the
existing truncation behavior for valid max_length values.
web_interface/blueprints/api_v3.py (1)

466-473: 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Do not convert Flask HTTP exceptions to 500 responses.

save_schedule_config() calls request.get_json() at Line 330 without silent=True. Flask raises a Werkzeug BadRequest for malformed JSON and UnsupportedMediaType for an invalid Content-Type. The local broad except Exception rewrites these client errors as CONFIG_SAVE_FAILED with status 500.

web_interface/app.py preserves HTTP exceptions only when they escape the route handler, so this local catch skips that handling. Reraise HTTPException before the generic API v3 exception handler and apply the same rule around other request-parsing except Exception handlers in web_interface/blueprints/api_v3.py. Add integration coverage for malformed JSON and incorrect Content-Type.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@web_interface/blueprints/api_v3.py` around lines 466 - 473, Update
save_schedule_config() and other request-parsing exception handlers in api_v3.py
to re-raise Werkzeug HTTPException instances before generic Exception handling,
preserving Flask’s original 4xx responses for malformed JSON and unsupported
Content-Type. Keep non-HTTP failures mapped to the existing API error responses,
and add integration coverage for both request errors.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/web_interface/error_handler.py`:
- Around line 35-39: Update _REDACT_AUTH_HEADER so it matches and redacts any
Authorization authentication scheme, not only the listed
Bearer/Basic/Digest/Token values, while preserving capture of the credential for
replacement. Add regression cases covering unsupported schemes such as ApiKey
and Negotiate and verify their secrets are absent from client responses.

In `@test/test_web_error_detail.py`:
- Around line 123-145: Update returns_the_detail() to inspect only Return
expressions in the exception handler, requiring a matching
describe_exception(exception_name) call within the returned jsonify or
error_response payload. Do not accept calls that are merely computed elsewhere
or discarded; preserve the bare except behavior where no exception name exists.

---

Outside diff comments:
In `@src/web_interface/error_handler.py`:
- Around line 83-84: Validate max_length before the truncation logic in the
error-handling function: reject any value below one before evaluating the
len(text) truncation branch. Preserve the existing truncation behavior for valid
max_length values.

In `@web_interface/blueprints/api_v3.py`:
- Around line 466-473: Update save_schedule_config() and other request-parsing
exception handlers in api_v3.py to re-raise Werkzeug HTTPException instances
before generic Exception handling, preserving Flask’s original 4xx responses for
malformed JSON and unsupported Content-Type. Keep non-HTTP failures mapped to
the existing API error responses, and add integration coverage for both request
errors.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: f8221799-ee5d-474a-9294-690aecd6aed8

📥 Commits

Reviewing files that changed from the base of the PR and between 8c11714 and 376f248.

📒 Files selected for processing (4)
  • src/web_interface/error_handler.py
  • test/test_web_error_detail.py
  • web_interface/app.py
  • web_interface/blueprints/api_v3.py

Comment thread src/web_interface/error_handler.py
Comment thread test/test_web_error_detail.py Outdated
ChuckBuilds and others added 4 commits August 11, 2026 13:32
Every failing endpoint returned "An error occurred; see logs for
details" and nothing else. That is survivable until the logs are the
thing you cannot reach: a device whose SD card was failing answered the
restart action, /system/status and /logs with that same sentence -- the
log viewer included, because journalctl could not be executed -- while
the exception underneath said

    [Errno 5] Input/output error: 'systemctl'

which names the fault outright. The only endpoint that helped was
/health, and only because it happens to pass a subprocess's stderr
through. Diagnosis came down to guessing which endpoint leaked something.

Add describe_exception(), returning "TypeName: message" on one line, and
populate the `details` field that the response schema has always had and
nothing ever filled. The type alone carries information -- a bare
PermissionError says more than any generic sentence.

Exception text is not automatically safe to echo: a requests error
quotes the URL it failed on, and plugins that authenticate by query
string put their key there. Credential values are redacted while the
parameter name is kept, since knowing which credential was involved is
part of the diagnosis. Length is capped and newlines collapsed so a
parser's context cannot flood a JSON field.

Nine handlers in api_v3 bound the exception and never used it, so the
promised log entry was never written either -- "see logs for details"
was false, not merely unhelpful. Those now log with a traceback and
carry the detail. The other 60 already logged and are unchanged; they
can adopt the helper as they are touched.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Udr6MfaFLUPhX5Fgo67Jf5
Three review findings.

The sanitizer missed two credential shapes that requests puts in its
exception text verbatim: `Authorization: Bearer <token>` and
`https://user:password@host`. Both would have gone straight into a
response. The auth-scheme name and the username are kept -- they say
which credential and whose without being the secret.

The AST test only asked whether *something* had been logged, so a
`logger.info("failed")` satisfied it while discarding the exception just
as completely. It now requires an error-level record carrying exc_info
and `describe_exception()` called on the handler's own bound exception.

Enforcing that revealed the first cut had scoped itself wrongly. I had
converted the nine handlers that logged nothing and left the sixty that
logged, reasoning their detail was at least in the journal. But
/system/status is one of the sixty, and on the failing device it told me
nothing -- the journal was exactly what could not be read. Splitting
them left most of the diagnostic surface unhelpful for the case this
change exists for, so all sixty-nine now carry the detail.

Two handlers had no bound exception name, and three passed the message
through a variable rather than a literal; both shapes needed doing by
hand. Full suite: 2383 passed, one pre-existing unrelated failure.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Udr6MfaFLUPhX5Fgo67Jf5
Werkzeug's HTTPExceptions subclass Exception, so the catch-all handler
saw them too and turned every 405, 400, 413 and 415 into a 500
UNKNOWN_ERROR. A GET on a POST-only route answered "an error occurred;
see logs for details", which tells the caller nothing and blames the
wrong side -- found while probing a device whose POST-only config
endpoints did exactly that.

Hand HTTPExceptions back as themselves, with their own status and
description. A genuine server fault still reports as one, with the
detail this branch adds.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Udr6MfaFLUPhX5Fgo67Jf5
Two review findings.

The auth-header pattern listed Bearer, Basic, Digest and Token, so
`Authorization: ApiKey SECRET` or `Negotiate SECRET` went to the client
intact. A fixed list silently leaks whatever it does not name, and
plugin APIs invent their own schemes, so match any scheme name and keep
it while redacting the credential.

The AST test accepted a describe_exception(e) call anywhere in the
handler, which a handler could satisfy by computing the detail and
dropping it before returning the generic message. It now requires the
call inside every return expression, which is where it has to be to
reach the caller.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Udr6MfaFLUPhX5Fgo67Jf5
@ChuckBuilds
ChuckBuilds force-pushed the fix/surface-web-error-detail branch from 83a1f92 to 6ae69df Compare August 11, 2026 17:35
@ChuckBuilds
ChuckBuilds merged commit 44f59ed into main Aug 11, 2026
9 checks passed
@ChuckBuilds
ChuckBuilds deleted the fix/surface-web-error-detail branch August 11, 2026 17:56
ChuckBuilds added a commit that referenced this pull request Aug 13, 2026
CodeQL flagged four exposure paths. Two were mine and genuinely raw: the
OSError from failing to spawn calendar_registration.py, which carries the
interpreter path and whatever the OS chose to say, and the ImportError
for the Google libraries, whose message named the missing module by
interpolating the exception directly. Both now go through
describe_exception, and the unredacted text goes to the log.

The other two are the repo-wide pattern from PR #448 -- 67 handlers on
main already return details=describe_exception(e), and these two new
handlers follow it. That function is the sanitizer: it strips URL
userinfo, auth headers and credential-shaped key=value pairs, collapses
to one line and caps the length. CodeQL's taint tracking cannot see a
sanitizer it has no model for, so it reports the flow regardless.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Udr6MfaFLUPhX5Fgo67Jf5
ChuckBuilds added a commit that referenced this pull request Aug 13, 2026
* fix(calendar): implement the OAuth and calendar-listing endpoints

The plugin's config advertised a three-step setup and only step 1
existed. Step 3's picker fetched
/api/v3/plugins/calendar/list-calendars, which was never registered, so
Flask fell through to the global 404 handler and the user saw "Resource
not found" -- a message that names nothing and points nowhere. Step 2
had no endpoint at all, so even a working picker would have found no
token to list with.

Two routes, following the pattern the spotify and ytm plugins already
use for their own auth scripts:

  POST /plugins/calendar/authenticate    two-step Google OAuth
  GET  /plugins/calendar/list-calendars  calendars for the picker

The authenticate route drives calendar_registration.py, which the plugin
already ships and which was written expressly for this -- it reads a
redirect URL on stdin and prints one JSON object. It takes two calls
because a human has to visit Google in between; the script persists the
PKCE verifier from the first call for the second, without which the
exchange fails with "Missing code verifier".

The listing route reads the token directly rather than shelling out
again: the picker is interactive and a subprocess per click is slower
than the API call it would wrap. It refreshes an expired token in place,
sorts the primary calendar first, and drops entries with no id, which
could not be selected anyway.

Both name the plugin when it is not installed, rather than reproducing
the anonymous 404 that started this.

Verified against the live Google API on the dev rig: HTTP 200 with the
account's real calendars.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Udr6MfaFLUPhX5Fgo67Jf5

* fix(calendar): one input for the auth code, and a louder warning

Two things reported after testing the flow.

There were two boxes and no way to tell which to use. The config
template's string branch dispatches widgets from an allow-list of names,
and anything missing from it falls through to a plain input type=text --
so the field rendered both the widget's own box and a stray one for the
same key. google-oauth is now on that list, which is all the widget ever
needed to render in place of the fallback rather than beside it.

And the warning that the redirect page fails to load was small grey text
under a link, which is where it is least likely to be read. It is now an
amber callout that leads with "The next page will fail to load. That is
expected." The failure lands at exactly the moment the user has to act
on it, and it looks precisely like the flow breaking rather than
working. The paste box is labelled too, rather than relying on a
placeholder that vanishes on focus.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Udr6MfaFLUPhX5Fgo67Jf5

* fix(calendar): redact script diagnostics, and page the calendar list

Three findings from CodeRabbit, all valid.

Raw subprocess output was being returned to the client -- the script's
stderr on one path, and its own error payload on another. CodeQL flagged
the same line. That script handles OAuth client secrets and interpolates
exceptions into its messages, so either could carry a secret or a path.
Both now go to the log unredacted, where they are worth having in full,
and reach the client through a redactor.

That redactor already existed inside describe_exception, which only
takes exceptions. Split out as redact_text: an exception is not the only
thing worth returning, and a subprocess's stderr is just as capable of
quoting a token.

calendarList.list returns 100 entries per page by default, caps at 250,
and hands back a nextPageToken when there are more. Reading one page
would have hidden calendars from the picker with nothing to say the list
was cut short. It now pages, asking for 250 at a time, bounded at ten
pages so a malformed token cannot spin.

And a test helper was a lambda where ruff wants a def.

The five new tests fail against the previous commit.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Udr6MfaFLUPhX5Fgo67Jf5

* fix(calendar): redact the last two raw exception interpolations

CodeQL flagged four exposure paths. Two were mine and genuinely raw: the
OSError from failing to spawn calendar_registration.py, which carries the
interpreter path and whatever the OS chose to say, and the ImportError
for the Google libraries, whose message named the missing module by
interpolating the exception directly. Both now go through
describe_exception, and the unredacted text goes to the log.

The other two are the repo-wide pattern from PR #448 -- 67 handlers on
main already return details=describe_exception(e), and these two new
handlers follow it. That function is the sanitizer: it strips URL
userinfo, auth headers and credential-shaped key=value pairs, collapses
to one line and caps the length. CodeQL's taint tracking cannot see a
sanitizer it has no model for, so it reports the flow regardless.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Udr6MfaFLUPhX5Fgo67Jf5

* fix(calendar): announce status changes, name the paste box, drop a no-op

Three more from the review, all valid.

The status line is written after every async call -- the consent link is
ready, the exchange failed -- and was a plain paragraph, so a screen
reader was told none of it. It is a live region now.

The paste box had a visible label that was never associated with it, so
its only accessible name was the placeholder, which disappears on focus:
precisely when the value is being pasted. The label now points at the
input by id.

And a conditional in the test helper returned the same value from both
branches, which Ruff flags as RUF034. It was left over from making the
fake page; one page is all those cases need, and TestPagination builds
its own sequences.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Udr6MfaFLUPhX5Fgo67Jf5

* test(calendar): assert the accessibility relationships, not their parts

The previous assertions searched for role="status", aria-live, a label
`for` and an input `id` independently, so they passed whether or not
those belonged together. Two attributes on different elements announce
nothing, and a `for` that names something other than the input leaves it
just as anonymous.

Both attributes are now asserted on the status element itself, and the
label and input are checked to go through the same identifier rather
than merely both existing. Verified by mutation: a mismatched pair and a
displaced aria-live are both caught.

Reported by CodeRabbit, against tests I had written two commits earlier.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Udr6MfaFLUPhX5Fgo67Jf5

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants