Compare commits

..

2 Commits

Author SHA1 Message Date
claude-ai-fix[bot]
16e2c6aac5 ci(triage): catch a malformed credential, not just a revoked one
The token was replaced at 08:25 and the runs at 08:26 still failed — with a
different error, which this gate did not recognise:

  "Invalid auth token · Fix external auth token · Invalid Authorization header
   value from CLAUDE_CODE_OAUTH_TOKEN: it contains a line break at character 62
   (110 characters on 2 lines)."

  "error": "invalid_request", "api_error_status": null

No "authentication_failed", no 401 — so the gate added a few hours earlier
reported this as a generic workflow fault, which is exactly the unhelpful
message it existed to replace. Two shapes of the same problem inside a week:
revoked (401) and malformed (invalid_request, reason only in the message text).

Detection now also matches the text marker, but only on an object the SDK
itself flagged with is_api_error_message — an issue body that merely mentions
the secret's name cannot fake one, and a false positive would change only the
message since this branch exits 1 regardless.

The SDK's own wording is more useful than anything inferred here ("a line break
at character 62" names the exact defect), so it is now quoted verbatim in the
annotation, and the remedy says to re-enter the secret as a SINGLE line.

Verified against both real execution-file shapes captured from production —
today's malformed failure and the 08-30 revoked one — plus regression that
max_turns still escalates, generic failures keep the generic message, and an
issue mentioning CLAUDE_CODE_OAUTH_TOKEN is not misreported as a credential
fault.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-31 10:29:49 +02:00
claude-ai-fix[bot]
3ac46b0cd5 ci(triage): name the real fault when the Claude credential is rejected
The AI pipeline has been down since ~2026-08-25. Every Claude-backed step fails
with:

  "result": "Failed to authenticate. API Error: 401 OAuth access token has been
             revoked."
  "error": "authentication_failed", "api_error_status": 401

The CLAUDE_CODE_OAUTH_TOKEN secret (last updated 2026-07-24) has been revoked.
That is not fixable in code — it needs regenerating — but the six days it went
unnoticed are, because nothing on the way out said so.

What a maintainer actually saw was the action reporting:

  "--json-schema was provided but Claude did not return structured_output.
   Result subtype: success"

which points at the schema, and then Apply verdict's generic "usually a
workflow-level fault ... left untouched for a retry". Neither mentions
credentials, and the failure presents per-issue while the real scope is every
tier at once: tier 1 cannot label, so tier 2's batch is empty and the sweep
reports success daily having done nothing.

GATE 1 now checks the execution file for authentication_failed / HTTP 401
before the max-turns branch and says what is wrong and what to do — regenerate
with `claude setup-token`, update the secret in the CR_PAT environment, and set
AI_DISABLED=true to silence the runs meanwhile. Same array guard and
fail-closed posture as hit_max_turns: an unrecognised shape is simply not an
auth failure and falls through to the generic branch.

Behaviour is otherwise unchanged — this branch already exited 1 without
touching labels, which was correct for a systemic fault.

Verified against the exact execution-file shape captured from the live 08-30
failure (both the issues and catch-up paths report the new error), and
regression-checked that max_turns still escalates on the automated retry, that
generic failures keep the generic message with and without an execution file,
and that the verdict paths are untouched.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-31 10:20:17 +02:00
4 changed files with 60 additions and 89 deletions

View File

