Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 9 additions & 15 deletions plugins/hermes-achievements/dashboard/dist/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -48,22 +48,16 @@
return tier ? "ha-tier-" + tier.toLowerCase() : "ha-tier-pending";
};

async function api(path, options) {
function api(path, options) {
// Delegate to the host SDK's fetchJSON so auth is handled correctly in
// BOTH dashboard modes: loopback (X-Hermes-Session-Token header) and
// gated OAuth (hermes_session_at cookie via credentials:'include').
// Hand-rolling fetch + reading window.__HERMES_SESSION_TOKEN__ directly
// 401s in gated mode (the token isn't injected there). fetchJSON throws
// Error("<status>: <body>") on non-2xx — the call sites' .catch() relies
// on that to surface errors, so we let it propagate (don't swallow).
const url = "/api/plugins/hermes-achievements" + path;
const token = window.__HERMES_SESSION_TOKEN__ || "";
const headers = { ...((options && options.headers) || {}) };
if (token) headers["X-Hermes-Session-Token"] = token;
const res = await fetch(url, { ...(options || {}), headers });
if (!res.ok) {
const text = await res.text().catch(function () { return res.statusText; });
throw new Error(res.status + ": " + text);
}
const text = await res.text();
try {
return JSON.parse(text);
} catch (_) {
return null;
}
return SDK.fetchJSON(url, options);
}

function AchievementIcon({ icon }) {
Expand Down
103 changes: 58 additions & 45 deletions plugins/kanban/dashboard/dist/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -588,52 +588,62 @@
wsClosedRef.current = false;
function openWs() {
if (wsClosedRef.current) return;
const token = window.__HERMES_SESSION_TOKEN__ || "";
const proto = window.location.protocol === "https:" ? "wss:" : "ws:";
const qsParams = {
since: String(cursorRef.current || 0),
token: token,
};
// Build the WS URL via the host SDK so the correct auth param is used
// in BOTH modes: single-use ?ticket= in gated OAuth mode, ?token= in
// loopback. Reading window.__HERMES_SESSION_TOKEN__ directly (the old
// path) sends an empty token and is rejected in gated mode. buildWsUrl
// also applies the dashboard base-path prefix for reverse-proxied
// deployments, which the old inline URL did not. It's async (gated
// mode mints a fresh ticket per connect), so resolve then open.
const wsParams = { since: String(cursorRef.current || 0) };
// Pin the WS stream to the currently-selected board so events
// from other boards don't bleed in. Includes "default" so the
// dashboard's own board pin always wins over the server-side
// ``current`` file — same rationale as ``withBoard()`` above.
// Regression: #20879.
if (board) qsParams.board = board;
const qs = new URLSearchParams(qsParams);
const url = `${proto}//${window.location.host}${API}/events?${qs}`;
let ws;
try { ws = new WebSocket(url); } catch (_e) { return; }
wsRef.current = ws;
ws.onopen = function () { wsBackoffRef.current = 1000; };
ws.onmessage = function (ev) {
try {
const msg = JSON.parse(ev.data);
if (msg && Array.isArray(msg.events) && msg.events.length > 0) {
cursorRef.current = msg.cursor || cursorRef.current;
// Stamp per-task signal so the TaskDrawer can reload itself.
setTaskEventTick(function (prev) {
const next = Object.assign({}, prev);
for (const e of msg.events) {
if (e && e.task_id) next[e.task_id] = (next[e.task_id] || 0) + 1;
}
return next;
});
scheduleReload();
if (board) wsParams.board = board;
SDK.buildWsUrl(`${API}/events`, wsParams).then(function (url) {
if (wsClosedRef.current) return;
let ws;
try { ws = new WebSocket(url); } catch (_e) { return; }
wsRef.current = ws;
ws.onopen = function () { wsBackoffRef.current = 1000; };
ws.onmessage = function (ev) {
try {
const msg = JSON.parse(ev.data);
if (msg && Array.isArray(msg.events) && msg.events.length > 0) {
cursorRef.current = msg.cursor || cursorRef.current;
// Stamp per-task signal so the TaskDrawer can reload itself.
setTaskEventTick(function (prev) {
const next = Object.assign({}, prev);
for (const e of msg.events) {
if (e && e.task_id) next[e.task_id] = (next[e.task_id] || 0) + 1;
}
return next;
});
scheduleReload();
}
} catch (_e) { /* ignore */ }
};
ws.onclose = function (ev) {
if (wsClosedRef.current) return;
if (ev && ev.code === 1008) {
setError(tx(t, "wsAuthFailed",
"WebSocket auth failed — reload the page to refresh the session token."));
return;
}
} catch (_e) { /* ignore */ }
};
ws.onclose = function (ev) {
const delay = Math.min(wsBackoffRef.current, 30000);
wsBackoffRef.current = Math.min(wsBackoffRef.current * 2, 30000);
setTimeout(openWs, delay);
};
}).catch(function () {
// Ticket mint / URL build failed (e.g. session expired). Back off
// and retry; a hard auth failure surfaces via the 1008 close path.
if (wsClosedRef.current) return;
if (ev && ev.code === 1008) {
setError(tx(t, "wsAuthFailed",
"WebSocket auth failed — reload the page to refresh the session token."));
return;
}
const delay = Math.min(wsBackoffRef.current, 30000);
wsBackoffRef.current = Math.min(wsBackoffRef.current * 2, 30000);
setTimeout(openWs, delay);
};
});
}
openWs();
return function () {
Expand Down Expand Up @@ -2837,16 +2847,18 @@
if (!files.length) return;
setUploadBusy(true);
setUploadErr(null);
const token = window.__HERMES_SESSION_TOKEN__ || "";
const headers = token ? { Authorization: "Bearer " + token } : {};
const url = withBoard(`${API}/tasks/${encodeURIComponent(props.taskId)}/attachments`, boardSlug);
// Upload sequentially so a partial failure leaves a clear state.
let chain = Promise.resolve();
files.forEach(function (f) {
chain = chain.then(function () {
const fd = new FormData();
fd.append("file", f, f.name);
return fetch(url, { method: "POST", headers: headers, credentials: "same-origin", body: fd })
// SDK.authedFetch handles auth in BOTH modes (loopback token header /
// gated cookie) and applies the dashboard base-path prefix. The old
// hand-rolled Authorization:Bearer + credentials:'same-origin' sent
// an empty token and 401'd in gated mode.
return SDK.authedFetch(url, { method: "POST", body: fd })
.then(function (resp) {
if (!resp.ok) {
return resp.text().then(function (txt) {
Expand Down Expand Up @@ -3073,15 +3085,16 @@
const fileRef = useRef(null);
const [dlErr, setDlErr] = useState(null);
// Download via authenticated fetch → blob → synthetic anchor click.
// A plain <a href> can't carry the session header/bearer the dashboard
// auth middleware requires in loopback mode, so fetch with the token
// and hand the browser a blob URL instead.
// A plain <a href> can't carry the auth the dashboard middleware requires,
// so fetch authenticated and hand the browser a blob URL instead.
function downloadAttachment(a) {
const token = window.__HERMES_SESSION_TOKEN__ || "";
const headers = token ? { Authorization: "Bearer " + token } : {};
// SDK.authedFetch handles auth in BOTH modes (loopback token header /
// gated cookie) and applies the dashboard base-path prefix. The old
// hand-rolled Authorization:Bearer + credentials:'same-origin' sent an
// empty token and 401'd in gated mode.
const url = withBoard(`${API}/attachments/${a.id}`, props.boardSlug);
setDlErr(null);
fetch(url, { headers: headers, credentials: "same-origin" })
SDK.authedFetch(url)
.then(function (resp) {
if (!resp.ok) {
return resp.text().then(function (txt) {
Expand Down
43 changes: 27 additions & 16 deletions plugins/kanban/dashboard/plugin_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,6 @@
from __future__ import annotations

import asyncio
import hmac
import json
import logging
import os
Expand All @@ -63,26 +62,37 @@
# existing plugin-bypass; this is documented above).
# ---------------------------------------------------------------------------

def _check_ws_token(provided: Optional[str]) -> bool:
"""Constant-time compare against the dashboard session token.
def _ws_upgrade_authorized(ws: "WebSocket") -> bool:
"""Authorize a WebSocket upgrade by delegating to the dashboard's canonical
WS auth gate (``hermes_cli.web_server._ws_auth_ok``).

Delegating (rather than re-implementing a ``_SESSION_TOKEN``-only check)
means this endpoint transparently accepts whatever the core gate accepts
in each mode:

* loopback / ``--insecure``: legacy ``?token=<_SESSION_TOKEN>``
* gated OAuth: single-use ``?ticket=`` (the browser SDK's
``buildWsUrl`` mints one per connect)
* server-internal: the process-lifetime ``?internal=`` credential

The previous bespoke check only understood ``_SESSION_TOKEN``, so the
kanban live-events WS was rejected on every OAuth-gated deployment even
though the rest of the dashboard worked. Routing through the shared gate
also means this can never drift from core auth again.

Imported lazily so the plugin still loads in test contexts where the
dashboard web_server module isn't importable (e.g. the bare-FastAPI
test harness).
dashboard ``web_server`` module isn't importable (e.g. the bare-FastAPI
test harness); there we accept so the tail loop stays testable, matching
the prior behaviour.
"""
if not provided:
return False
try:
from hermes_cli import web_server as _ws
except Exception:
# No dashboard context (tests). Accept so the tail loop is still
# testable; in production the dashboard module always imports
# cleanly because it's the caller.
return True
expected = getattr(_ws, "_SESSION_TOKEN", None)
if not expected:
return True
return hmac.compare_digest(str(provided), str(expected))
return bool(_ws._ws_auth_ok(ws))


def _resolve_board(board: Optional[str]) -> Optional[str]:
Expand Down Expand Up @@ -2375,11 +2385,12 @@ def set_orchestration_settings(payload: OrchestrationSettingsBody):

@router.websocket("/events")
async def stream_events(ws: WebSocket):
# Enforce the dashboard session token as a query param — browsers can't
# set Authorization on a WS upgrade. This matches how the PTY bridge
# authenticates in hermes_cli/web_server.py.
token = ws.query_params.get("token")
if not _check_ws_token(token):
# Authorize the upgrade via the dashboard's canonical WS gate so the
# correct credential is accepted in every mode (loopback token / gated
# single-use ticket / server-internal credential). Browsers can't set
# Authorization on a WS upgrade, so the credential rides in the query
# string — the browser SDK's buildWsUrl() assembles it.
if not _ws_upgrade_authorized(ws):
await ws.close(code=http_status.WS_1008_POLICY_VIOLATION)
return
await ws.accept()
Expand Down
73 changes: 66 additions & 7 deletions tests/plugins/test_kanban_dashboard_plugin.py
Original file line number Diff line number Diff line change
Expand Up @@ -735,18 +735,29 @@ def test_board_auto_initializes_missing_db(tmp_path, monkeypatch):


def test_ws_events_rejects_when_token_required(tmp_path, monkeypatch):
"""When _SESSION_TOKEN is set (normal dashboard context), a missing or
wrong ?token= query param must be rejected with policy-violation."""
"""Loopback mode: a missing or wrong ?token= must be rejected with
policy-violation; the correct token is accepted. The kanban WS now
delegates to web_server._ws_auth_ok, so we stub that with the real
loopback-token semantics (auth_required False → constant-time token
compare)."""
home = tmp_path / ".hermes"
home.mkdir()
monkeypatch.setenv("HERMES_HOME", str(home))
monkeypatch.setattr(Path, "home", lambda: tmp_path)
kb.init_db()

# Stub web_server so _check_ws_token has a token to compare against.
# Stub web_server with a loopback-mode _ws_auth_ok (auth_required False →
# accept only the correct ?token=). Mirrors the real gate's loopback path.
import hermes_cli
import types
stub = types.SimpleNamespace(_SESSION_TOKEN="secret-xyz")

def _fake_ws_auth_ok(ws):
return ws.query_params.get("token", "") == "secret-xyz"

stub = types.SimpleNamespace(
_SESSION_TOKEN="secret-xyz",
_ws_auth_ok=_fake_ws_auth_ok,
)
monkeypatch.setitem(sys.modules, "hermes_cli.web_server", stub)
monkeypatch.setattr(hermes_cli, "web_server", stub, raising=False)

Expand Down Expand Up @@ -774,6 +785,51 @@ def test_ws_events_rejects_when_token_required(tmp_path, monkeypatch):
assert ws is not None # handshake succeeded


def test_ws_events_accepts_gated_ticket(tmp_path, monkeypatch):
"""Gated OAuth mode: the WS must accept a single-use ?ticket= (and reject
a bare ?token=, even one matching _SESSION_TOKEN). This is the regression
for the hosted-dashboard bug where the kanban live-events WS 1008'd on
every gated deployment because its bespoke check only knew _SESSION_TOKEN.
We stub _ws_auth_ok with the real gated semantics (ticket-only)."""
home = tmp_path / ".hermes"
home.mkdir()
monkeypatch.setenv("HERMES_HOME", str(home))
monkeypatch.setattr(Path, "home", lambda: tmp_path)
kb.init_db()

import hermes_cli
import types

def _fake_ws_auth_ok(ws):
# Gated mode: only a known ticket is accepted; token path rejected.
return ws.query_params.get("ticket", "") == "good-ticket"

stub = types.SimpleNamespace(
_SESSION_TOKEN="secret-xyz",
_ws_auth_ok=_fake_ws_auth_ok,
)
monkeypatch.setitem(sys.modules, "hermes_cli.web_server", stub)
monkeypatch.setattr(hermes_cli, "web_server", stub, raising=False)

app = FastAPI()
app.include_router(_load_plugin_router(), prefix="/api/plugins/kanban")
c = TestClient(app)

from starlette.websockets import WebSocketDisconnect

# Legacy token is rejected in gated mode, even if it's the real one.
with pytest.raises(WebSocketDisconnect) as exc:
with c.websocket_connect("/api/plugins/kanban/events?token=secret-xyz"):
pass
assert exc.value.code == 1008

# A valid ticket is accepted.
with c.websocket_connect(
"/api/plugins/kanban/events?ticket=good-ticket"
) as ws:
assert ws is not None


def test_ws_events_board_query_param_default_overrides_current_board_pointer(tmp_path, monkeypatch):
"""The event stream must honor ``board=default`` even when the global
current-board pointer targets a different board.
Expand Down Expand Up @@ -806,7 +862,10 @@ def test_ws_events_board_query_param_default_overrides_current_board_pointer(tmp
import hermes_cli
import types

stub = types.SimpleNamespace(_SESSION_TOKEN="secret-xyz")
stub = types.SimpleNamespace(
_SESSION_TOKEN="secret-xyz",
_ws_auth_ok=lambda ws: ws.query_params.get("token", "") == "secret-xyz",
)
monkeypatch.setitem(sys.modules, "hermes_cli.web_server", stub)
monkeypatch.setattr(hermes_cli, "web_server", stub, raising=False)

Expand Down Expand Up @@ -842,10 +901,10 @@ def test_ws_events_swallows_cancellation_on_shutdown(tmp_path, monkeypatch):
monkeypatch.setattr(Path, "home", lambda: tmp_path)
kb.init_db()

# Short-circuit the token check — this test is about the cancellation
# Short-circuit the auth check — this test is about the cancellation
# path, not auth.
import plugins.kanban.dashboard.plugin_api as pa
monkeypatch.setattr(pa, "_check_ws_token", lambda t: True)
monkeypatch.setattr(pa, "_ws_upgrade_authorized", lambda ws: True)

class _FakeWS:
def __init__(self):
Expand Down
Loading
Loading