fix(dashboard): sanction plugin WS/upload auth via SDK helpers (gated mode)
Dashboard plugins (kanban, hermes-achievements) read window.__HERMES_SESSION_TOKEN__ directly and hand-assembled WebSocket URLs with ?token=. That works in loopback/--insecure mode but is rejected on OAuth-gated deployments, where the session token is absent and _ws_auth_ok only accepts single-use ?ticket= auth. The result was 401s on plugin REST calls and 1008/403 on the kanban live-events WS whenever the dashboard ran behind OAuth (e.g. hosted Fly agents). Make the plugin SDK the single sanctioned auth surface: - web/src/lib/api.ts: add authedFetch() (raw Response for FormData uploads / blob downloads, token-or-cookie auth, no throw / no 401 redirect) and buildWsUrl() (assembles a ws(s):// URL with the correct auth param for the active mode — fresh single-use ticket in gated mode, token in loopback). - web/src/plugins/registry.ts: expose authedFetch, buildWsUrl, buildWsAuthParam, and sdkVersion on window.__HERMES_PLUGIN_SDK__; add SDK_CONTRACT_VERSION. - web/src/plugins/sdk.d.ts: hand-authored typed contract for the plugin SDK + registry globals (single source of truth for the Window declarations). - plugins/kanban + hermes-achievements dist bundles: stop reading the session token directly; route uploads/downloads through SDK.authedFetch and the live-events WS through SDK.buildWsUrl. - plugins/kanban plugin_api.py: _ws_upgrade_authorized() delegates the /events WS upgrade to the canonical web_server._ws_auth_ok gate, so it transparently accepts loopback token / gated ticket / internal credential and can never drift from core auth again. - tests: guard test asserting no plugin dist reads __HERMES_SESSION_TOKEN__ directly; kanban gated-ticket WS test. Verified live on a gated staging Fly agent: kanban /events upgrades 101 with a minted ticket (ticket_len=43, ws_auth_ok=True) where the old code got 403.
This commit is contained in:
@ -48,22 +48,16 @@
|
|||||||
return tier ? "ha-tier-" + tier.toLowerCase() : "ha-tier-pending";
|
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 url = "/api/plugins/hermes-achievements" + path;
|
||||||
const token = window.__HERMES_SESSION_TOKEN__ || "";
|
return SDK.fetchJSON(url, options);
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function AchievementIcon({ icon }) {
|
function AchievementIcon({ icon }) {
|
||||||
|
|||||||
105
plugins/kanban/dashboard/dist/index.js
vendored
105
plugins/kanban/dashboard/dist/index.js
vendored
@ -588,52 +588,62 @@
|
|||||||
wsClosedRef.current = false;
|
wsClosedRef.current = false;
|
||||||
function openWs() {
|
function openWs() {
|
||||||
if (wsClosedRef.current) return;
|
if (wsClosedRef.current) return;
|
||||||
const token = window.__HERMES_SESSION_TOKEN__ || "";
|
// Build the WS URL via the host SDK so the correct auth param is used
|
||||||
const proto = window.location.protocol === "https:" ? "wss:" : "ws:";
|
// in BOTH modes: single-use ?ticket= in gated OAuth mode, ?token= in
|
||||||
const qsParams = {
|
// loopback. Reading window.__HERMES_SESSION_TOKEN__ directly (the old
|
||||||
since: String(cursorRef.current || 0),
|
// path) sends an empty token and is rejected in gated mode. buildWsUrl
|
||||||
token: token,
|
// 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
|
// Pin the WS stream to the currently-selected board so events
|
||||||
// from other boards don't bleed in. Includes "default" so the
|
// from other boards don't bleed in. Includes "default" so the
|
||||||
// dashboard's own board pin always wins over the server-side
|
// dashboard's own board pin always wins over the server-side
|
||||||
// ``current`` file — same rationale as ``withBoard()`` above.
|
// ``current`` file — same rationale as ``withBoard()`` above.
|
||||||
// Regression: #20879.
|
// Regression: #20879.
|
||||||
if (board) qsParams.board = board;
|
if (board) wsParams.board = board;
|
||||||
const qs = new URLSearchParams(qsParams);
|
SDK.buildWsUrl(`${API}/events`, wsParams).then(function (url) {
|
||||||
const url = `${proto}//${window.location.host}${API}/events?${qs}`;
|
if (wsClosedRef.current) return;
|
||||||
let ws;
|
let ws;
|
||||||
try { ws = new WebSocket(url); } catch (_e) { return; }
|
try { ws = new WebSocket(url); } catch (_e) { return; }
|
||||||
wsRef.current = ws;
|
wsRef.current = ws;
|
||||||
ws.onopen = function () { wsBackoffRef.current = 1000; };
|
ws.onopen = function () { wsBackoffRef.current = 1000; };
|
||||||
ws.onmessage = function (ev) {
|
ws.onmessage = function (ev) {
|
||||||
try {
|
try {
|
||||||
const msg = JSON.parse(ev.data);
|
const msg = JSON.parse(ev.data);
|
||||||
if (msg && Array.isArray(msg.events) && msg.events.length > 0) {
|
if (msg && Array.isArray(msg.events) && msg.events.length > 0) {
|
||||||
cursorRef.current = msg.cursor || cursorRef.current;
|
cursorRef.current = msg.cursor || cursorRef.current;
|
||||||
// Stamp per-task signal so the TaskDrawer can reload itself.
|
// Stamp per-task signal so the TaskDrawer can reload itself.
|
||||||
setTaskEventTick(function (prev) {
|
setTaskEventTick(function (prev) {
|
||||||
const next = Object.assign({}, prev);
|
const next = Object.assign({}, prev);
|
||||||
for (const e of msg.events) {
|
for (const e of msg.events) {
|
||||||
if (e && e.task_id) next[e.task_id] = (next[e.task_id] || 0) + 1;
|
if (e && e.task_id) next[e.task_id] = (next[e.task_id] || 0) + 1;
|
||||||
}
|
}
|
||||||
return next;
|
return next;
|
||||||
});
|
});
|
||||||
scheduleReload();
|
scheduleReload();
|
||||||
}
|
}
|
||||||
} catch (_e) { /* ignore */ }
|
} catch (_e) { /* ignore */ }
|
||||||
};
|
};
|
||||||
ws.onclose = function (ev) {
|
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;
|
||||||
|
}
|
||||||
|
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 (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);
|
const delay = Math.min(wsBackoffRef.current, 30000);
|
||||||
wsBackoffRef.current = Math.min(wsBackoffRef.current * 2, 30000);
|
wsBackoffRef.current = Math.min(wsBackoffRef.current * 2, 30000);
|
||||||
setTimeout(openWs, delay);
|
setTimeout(openWs, delay);
|
||||||
};
|
});
|
||||||
}
|
}
|
||||||
openWs();
|
openWs();
|
||||||
return function () {
|
return function () {
|
||||||
@ -2837,8 +2847,6 @@
|
|||||||
if (!files.length) return;
|
if (!files.length) return;
|
||||||
setUploadBusy(true);
|
setUploadBusy(true);
|
||||||
setUploadErr(null);
|
setUploadErr(null);
|
||||||
const token = window.__HERMES_SESSION_TOKEN__ || "";
|
|
||||||
const headers = token ? { Authorization: "Bearer " + token } : {};
|
|
||||||
const url = withBoard(`${API}/tasks/${encodeURIComponent(props.taskId)}/attachments`, boardSlug);
|
const url = withBoard(`${API}/tasks/${encodeURIComponent(props.taskId)}/attachments`, boardSlug);
|
||||||
// Upload sequentially so a partial failure leaves a clear state.
|
// Upload sequentially so a partial failure leaves a clear state.
|
||||||
let chain = Promise.resolve();
|
let chain = Promise.resolve();
|
||||||
@ -2846,7 +2854,11 @@
|
|||||||
chain = chain.then(function () {
|
chain = chain.then(function () {
|
||||||
const fd = new FormData();
|
const fd = new FormData();
|
||||||
fd.append("file", f, f.name);
|
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) {
|
.then(function (resp) {
|
||||||
if (!resp.ok) {
|
if (!resp.ok) {
|
||||||
return resp.text().then(function (txt) {
|
return resp.text().then(function (txt) {
|
||||||
@ -3073,15 +3085,16 @@
|
|||||||
const fileRef = useRef(null);
|
const fileRef = useRef(null);
|
||||||
const [dlErr, setDlErr] = useState(null);
|
const [dlErr, setDlErr] = useState(null);
|
||||||
// Download via authenticated fetch → blob → synthetic anchor click.
|
// Download via authenticated fetch → blob → synthetic anchor click.
|
||||||
// A plain <a href> can't carry the session header/bearer the dashboard
|
// A plain <a href> can't carry the auth the dashboard middleware requires,
|
||||||
// auth middleware requires in loopback mode, so fetch with the token
|
// so fetch authenticated and hand the browser a blob URL instead.
|
||||||
// and hand the browser a blob URL instead.
|
|
||||||
function downloadAttachment(a) {
|
function downloadAttachment(a) {
|
||||||
const token = window.__HERMES_SESSION_TOKEN__ || "";
|
// SDK.authedFetch handles auth in BOTH modes (loopback token header /
|
||||||
const headers = token ? { Authorization: "Bearer " + token } : {};
|
// 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);
|
const url = withBoard(`${API}/attachments/${a.id}`, props.boardSlug);
|
||||||
setDlErr(null);
|
setDlErr(null);
|
||||||
fetch(url, { headers: headers, credentials: "same-origin" })
|
SDK.authedFetch(url)
|
||||||
.then(function (resp) {
|
.then(function (resp) {
|
||||||
if (!resp.ok) {
|
if (!resp.ok) {
|
||||||
return resp.text().then(function (txt) {
|
return resp.text().then(function (txt) {
|
||||||
|
|||||||
@ -36,7 +36,6 @@ the port.
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
import hmac
|
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
@ -63,15 +62,29 @@ router = APIRouter()
|
|||||||
# existing plugin-bypass; this is documented above).
|
# existing plugin-bypass; this is documented above).
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
def _check_ws_token(provided: Optional[str]) -> bool:
|
def _ws_upgrade_authorized(ws: "WebSocket") -> bool:
|
||||||
"""Constant-time compare against the dashboard session token.
|
"""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
|
Imported lazily so the plugin still loads in test contexts where the
|
||||||
dashboard web_server module isn't importable (e.g. the bare-FastAPI
|
dashboard ``web_server`` module isn't importable (e.g. the bare-FastAPI
|
||||||
test harness).
|
test harness); there we accept so the tail loop stays testable, matching
|
||||||
|
the prior behaviour.
|
||||||
"""
|
"""
|
||||||
if not provided:
|
|
||||||
return False
|
|
||||||
try:
|
try:
|
||||||
from hermes_cli import web_server as _ws
|
from hermes_cli import web_server as _ws
|
||||||
except Exception:
|
except Exception:
|
||||||
@ -79,10 +92,7 @@ def _check_ws_token(provided: Optional[str]) -> bool:
|
|||||||
# testable; in production the dashboard module always imports
|
# testable; in production the dashboard module always imports
|
||||||
# cleanly because it's the caller.
|
# cleanly because it's the caller.
|
||||||
return True
|
return True
|
||||||
expected = getattr(_ws, "_SESSION_TOKEN", None)
|
return bool(_ws._ws_auth_ok(ws))
|
||||||
if not expected:
|
|
||||||
return True
|
|
||||||
return hmac.compare_digest(str(provided), str(expected))
|
|
||||||
|
|
||||||
|
|
||||||
def _resolve_board(board: Optional[str]) -> Optional[str]:
|
def _resolve_board(board: Optional[str]) -> Optional[str]:
|
||||||
@ -2375,11 +2385,12 @@ def set_orchestration_settings(payload: OrchestrationSettingsBody):
|
|||||||
|
|
||||||
@router.websocket("/events")
|
@router.websocket("/events")
|
||||||
async def stream_events(ws: WebSocket):
|
async def stream_events(ws: WebSocket):
|
||||||
# Enforce the dashboard session token as a query param — browsers can't
|
# Authorize the upgrade via the dashboard's canonical WS gate so the
|
||||||
# set Authorization on a WS upgrade. This matches how the PTY bridge
|
# correct credential is accepted in every mode (loopback token / gated
|
||||||
# authenticates in hermes_cli/web_server.py.
|
# single-use ticket / server-internal credential). Browsers can't set
|
||||||
token = ws.query_params.get("token")
|
# Authorization on a WS upgrade, so the credential rides in the query
|
||||||
if not _check_ws_token(token):
|
# string — the browser SDK's buildWsUrl() assembles it.
|
||||||
|
if not _ws_upgrade_authorized(ws):
|
||||||
await ws.close(code=http_status.WS_1008_POLICY_VIOLATION)
|
await ws.close(code=http_status.WS_1008_POLICY_VIOLATION)
|
||||||
return
|
return
|
||||||
await ws.accept()
|
await ws.accept()
|
||||||
|
|||||||
@ -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):
|
def test_ws_events_rejects_when_token_required(tmp_path, monkeypatch):
|
||||||
"""When _SESSION_TOKEN is set (normal dashboard context), a missing or
|
"""Loopback mode: a missing or wrong ?token= must be rejected with
|
||||||
wrong ?token= query param must be rejected with policy-violation."""
|
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 = tmp_path / ".hermes"
|
||||||
home.mkdir()
|
home.mkdir()
|
||||||
monkeypatch.setenv("HERMES_HOME", str(home))
|
monkeypatch.setenv("HERMES_HOME", str(home))
|
||||||
monkeypatch.setattr(Path, "home", lambda: tmp_path)
|
monkeypatch.setattr(Path, "home", lambda: tmp_path)
|
||||||
kb.init_db()
|
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 hermes_cli
|
||||||
import types
|
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.setitem(sys.modules, "hermes_cli.web_server", stub)
|
||||||
monkeypatch.setattr(hermes_cli, "web_server", stub, raising=False)
|
monkeypatch.setattr(hermes_cli, "web_server", stub, raising=False)
|
||||||
|
|
||||||
@ -774,6 +785,51 @@ def test_ws_events_rejects_when_token_required(tmp_path, monkeypatch):
|
|||||||
assert ws is not None # handshake succeeded
|
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):
|
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
|
"""The event stream must honor ``board=default`` even when the global
|
||||||
current-board pointer targets a different board.
|
current-board pointer targets a different board.
|
||||||
@ -806,7 +862,10 @@ def test_ws_events_board_query_param_default_overrides_current_board_pointer(tmp
|
|||||||
import hermes_cli
|
import hermes_cli
|
||||||
import types
|
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.setitem(sys.modules, "hermes_cli.web_server", stub)
|
||||||
monkeypatch.setattr(hermes_cli, "web_server", stub, raising=False)
|
monkeypatch.setattr(hermes_cli, "web_server", stub, raising=False)
|
||||||
|
|
||||||
@ -842,10 +901,10 @@ def test_ws_events_swallows_cancellation_on_shutdown(tmp_path, monkeypatch):
|
|||||||
monkeypatch.setattr(Path, "home", lambda: tmp_path)
|
monkeypatch.setattr(Path, "home", lambda: tmp_path)
|
||||||
kb.init_db()
|
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.
|
# path, not auth.
|
||||||
import plugins.kanban.dashboard.plugin_api as pa
|
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:
|
class _FakeWS:
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
|
|||||||
95
tests/plugins/test_plugin_dashboard_auth_contract.py
Normal file
95
tests/plugins/test_plugin_dashboard_auth_contract.py
Normal file
@ -0,0 +1,95 @@
|
|||||||
|
"""Guardrail: dashboard plugins must NOT read the session token directly.
|
||||||
|
|
||||||
|
The dashboard host exposes a sanctioned, gated-mode-aware auth surface on the
|
||||||
|
plugin SDK (``window.__HERMES_PLUGIN_SDK__``): ``fetchJSON`` (JSON REST),
|
||||||
|
``authedFetch`` (uploads / blob downloads), and ``buildWsUrl`` /
|
||||||
|
``buildWsAuthParam`` (WebSockets). These handle BOTH dashboard auth modes —
|
||||||
|
loopback (``X-Hermes-Session-Token`` header) and gated OAuth
|
||||||
|
(``hermes_session_at`` cookie / single-use ``?ticket=``).
|
||||||
|
|
||||||
|
Plugins that hand-roll ``fetch`` / ``WebSocket`` and read
|
||||||
|
``window.__HERMES_SESSION_TOKEN__`` directly send an empty token in gated mode
|
||||||
|
and 401/1008. That bug shipped in the kanban and achievements plugins and was
|
||||||
|
invisible until the dashboard ran gated on hosted Fly agents.
|
||||||
|
|
||||||
|
This test fails if any bundled plugin's frontend reads the token global
|
||||||
|
directly, forcing new/edited plugins through the SDK surface instead. It is
|
||||||
|
the enforcement half of the "single sanctioned auth surface" design — the SDK
|
||||||
|
helpers are the carrot, this test is the stick.
|
||||||
|
|
||||||
|
If you have a legitimate reason to reference the token name (e.g. a comment
|
||||||
|
explaining why NOT to use it), add the file to ``_ALLOWED_FILES`` with a note.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
# Repo root: tests/plugins/<this file> → ../../
|
||||||
|
_REPO_ROOT = Path(__file__).resolve().parents[2]
|
||||||
|
_PLUGINS_DIR = _REPO_ROOT / "plugins"
|
||||||
|
|
||||||
|
# The forbidden global. Reading it directly bypasses the gated-mode auth path.
|
||||||
|
_FORBIDDEN = "__HERMES_SESSION_TOKEN__"
|
||||||
|
|
||||||
|
# Files explicitly allowed to mention the token (none today). Map path →
|
||||||
|
# reason so the allowance is self-documenting if one is ever needed.
|
||||||
|
_ALLOWED_FILES: dict[str, str] = {}
|
||||||
|
|
||||||
|
|
||||||
|
def _plugin_frontend_bundles() -> list[Path]:
|
||||||
|
"""Every plugin-shipped JS bundle the dashboard loads into the browser."""
|
||||||
|
if not _PLUGINS_DIR.is_dir():
|
||||||
|
return []
|
||||||
|
# Plugin dashboards live at plugins/<name>/dashboard/dist/*.js
|
||||||
|
return sorted(_PLUGINS_DIR.glob("*/dashboard/dist/*.js"))
|
||||||
|
|
||||||
|
|
||||||
|
def test_there_are_plugin_bundles_to_check() -> None:
|
||||||
|
"""Sanity: the glob actually finds the bundles, so a future layout change
|
||||||
|
doesn't silently turn this guard into a no-op."""
|
||||||
|
bundles = _plugin_frontend_bundles()
|
||||||
|
names = {b.parent.parent.parent.name for b in bundles}
|
||||||
|
# kanban + hermes-achievements are bundled today; assert at least one is
|
||||||
|
# found so the guard can't pass vacuously.
|
||||||
|
assert bundles, "no plugin dashboard bundles found — glob/layout drift?"
|
||||||
|
assert names, "could not resolve plugin names from bundle paths"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"bundle",
|
||||||
|
_plugin_frontend_bundles(),
|
||||||
|
ids=lambda p: str(p.relative_to(_REPO_ROOT)),
|
||||||
|
)
|
||||||
|
def test_plugin_bundle_does_not_read_session_token(bundle: Path) -> None:
|
||||||
|
rel = str(bundle.relative_to(_REPO_ROOT))
|
||||||
|
text = bundle.read_text(encoding="utf-8", errors="replace")
|
||||||
|
|
||||||
|
if rel in _ALLOWED_FILES:
|
||||||
|
return # explicitly allowed (with a documented reason)
|
||||||
|
|
||||||
|
# Only flag CODE reads of the token global, not mentions in ``//`` comments
|
||||||
|
# (e.g. a comment explaining why the SDK helper is used instead). A line is
|
||||||
|
# a code read if it contains the global and the global appears before any
|
||||||
|
# ``//`` comment marker on that line.
|
||||||
|
offending: list[str] = []
|
||||||
|
for i, line in enumerate(text.splitlines(), start=1):
|
||||||
|
idx = line.find(_FORBIDDEN)
|
||||||
|
if idx == -1:
|
||||||
|
continue
|
||||||
|
comment_idx = line.find("//")
|
||||||
|
in_comment = comment_idx != -1 and comment_idx < idx
|
||||||
|
if not in_comment:
|
||||||
|
offending.append(f" {i}: {line.strip()}")
|
||||||
|
|
||||||
|
if not offending:
|
||||||
|
return
|
||||||
|
|
||||||
|
pytest.fail(
|
||||||
|
f"{rel} reads {_FORBIDDEN} directly — this bypasses gated-mode auth "
|
||||||
|
f"and 401/1008s on OAuth-gated dashboards. Use the plugin SDK instead: "
|
||||||
|
f"SDK.fetchJSON (JSON), SDK.authedFetch (uploads/downloads), or "
|
||||||
|
f"SDK.buildWsUrl (WebSockets). Offending lines:\n" + "\n".join(offending)
|
||||||
|
)
|
||||||
@ -192,6 +192,63 @@ export async function buildWsAuthParam(): Promise<[string, string]> {
|
|||||||
return ["token", token];
|
return ["token", token];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Authenticated ``fetch`` for dashboard ``/api/...`` requests that aren't
|
||||||
|
* plain JSON — file uploads (``FormData``), binary downloads (blobs), etc.
|
||||||
|
* Mirrors ``fetchJSON``'s auth handling but returns the raw ``Response`` so
|
||||||
|
* the caller can read ``.blob()`` / ``.formData()`` / stream it.
|
||||||
|
*
|
||||||
|
* Auth, in both modes, exactly as ``fetchJSON`` does it:
|
||||||
|
* - loopback / ``--insecure``: attach the ``X-Hermes-Session-Token`` header.
|
||||||
|
* - gated OAuth: no token header (it's absent by design); the
|
||||||
|
* ``hermes_session_at`` cookie rides along via ``credentials: 'include'``.
|
||||||
|
*
|
||||||
|
* Unlike ``fetchJSON`` this does NOT parse the body, does NOT throw on
|
||||||
|
* non-2xx (the caller decides — a 404 on a download is meaningful), and
|
||||||
|
* does NOT run the global 401 → /login redirect (binary endpoints aren't
|
||||||
|
* navigation targets). Callers that want the redirect behaviour should use
|
||||||
|
* ``fetchJSON``.
|
||||||
|
*/
|
||||||
|
export async function authedFetch(
|
||||||
|
url: string,
|
||||||
|
init?: RequestInit,
|
||||||
|
): Promise<Response> {
|
||||||
|
const headers = new Headers(init?.headers);
|
||||||
|
const token = window.__HERMES_SESSION_TOKEN__;
|
||||||
|
if (token) {
|
||||||
|
setSessionHeader(headers, token);
|
||||||
|
}
|
||||||
|
return fetch(`${BASE}${url}`, {
|
||||||
|
...init,
|
||||||
|
headers,
|
||||||
|
credentials: init?.credentials ?? "include",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build an absolute ``ws(s)://`` URL for a dashboard WebSocket endpoint,
|
||||||
|
* with the correct auth query param appended for the active mode (fresh
|
||||||
|
* single-use ``ticket`` in gated mode, ``token`` in loopback). Plugins and
|
||||||
|
* the SPA should use this instead of hand-assembling a WS URL + reading
|
||||||
|
* ``window.__HERMES_SESSION_TOKEN__`` directly, so the gated-mode ticket
|
||||||
|
* path can never be forgotten.
|
||||||
|
*
|
||||||
|
* ``path`` is the dashboard-relative path (e.g.
|
||||||
|
* ``"/api/plugins/kanban/events"``); the base-path prefix and host are
|
||||||
|
* applied here. Extra query params can be supplied via ``params`` and are
|
||||||
|
* merged before the auth param.
|
||||||
|
*/
|
||||||
|
export async function buildWsUrl(
|
||||||
|
path: string,
|
||||||
|
params?: Record<string, string>,
|
||||||
|
): Promise<string> {
|
||||||
|
const [authName, authValue] = await buildWsAuthParam();
|
||||||
|
const proto = window.location.protocol === "https:" ? "wss:" : "ws:";
|
||||||
|
const qs = new URLSearchParams(params ?? {});
|
||||||
|
qs.set(authName, authValue);
|
||||||
|
return `${proto}//${window.location.host}${BASE}${path}?${qs}`;
|
||||||
|
}
|
||||||
|
|
||||||
export const api = {
|
export const api = {
|
||||||
getStatus: () => fetchJSON<StatusResponse>("/api/status"),
|
getStatus: () => fetchJSON<StatusResponse>("/api/status"),
|
||||||
/**
|
/**
|
||||||
|
|||||||
@ -17,7 +17,7 @@ import React, {
|
|||||||
useContext,
|
useContext,
|
||||||
createContext,
|
createContext,
|
||||||
} from "react";
|
} from "react";
|
||||||
import { api, fetchJSON } from "@/lib/api";
|
import { api, fetchJSON, authedFetch, buildWsUrl, buildWsAuthParam } from "@/lib/api";
|
||||||
import { cn, timeAgo, isoTimeAgo } from "@/lib/utils";
|
import { cn, timeAgo, isoTimeAgo } from "@/lib/utils";
|
||||||
import { Badge } from "@nous-research/ui/ui/components/badge";
|
import { Badge } from "@nous-research/ui/ui/components/badge";
|
||||||
import { Button } from "@nous-research/ui/ui/components/button";
|
import { Button } from "@nous-research/ui/ui/components/button";
|
||||||
@ -88,15 +88,18 @@ export function getRegisteredCount(): number {
|
|||||||
// Expose SDK + registry on window
|
// Expose SDK + registry on window
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
declare global {
|
/**
|
||||||
interface Window {
|
* Version of the plugin SDK contract (see ``plugins/sdk.d.ts``). Bump the
|
||||||
__HERMES_PLUGIN_SDK__: unknown;
|
* major on any backwards-incompatible change to the exposed surface;
|
||||||
__HERMES_PLUGINS__: {
|
* additive changes (new optional fields / helpers) don't require a bump.
|
||||||
register: typeof registerPlugin;
|
* Exposed at runtime as ``window.__HERMES_PLUGIN_SDK__.sdkVersion`` so a
|
||||||
registerSlot: typeof registerSlot;
|
* plugin (or a future host-side compatibility gate) can read it.
|
||||||
};
|
*/
|
||||||
}
|
export const SDK_CONTRACT_VERSION = "1.1.0";
|
||||||
}
|
|
||||||
|
// Window globals for the plugin SDK are declared in ``plugins/sdk.d.ts`` —
|
||||||
|
// the single source of truth for the public contract. Don't redeclare them
|
||||||
|
// here (duplicate ambient declarations with differing modifiers conflict).
|
||||||
|
|
||||||
export function exposePluginSDK() {
|
export function exposePluginSDK() {
|
||||||
window.__HERMES_PLUGINS__ = {
|
window.__HERMES_PLUGINS__ = {
|
||||||
@ -105,6 +108,9 @@ export function exposePluginSDK() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
window.__HERMES_PLUGIN_SDK__ = {
|
window.__HERMES_PLUGIN_SDK__ = {
|
||||||
|
// Contract version of the plugin SDK surface (see plugins/sdk.d.ts).
|
||||||
|
// Bump on backwards-incompatible changes; additive changes don't need it.
|
||||||
|
sdkVersion: SDK_CONTRACT_VERSION,
|
||||||
// React core — plugins use these instead of importing react
|
// React core — plugins use these instead of importing react
|
||||||
React,
|
React,
|
||||||
hooks: {
|
hooks: {
|
||||||
@ -119,8 +125,19 @@ export function exposePluginSDK() {
|
|||||||
|
|
||||||
// Hermes API client
|
// Hermes API client
|
||||||
api,
|
api,
|
||||||
// Raw fetchJSON for plugin-specific endpoints
|
// Raw fetchJSON for plugin-specific JSON endpoints
|
||||||
fetchJSON,
|
fetchJSON,
|
||||||
|
// Authenticated fetch for non-JSON endpoints (uploads / blob downloads).
|
||||||
|
// Handles loopback-token vs gated-cookie auth so plugins never read
|
||||||
|
// window.__HERMES_SESSION_TOKEN__ directly.
|
||||||
|
authedFetch,
|
||||||
|
// Build a ws(s):// URL with the correct auth param for the active mode
|
||||||
|
// (single-use ticket in gated mode, token in loopback). Use this for any
|
||||||
|
// plugin WebSocket instead of hand-assembling the URL.
|
||||||
|
buildWsUrl,
|
||||||
|
// Lower-level: resolve just the [authParamName, authParamValue] pair, for
|
||||||
|
// plugins that need to build the WS URL themselves.
|
||||||
|
buildWsAuthParam,
|
||||||
|
|
||||||
// UI components — Nous DS where available, shadcn/ui primitives elsewhere.
|
// UI components — Nous DS where available, shadcn/ui primitives elsewhere.
|
||||||
components: {
|
components: {
|
||||||
|
|||||||
160
web/src/plugins/sdk.d.ts
vendored
Normal file
160
web/src/plugins/sdk.d.ts
vendored
Normal file
@ -0,0 +1,160 @@
|
|||||||
|
/**
|
||||||
|
* Hermes Dashboard Plugin SDK — typed contract (SPIKE)
|
||||||
|
* ====================================================
|
||||||
|
*
|
||||||
|
* This is the public type surface for ``window.__HERMES_PLUGIN_SDK__`` and
|
||||||
|
* ``window.__HERMES_PLUGINS__``, the globals the dashboard host exposes to
|
||||||
|
* plugin bundles (see ``web/src/plugins/registry.ts::exposePluginSDK``).
|
||||||
|
*
|
||||||
|
* STATUS: spike. This file documents the contract and gives plugin authors
|
||||||
|
* (in-repo IIFEs and external bundles alike) editor types without bundling
|
||||||
|
* their own copies of React / the API client. It is intentionally a
|
||||||
|
* hand-authored ambient declaration rather than ``typeof
|
||||||
|
* window.__HERMES_PLUGIN_SDK__`` because:
|
||||||
|
* 1. The runtime object is assembled from many internal modules
|
||||||
|
* (``@/lib/api``, ``@nous-research/ui``, …). Deriving the type would
|
||||||
|
* leak those internal import paths into the public contract and couple
|
||||||
|
* external plugins to the host's internal module layout.
|
||||||
|
* 2. A hand-authored contract is the *versioned API boundary* — changing
|
||||||
|
* it is a deliberate act, visible in review, not an accidental
|
||||||
|
* consequence of refactoring an internal helper.
|
||||||
|
*
|
||||||
|
* Versioning: bump ``HermesPluginSDK["sdkVersion"]`` (and the
|
||||||
|
* ``SDK_CONTRACT_VERSION`` const the host exposes) on any
|
||||||
|
* backwards-incompatible change to this surface. Additive changes
|
||||||
|
* (new optional fields, new helpers) don't require a major bump.
|
||||||
|
*
|
||||||
|
* OPEN QUESTIONS for productionising this spike (do not block the auth fix):
|
||||||
|
* - Ship as a published ``@hermes/dashboard-plugin-sdk`` types package, or
|
||||||
|
* keep in-repo and copy into external plugin repos?
|
||||||
|
* - Should the host assert at runtime that a plugin's declared
|
||||||
|
* ``manifest.sdk_version`` is compatible before executing it?
|
||||||
|
* - The ``components`` map is typed loosely as ``Record<string,
|
||||||
|
* ComponentType>`` here; do we want exact per-component prop types
|
||||||
|
* (pulls @nous-research/ui types into the contract) or is the loose
|
||||||
|
* shape the right boundary for external authors?
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { ComponentType } from "react";
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Auth-relevant helpers (the surface this PR adds/sanctions)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* JSON ``fetch`` for dashboard ``/api/...`` endpoints. Handles auth in both
|
||||||
|
* modes (loopback session-token header / gated cookie), throws
|
||||||
|
* ``Error("<status>: <body>")`` on non-2xx, and triggers the global
|
||||||
|
* 401 → /login redirect in gated mode. Use for all JSON plugin endpoints.
|
||||||
|
*/
|
||||||
|
export type FetchJSON = <T = unknown>(
|
||||||
|
url: string,
|
||||||
|
init?: RequestInit,
|
||||||
|
options?: { allowUnauthorized?: boolean },
|
||||||
|
) => Promise<T>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Authenticated ``fetch`` for NON-JSON endpoints (uploads via ``FormData``,
|
||||||
|
* binary/blob downloads). Same auth handling as ``fetchJSON`` but returns
|
||||||
|
* the raw ``Response``, does not parse, does not throw on non-2xx, and does
|
||||||
|
* not run the 401 redirect. Plugins MUST use this (or ``fetchJSON``) instead
|
||||||
|
* of calling ``fetch`` with a hand-read ``window.__HERMES_SESSION_TOKEN__``.
|
||||||
|
*/
|
||||||
|
export type AuthedFetch = (url: string, init?: RequestInit) => Promise<Response>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build an absolute ``ws(s)://`` URL for a dashboard WebSocket endpoint with
|
||||||
|
* the correct auth query param for the active mode (single-use ``ticket`` in
|
||||||
|
* gated OAuth mode, ``token`` in loopback). Plugins MUST use this for any
|
||||||
|
* WebSocket instead of hand-assembling the URL + reading the session token.
|
||||||
|
*/
|
||||||
|
export type BuildWsUrl = (
|
||||||
|
path: string,
|
||||||
|
params?: Record<string, string>,
|
||||||
|
) => Promise<string>;
|
||||||
|
|
||||||
|
/** Lower-level: just the ``[authParamName, authParamValue]`` pair. */
|
||||||
|
export type BuildWsAuthParam = () => Promise<[string, string]>;
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Registry surface (window.__HERMES_PLUGINS__)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export interface PluginRegistry {
|
||||||
|
/** Register the plugin's main tab component by manifest name. */
|
||||||
|
register(name: string, component: ComponentType<Record<string, never>>): void;
|
||||||
|
/** Register a component into a named host slot. */
|
||||||
|
registerSlot(slot: string, name: string, component: ComponentType): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// SDK surface (window.__HERMES_PLUGIN_SDK__)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export interface HermesPluginSDK {
|
||||||
|
/** Contract version of this SDK surface (see SDK_CONTRACT_VERSION). */
|
||||||
|
readonly sdkVersion: string;
|
||||||
|
|
||||||
|
/** React core — use instead of importing/bundling react. */
|
||||||
|
React: typeof import("react").default;
|
||||||
|
hooks: {
|
||||||
|
useState: typeof import("react").useState;
|
||||||
|
useEffect: typeof import("react").useEffect;
|
||||||
|
useCallback: typeof import("react").useCallback;
|
||||||
|
useMemo: typeof import("react").useMemo;
|
||||||
|
useRef: typeof import("react").useRef;
|
||||||
|
useContext: typeof import("react").useContext;
|
||||||
|
createContext: typeof import("react").createContext;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Typed convenience client for core dashboard endpoints. Typed permissively
|
||||||
|
* at the boundary (methods vary in arity and return type — most return
|
||||||
|
* ``Promise<T>``, a few return a URL string synchronously); plugins call the
|
||||||
|
* specific methods they need. See ``web/src/lib/api.ts`` for the concrete shape.
|
||||||
|
*/
|
||||||
|
api: Record<string, (...args: never[]) => unknown>;
|
||||||
|
|
||||||
|
/** JSON fetch with host auth handling. */
|
||||||
|
fetchJSON: FetchJSON;
|
||||||
|
/** Authenticated raw fetch for uploads / blob downloads. */
|
||||||
|
authedFetch: AuthedFetch;
|
||||||
|
/** Build an auth'd WebSocket URL for the active mode. */
|
||||||
|
buildWsUrl: BuildWsUrl;
|
||||||
|
/** Resolve just the WS auth query-param pair. */
|
||||||
|
buildWsAuthParam: BuildWsAuthParam;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shared UI primitives (Nous DS / shadcn). Typed permissively at the
|
||||||
|
* boundary: the host's concrete components (some of which require props like
|
||||||
|
* ``active``/``value``/``name``) must be assignable here, and external plugin
|
||||||
|
* authors render them dynamically without the host's internal prop types.
|
||||||
|
* ``ComponentType<never>`` accepts any component regardless of its prop
|
||||||
|
* requirements (props are contravariant).
|
||||||
|
*/
|
||||||
|
components: Record<string, ComponentType<never>>;
|
||||||
|
|
||||||
|
utils: {
|
||||||
|
cn: (...classes: Array<string | false | null | undefined>) => string;
|
||||||
|
/** Relative-time formatter. Accepts an epoch-ms number. */
|
||||||
|
timeAgo: (ts: number) => string;
|
||||||
|
/** Relative-time formatter for an ISO-8601 string. */
|
||||||
|
isoTimeAgo: (iso: string) => string;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* i18n hook. Returns the host's i18n context value; typed loosely at the
|
||||||
|
* boundary so the contract doesn't couple to the host's internal
|
||||||
|
* ``I18nContextValue`` shape. Plugins typically call ``useI18n().t(...)``.
|
||||||
|
*/
|
||||||
|
useI18n: () => unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
interface Window {
|
||||||
|
__HERMES_PLUGIN_SDK__?: HermesPluginSDK;
|
||||||
|
__HERMES_PLUGINS__?: PluginRegistry;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export {};
|
||||||
Reference in New Issue
Block a user