@@ -363,13 +363,22 @@ jobs:
}
# Did the run die because the Claude credential is bad? The action
# reports this uselessly — a revoked token surfaces as "--json-schema
# was provided but Claude did not return structured_output", which
# points at the schema and not at auth. The execution file carries the
# truth: api_retry / result objects with error "authentication_failed"
# and a 401. Same array guard and fail-closed posture as above; an
# unrecognised shape simply is not an auth failure and falls through
# to the generic branch.
# reports this uselessly — the failure surfaces as "--json-schema was
# provided but Claude did not return structured_output", which points
# at the schema and not at auth. The execution file carries the truth.
#
# A bad credential shows up in more than one shape, and both have been
# seen in production within a week:
# * revoked token -> error "authentication_failed", HTTP 401
# * malformed token -> error "invalid_request", api_error_status
# null, and the reason only in the SDK's message text ("Invalid
# Authorization header value from CLAUDE_CODE_OAUTH_TOKEN: it
# contains a line break at character 62").
# Matching only the first shape reported the second as a generic
# workflow fault, so the text marker is checked too — but only on an
# object the SDK itself flagged as an API error, so an issue body that
# merely mentions the secret's name cannot fake one. A false positive
# would change only the message: this branch exits 1 either way.
hit_auth_failure() {
[ -n "${EXECUTION_FILE:-}" ] && [ -s "${EXECUTION_FILE:-}" ] || return 1
jq -e '(type == "array") and
@@ -377,10 +386,26 @@ jobs:
(type == "object") and
(((.error? // "") == "authentication_failed") or
((.error_status? // 0) == 401) or
((.api_error_status? // 0) == 401)))' \
((.api_error_status? // 0) == 401) or
(((.is_api_error_message? // false) == true) and
(tostring | test("CLAUDE_CODE_OAUTH_TOKEN|Invalid auth token")))))' \
"$EXECUTION_FILE" >/dev/null 2>&1
}
# The SDK's own words are far more useful than anything this script
# can infer — "it contains a line break at character 62" names the
# exact defect. Surface it verbatim when present.
auth_failure_detail() {
[ -n "${EXECUTION_FILE:-}" ] && [ -s "${EXECUTION_FILE:-}" ] || return 0
jq -r 'if type == "array" then
[ .[]? | select(type == "object")
| select((.is_api_error_message? // false) == true)
| tostring
| capture("(?<m>Invalid Authorization header value[^\"]*|Invalid auth token[^\"]*)")
| .m ] | first // ""
else "" end' "$EXECUTION_FILE" 2> /dev/null || true
}
# GATE 1 — did the action itself run? This is checked BEFORE looking
# at the payload, because the action can fail *after* having written
# a valid structured output: the object would sail through the shape
@@ -405,7 +430,8 @@ jobs:
# goes quiet while each run still fails in a way that reads like a
# per-issue problem. Say plainly what is wrong and what to do.
if hit_auth_failure; then
echo "::error::CLAUDE_CODE_OAUTH_TOKEN is rejected (HTTP 401 / authentication_failed). This is NOT a problem with issue #$ISSUE — every AI workflow is down until the credential is replaced. Regenerate it with 'claude setup-token' and update the CLAUDE_CODE_OAUTH_TOKEN secret in the CR_PAT environment. Set the AI_DISABLED repo variable to 'true' to silence these runs meanwhile."
DETAIL=$(auth_failure_detail)
echo "::error::CLAUDE_CODE_OAUTH_TOKEN is being rejected${DETAIL:+ — $DETAIL}. This is NOT a problem with issue #$ISSUE: every AI workflow is down until the credential is fixed. Regenerate with 'claude setup-token' and re-enter the secret in the CR_PAT environment as a SINGLE line with no line break or trailing newline. Set the AI_DISABLED repo variable to 'true' to silence these runs meanwhile."
exit 1
fi

View File

@@ -1,18 +1,4 @@
## 1.5.3.3 (2026-08-31)
- Complete the iOS companion app fix from 1.5.3.2. The "no preview available"
screen -- what you get for a `.zip`, `.bin` or anything else FileBrowser
cannot render -- offers its Download and "Open file" buttons as new-tab
links, and so does the share list in settings. The app hands every new tab
to an external browser, which carries no ingress session, so those answered
401. Those two buttons and the settings share links now stay in the panel
when running in the companion app, and open a new tab as before in a normal
browser -- as does a cmd/ctrl/shift-click anywhere, which is left alone.
Download also saves the file instead of displaying it, which 1.5.3.2 only
fixed for the download button in the file list. Links the app opens without
a link element, such as the public-share sidebar download, are still
affected.
## 1.5.3.2 (2026-08-30)
- Fix Download in the Home Assistant iOS companion app (iOS 17 and later),
where a file opened and showed its content with no way to save it.

View File

@@ -118,4 +118,4 @@ schema:
slug: filebrowser_quantum
udev: true
url: https://github.com/alexbelgium/hassio-addons
version: "1.5.3.3"
version: "1.5.3.2"

View File

@@ -41,72 +41,31 @@ server {
# same instance's files/ or public/share/ route. Same shape as the komga
# add-on's ingress filter.
#
# 2. Saving and opening a file. FileBrowser downloads by clicking an <a>
# that carries no download attribute -- a hidden one it builds itself
# (api/resources.js), and two visible ones in the "no preview available"
# fallback (views/files/Preview.vue), which are also target="_blank".
# Neither works in the Home Assistant companion app:
# 2. Download. FileBrowser downloads a file by building an <a> with no
# download attribute, clicking it, and letting the attachment response do
# the rest. The Home Assistant iOS companion app is a WKWebView, and a
# download happens there only when WebKit turns a navigation *action*
# into a WKDownload, which is what the download attribute does -- the app
# hands that to its own download manager
# (WebViewController+WebKitDelegates.swift, navigationAction:didBecome
# download:). Its response policy delegate returns .allow for every
# sub-frame and never returns .download, so a plain attachment navigation
# inside the ingress panel is just rendered: a text file opens and shows
# its content with no way to save it. Adding the attribute makes the same
# click a real download. Desktop browsers already downloaded these and
# are unaffected, and an empty value keeps the filename the server sends
# in Content-Disposition. Matched on the two exact download endpoints,
# minus inline=true: the "no preview available" fallback renders an
# "Open file" link on the same endpoint with that parameter
# (views/files/Preview.vue), and it is meant to open, not save. Only
# programmatic clicks pass through here -- a person clicking a link never
# calls HTMLAnchorElement.prototype.click -- so this reaches
# FileBrowser's own hidden download anchor and nothing a user clicks.
#
# - A download happens in its WKWebView only when WebKit turns a
# navigation *action* into a WKDownload, which is what the download
# attribute does; the app hands that to its own download manager
# (WebViewController+WebKitDelegates.swift, navigationAction:didBecome
# download:, iOS 17+). Its response policy delegate returns .allow for
# every sub-frame and never returns .download, so a plain attachment
# navigation in the ingress panel is just rendered: the file opens and
# shows its content with no way to save it.
# - target="_blank" is worse. The app has no navigationAction policy
# delegate, so every new-window request reaches createWebViewWith,
# which hands the url to an external browser. That browser carries no
# ingress session cookie, so Home Assistant answers 401.
#
# One capturing click listener covers both, and covers the hidden anchor
# too: a programmatic .click() on an anchor that is in the document
# dispatches through it exactly like a real one -- same event, button 0,
# no modifiers -- and the capture phase runs before the link is followed.
# It replaces an earlier wrapper around HTMLAnchorElement.prototype.click,
# which reached the hidden anchor but not the two the user clicks. The
# wrapper would also have caught a click on a *detached* anchor, which
# this cannot; both of FileBrowser's download paths append theirs to the
# body first (api/resources.js), so nothing is lost today.
#
# Only unmodified primary clicks are touched. A cmd/ctrl/shift-click is
# an explicit request for a separate context and is left alone, which
# matters on desktop and in the Mac Catalyst app.
#
# Downloads gain the attribute everywhere -- desktop browsers already
# saved these on a plain click, and an empty value keeps the filename the
# server sends in Content-Disposition. One behaviour does change there:
# if the endpoint answers with an error the body is saved as a file
# instead of being shown, because the decision is made before the
# response exists. Matched on the two exact download endpoints minus
# inline=true, which is the fallback's "Open file" link and is meant to
# open rather than save. That branch drops target="_blank" in every
# browser too: with the attribute present a same-origin link downloads
# and never opens a tab (measured), so it changes nothing here, but it
# stops the companion app from taking its new-window path before it
# considers the download -- an ordering that cannot be tested from
# outside iOS.
#
# Dropping target="_blank" from links that are *not* downloads is limited
# to the companion app, identified by the Mobile/HomeAssistant marker it
# appends to the user agent
# (HAAPI.swift, applicationNameForUserAgent -- it covers iPhone, iPad and
# Mac Catalyst). In a real browser a new tab is the better behaviour and
# is left alone; in the app it is a 401. It applies to any same-origin
# link below the add-on's own base path, which in practice is the
# "Open file" button and the share links in settings
# (views/settings/Shares.vue). Links to other origins keep their new tab,
# and only an exact target="_blank" is matched, so named windows, _parent
# and _top are untouched.
#
# Not covered, and still 401 in the app: anything the app opens with
# window.open() rather than an anchor, which is the public-share sidebar
# download and absolute sidebar links (components/sidebar/Links.vue); and
# links inside a document FileBrowser renders in its own iframe -- the
# pdf viewer, the srcdoc markdown/html preview, OnlyOffice -- because a
# listener on this document never sees another document's clicks.
sub_filter "window.__pwaDeferredPrompt = null;" "window.__pwaDeferredPrompt = null;(function(){var o=window.open;window.open=function(u,n,f){try{var b=(window.globalVars||{}).baseURL;if(u&&n==='_blank'&&!f&&b){if(b.slice(-1)!=='/')b+='/';var t=new URL(u,location.href);if((t.protocol==='http:'||t.protocol==='https:')&&t.origin===location.origin&&(t.pathname.indexOf(b+'files/')===0||t.pathname.indexOf(b+'public/share/')===0)){location.assign(t.href);return window}}}catch(e){}return o.apply(window,arguments)};document.addEventListener('click',function(e){try{if(e.button||e.metaKey||e.ctrlKey||e.shiftKey||e.altKey)return;var a=e.target&&e.target.closest?e.target.closest('a'):null;if(!a||!a.href)return;var b=(window.globalVars||{}).baseURL;if(!b)return;if(b.slice(-1)!=='/')b+='/';var t=new URL(a.href,location.href);if(t.origin!==location.origin)return;if(!a.hasAttribute('download')&&t.searchParams.get('inline')!=='true'&&(t.pathname===b+'api/resources/download'||t.pathname===b+'public/api/resources/download')){a.download='';a.removeAttribute('target');return}if(a.target==='_blank'&&t.pathname.indexOf(b)===0&&navigator.userAgent.indexOf('Mobile/HomeAssistant')!==-1){a.removeAttribute('target')}}catch(err){}},true)})();";
# Not covered: the public-share sidebar downloads with window.open()
# rather than an anchor, and the app's download manager is gated on
# iOS 17 (WebViewController+WebKitDelegates.swift).
sub_filter "window.__pwaDeferredPrompt = null;" "window.__pwaDeferredPrompt = null;(function(){var o=window.open;window.open=function(u,n,f){try{var b=(window.globalVars||{}).baseURL;if(u&&n==='_blank'&&!f&&b){if(b.slice(-1)!=='/')b+='/';var t=new URL(u,location.href);if((t.protocol==='http:'||t.protocol==='https:')&&t.origin===location.origin&&(t.pathname.indexOf(b+'files/')===0||t.pathname.indexOf(b+'public/share/')===0)){location.assign(t.href);return window}}}catch(e){}return o.apply(window,arguments)};var c=HTMLAnchorElement.prototype.click;HTMLAnchorElement.prototype.click=function(){try{var b=(window.globalVars||{}).baseURL;if(b&&this.href&&!this.hasAttribute('download')){if(b.slice(-1)!=='/')b+='/';var t=new URL(this.href,location.href);if(t.origin===location.origin&&t.searchParams.get('inline')!=='true'&&(t.pathname===b+'api/resources/download'||t.pathname===b+'public/api/resources/download')){this.download=''}}}catch(e){}return c.apply(this,arguments)}})();";
}
}