diff --git a/codec_cortex.html b/codec_cortex.html
index 9ca494a4..1154fd70 100644
--- a/codec_cortex.html
+++ b/codec_cortex.html
@@ -182,7 +182,7 @@
-
+
diff --git a/codec_dashboard.html b/codec_dashboard.html
index 059390d2..1b5abc78 100644
--- a/codec_dashboard.html
+++ b/codec_dashboard.html
@@ -666,7 +666,7 @@
-
+
diff --git a/docs/known-issues.md b/docs/known-issues.md
index fba080f6..b80868aa 100644
--- a/docs/known-issues.md
+++ b/docs/known-issues.md
@@ -411,7 +411,7 @@ same problem and was fixed on 2026-09-05 (entry above). Fix: send these through
`codec_audit.log_event` like the other services. Found in the Mac merge pass of
#418-#439. Revisit: the next item that touches either bridge.
-## Inbox actions show "HTTP 400" instead of the reason (2026-10-07)
+## Inbox actions show "HTTP 400" instead of the reason (2026-10-07) — FIXED (the shell reads {error} or {detail})
When an Inbox action is refused (for example a grant that is not allowed), the
toast says "HTTP 400" and drops the server's `error` / `detail` text, so the
diff --git a/static/codec-shell.js b/static/codec-shell.js
index 7d37c3bb..3136beee 100644
--- a/static/codec-shell.js
+++ b/static/codec-shell.js
@@ -1246,11 +1246,18 @@
if (m) h['x-csrf-token'] = m[1];
return h;
}
+ // The server's reason for a refusal: {error} from CODEC's own routes, {detail} from
+ // FastAPI's HTTPException (a list of {msg} for a validation error); else the status.
+ function errText(d, status) {
+ var x = d && (d.error || d.detail);
+ if (Array.isArray(x)) x = x.map(function (e) { return (e && e.msg) || ''; }).filter(Boolean).join('; ');
+ return (typeof x === 'string' && x) ? x : 'HTTP ' + status;
+ }
function postJSON(url, data) {
var h = csrf({ 'Content-Type': 'application/json' });
return fetch(url, { method: 'POST', headers: h, body: JSON.stringify(data || {}) }).then(function (r) {
return r.json().catch(function () { return {}; }).then(function (d) {
- if (!r.ok) throw new Error(d.error || ('HTTP ' + r.status));
+ if (!r.ok) throw new Error(errText(d, r.status));
return d;
});
});
@@ -1729,7 +1736,7 @@
if (!SAY.reqs[i]) {
SAY.reqs[i] = fetch('/api/tts', { method: 'POST', headers: csrf({ 'Content-Type': 'application/json' }),
body: JSON.stringify({ text: SAY.parts[i] }) }).then(function (r) {
- if (!r.ok) return r.json().catch(function () { return {}; }).then(function (d) { throw new Error(d.error || ('HTTP ' + r.status)); });
+ if (!r.ok) return r.json().catch(function () { return {}; }).then(function (d) { throw new Error(errText(d, r.status)); });
return r.blob();
}).then(function (b) {
var u = URL.createObjectURL(b);
diff --git a/tests/test_shell_error_reason.py b/tests/test_shell_error_reason.py
new file mode 100644
index 00000000..69850c63
--- /dev/null
+++ b/tests/test_shell_error_reason.py
@@ -0,0 +1,37 @@
+"""A refused page action shows the server's reason, not "HTTP 400" (docs/known-issues.md).
+
+The shell's postJSON only read {error}; FastAPI's HTTPException answers {detail},
+so an Inbox Grant or Resume refused by the agents routes said "HTTP 400".
+"""
+from __future__ import annotations
+
+import json
+import re
+import shutil
+import subprocess
+from pathlib import Path
+
+import pytest
+
+SHELL = (Path(__file__).resolve().parent.parent / "static" / "codec-shell.js").read_text(encoding="utf-8")
+
+
+def _err_text_source() -> str:
+ m = re.search(r"function errText\(d, status\) \{.*?\n \}\n", SHELL, re.S)
+ assert m, "errText is defined in the shell"
+ return m.group(0)
+
+
+def test_every_shell_request_helper_uses_the_reason():
+ assert "throw new Error(d.error || ('HTTP ' + r.status))" not in SHELL
+ assert SHELL.count("errText(d, r.status)") >= 2
+
+
+@pytest.mark.skipif(shutil.which("node") is None, reason="needs node")
+def test_the_reason_comes_from_error_detail_or_a_validation_list():
+ cases = [({"error": "Not allowed here."}, 400), ({"detail": "agent must be paused"}, 409),
+ ({"detail": [{"msg": "field required"}, {"msg": "bad value"}]}, 422), ({}, 500), (None, 502)]
+ js = _err_text_source() + "console.log(JSON.stringify(%s.map(c => errText(c[0], c[1]))));" % json.dumps(cases)
+ out = subprocess.run(["node", "-e", js], capture_output=True, text=True, timeout=30, check=True).stdout
+ assert json.loads(out) == ["Not allowed here.", "agent must be paused", "field required; bad value",
+ "HTTP 500", "HTTP 502"]