Skip to content

Commit 49eb167

Browse files
committed
feat: harden dashboard server api
1 parent ef19f9a commit 49eb167

8 files changed

Lines changed: 197 additions & 13 deletions

File tree

README.md

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -203,9 +203,12 @@ Serve the dashboard with live aggregate refresh and lazy raw-context loading:
203203

204204
```bash
205205
codex-usage-tracker serve-dashboard --open
206+
codex-usage-tracker serve-dashboard --no-context-api --open
206207
```
207208

208-
When served this way, the dashboard gets a `Refresh` button plus a `Live` toggle that polls the localhost `/api/usage` endpoint every 10 seconds while the tab is visible. Each poll refreshes the SQLite aggregate index from local Codex logs and replaces the in-memory dashboard rows without embedding raw transcript content. Use the `Load` selector to fetch 5,000, 10,000, 20,000, or all aggregate calls; `--limit 0` also means all calls for CLI-generated dashboards. The table renders 500 rows or thread groups per page so larger histories remain responsive. Each call detail panel also gets a `Load context` action. Pressing it fetches only that call's logged turn context from the original local JSONL source. Tool output is omitted by default; the `Include tool output` action loads redacted, size-limited tool output for that call. None of this raw context is written to SQLite, CSV, or the generated HTML.
209+
When served this way, the dashboard gets a `Refresh` button plus a `Live` toggle that polls the localhost `/api/usage` endpoint every 10 seconds while the tab is visible. Refresh calls and `/api/context` require a random per-server token embedded in that generated dashboard, and the server rejects non-loopback `Host` or cross-origin `Origin` headers. Each poll refreshes the SQLite aggregate index from local Codex logs and replaces the in-memory dashboard rows without embedding raw transcript content. Use the `Load` selector to fetch 5,000, 10,000, 20,000, or all aggregate calls; `--limit 0` also means all calls for CLI-generated dashboards. The table renders 500 rows or thread groups per page so larger histories remain responsive. Each call detail panel also gets a `Load context` action when the context API is enabled. Pressing it fetches only that call's logged turn context from the original local JSONL source. Tool output is omitted by default; the `Include tool output` action loads redacted, size-limited tool output for that call. None of this raw context is written to SQLite, CSV, or the generated HTML.
210+
211+
`serve-dashboard --context-api explicit` is the default and keeps context loading as an explicit per-row action. `serve-dashboard --no-context-api` or `--context-api disabled` serves live aggregate refresh while disabling `/api/context` entirely.
209212

210213
Dashboard behavior:
211214

@@ -370,6 +373,8 @@ The SQLite database is stored at `~/.codex-usage-tracker/usage.sqlite3` by defau
370373

371374
Raw chat text and tool outputs are ignored by the parser and are never written to the tracker database, CSV exports, or generated dashboard HTML. `usage_call_context`, `codex-usage-tracker context`, and the `serve-dashboard` context endpoint read a single source JSONL file only when explicitly requested, redact common secret patterns, and cap returned text size.
372375

376+
The localhost server binds only to loopback hosts, validates loopback `Host` and `Origin` headers, protects refresh/context API calls with a random per-server token, and can disable the context API entirely with `--no-context-api`.
377+
373378
For MCP users, `usage_call_context` is additionally disabled unless the MCP server process has `CODEX_USAGE_TRACKER_ALLOW_RAW_CONTEXT=1` in its environment. Aggregate MCP tools do not require that opt-in.
374379

375380
Cost estimates are calculated only from aggregate token fields and your local pricing config. They are omitted when no matching model price is configured. Pricing refreshes pull only OpenAI's public pricing markdown and do not send local usage data anywhere.

docs/dashboard-guide.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,8 @@ codex-usage-tracker dashboard --open
3333

3434
Static file mode can still filter, sort, and inspect aggregate call fields. It cannot refresh from logs or load raw context until you open the dashboard through `serve-dashboard`.
3535

36+
The localhost server uses a random per-server token for refresh and context API calls, validates loopback `Host` and `Origin` headers, and can run as aggregate-only with `codex-usage-tracker serve-dashboard --no-context-api`.
37+
3638
## Insights View
3739

3840
![Insights view with ranked attention cards, investigation presets, and top threads by attention score.](assets/dashboard-insights.png)
@@ -114,6 +116,7 @@ When served from localhost, the details panel includes `Load context` and `Inclu
114116
- `Load context` fetches a size-limited, redacted context excerpt for only that call.
115117
- `Include tool output` repeats the request with tool output included, still redacted and capped.
116118
- Raw context is not written to SQLite, CSV, or the generated dashboard HTML.
119+
- If the server was started with `--no-context-api`, the context buttons stay disabled and the dashboard remains aggregate-only.
117120

118121
## Practical Workflow
119122

src/codex_usage_tracker/cli.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -302,6 +302,17 @@ def _add_dashboard_parsers(
302302
serve.add_argument("--host", default="127.0.0.1")
303303
serve.add_argument("--port", type=int, default=8765)
304304
serve.add_argument("--context-chars", type=int, default=DEFAULT_CONTEXT_CHARS)
305+
serve.add_argument(
306+
"--context-api",
307+
choices=["explicit", "disabled"],
308+
default="explicit",
309+
help="Enable explicit per-row context loading or disable the context API.",
310+
)
311+
serve.add_argument(
312+
"--no-context-api",
313+
action="store_true",
314+
help="Serve aggregate dashboard refresh only and disable /api/context.",
315+
)
305316
serve.add_argument("--open", action="store_true")
306317
serve.add_argument(
307318
"--refresh",
@@ -656,6 +667,7 @@ def _run_serve_dashboard(args: argparse.Namespace) -> int:
656667
open_browser=args.open,
657668
codex_home=args.codex_home,
658669
include_archived=args.include_archived,
670+
context_api="disabled" if args.no_context_api else args.context_api,
659671
)
660672
return 0
661673

src/codex_usage_tracker/dashboard.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,8 @@ def dashboard_payload(
3636
pricing_path: Path = DEFAULT_PRICING_PATH,
3737
allowance_path: Path = DEFAULT_ALLOWANCE_PATH,
3838
since: str | None = None,
39+
api_token: str | None = None,
40+
context_api_enabled: bool = False,
3941
) -> dict[str, object]:
4042
"""Return aggregate-only dashboard data without rendering HTML."""
4143

@@ -70,6 +72,8 @@ def dashboard_payload(
7072
"limit_label": "All" if normalized_limit is None else str(normalized_limit),
7173
"parser_diagnostics": parser_diagnostics,
7274
"parser_adapter": metadata.get("parser_adapter"),
75+
"api_token": api_token or "",
76+
"context_api_enabled": context_api_enabled,
7377
}
7478

7579

@@ -80,6 +84,8 @@ def generate_dashboard(
8084
pricing_path: Path = DEFAULT_PRICING_PATH,
8185
allowance_path: Path = DEFAULT_ALLOWANCE_PATH,
8286
since: str | None = None,
87+
api_token: str | None = None,
88+
context_api_enabled: bool = False,
8389
) -> Path:
8490
output_path.parent.mkdir(parents=True, exist_ok=True)
8591
guide_href = _dashboard_guide_href(output_path)
@@ -93,6 +99,8 @@ def generate_dashboard(
9399
pricing_path=pricing_path,
94100
allowance_path=allowance_path,
95101
since=since,
102+
api_token=api_token,
103+
context_api_enabled=context_api_enabled,
96104
),
97105
ensure_ascii=True,
98106
).replace("</", "<\\/")

src/codex_usage_tracker/plugin_data/dashboard/dashboard.js

Lines changed: 17 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@ const initialPayload = JSON.parse(document.getElementById('usage-data').textCont
88
let allowanceWindows = Array.isArray(initialPayload.allowance_windows) ? initialPayload.allowance_windows : [];
99
let allowanceError = initialPayload.allowance_error || '';
1010
let parserDiagnostics = initialPayload.parser_diagnostics || {};
11+
let apiToken = initialPayload.api_token || '';
12+
let contextApiEnabled = Boolean(initialPayload.context_api_enabled);
1113
let totalAvailableRows = Number(initialPayload.total_available_rows || data.length);
1214
let loadedLimit = payloadLimit(initialPayload);
1315
const rowsEl = document.getElementById('rows');
@@ -1126,10 +1128,13 @@ const initialPayload = JSON.parse(document.getElementById('usage-data').textCont
11261128
}
11271129
function contextControls(row) {
11281130
const fileMode = window.location.protocol === 'file:';
1129-
const disabled = fileMode ? ' disabled' : '';
1131+
const apiUnavailable = !contextApiEnabled || !apiToken;
1132+
const disabled = fileMode || apiUnavailable ? ' disabled' : '';
11301133
const hint = fileMode
11311134
? 'Open this dashboard with codex-usage-tracker serve-dashboard to load raw context on demand.'
1132-
: 'Context is not embedded in this dashboard. Press a button to read this call from the local JSONL source.';
1135+
: apiUnavailable
1136+
? 'Context loading is disabled for this dashboard server. Restart with --context-api explicit to enable explicit row actions.'
1137+
: 'Context is not embedded in this dashboard. Press a button to read this call from the local JSONL source.';
11331138
return `
11341139
<div class="context-actions">
11351140
<button class="context-button" type="button" data-context-load${disabled}>Load context</button>
@@ -1156,7 +1161,10 @@ const initialPayload = JSON.parse(document.getElementById('usage-data').textCont
11561161
if (includeToolOutput) params.set('include_tool_output', '1');
11571162
try {
11581163
const response = await fetch(`/api/context?${params.toString()}`, {
1159-
headers: { 'Accept': 'application/json' },
1164+
headers: {
1165+
'Accept': 'application/json',
1166+
'X-Codex-Usage-Token': apiToken,
1167+
},
11601168
cache: 'no-store',
11611169
});
11621170
if (!response.ok) {
@@ -1374,6 +1382,8 @@ const initialPayload = JSON.parse(document.getElementById('usage-data').textCont
13741382
allowanceWindows = Array.isArray(nextPayload.allowance_windows) ? nextPayload.allowance_windows : [];
13751383
allowanceError = nextPayload.allowance_error || '';
13761384
parserDiagnostics = nextPayload.parser_diagnostics || {};
1385+
apiToken = nextPayload.api_token || apiToken;
1386+
contextApiEnabled = Boolean(nextPayload.context_api_enabled);
13771387
totalAvailableRows = Number(nextPayload.total_available_rows || data.length);
13781388
loadedLimit = payloadLimit(nextPayload);
13791389
rebuildDashboardIndexes();
@@ -1397,7 +1407,10 @@ const initialPayload = JSON.parse(document.getElementById('usage-data').textCont
13971407
try {
13981408
const params = new URLSearchParams({ refresh: '1', limit: loadLimitEl.value, _: String(Date.now()) });
13991409
const response = await fetch(`/api/usage?${params.toString()}`, {
1400-
headers: { 'Accept': 'application/json' },
1410+
headers: {
1411+
'Accept': 'application/json',
1412+
'X-Codex-Usage-Token': apiToken,
1413+
},
14011414
cache: 'no-store',
14021415
});
14031416
if (!response.ok) {

src/codex_usage_tracker/plugin_data/docs/dashboard-guide.html

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,7 @@ <h2>Open The Dashboard</h2>
8383
codex-usage-tracker serve-dashboard --open</code></pre>
8484
<p>For optional allowance context, run <code>codex-usage-tracker init-allowance</code> and copy current 5-hour or weekly remaining usage from Codex Usage or <code>/status</code> into the local template.</p>
8585
<p>The server enables live aggregate refresh and on-demand context loading. Static file mode can still filter, sort, and inspect aggregate fields, but cannot refresh logs or load context.</p>
86+
<p>The localhost server uses a random per-server token for refresh and context API calls, validates loopback <code>Host</code> and <code>Origin</code> headers, and can run as aggregate-only with <code>codex-usage-tracker serve-dashboard --no-context-api</code>.</p>
8687

8788
<h2>Insights View</h2>
8889
<img src="assets/dashboard-insights.png" alt="Insights view with ranked attention cards, investigation presets, and top threads by attention score.">
@@ -112,7 +113,7 @@ <h2>Threads View</h2>
112113

113114
<h2>Details And Context</h2>
114115
<img src="assets/dashboard-details.png" alt="Details panel showing aggregate usage fields for a selected call.">
115-
<p>The details panel shows primary cost, Codex credits, allowance impact, cache, context, pricing, and next-action signals first. It then groups thread narrative, token/pricing breakdowns, collapsed raw identifiers, and source metadata. When served from localhost, <code>Load context</code> fetches one redacted, size-limited source excerpt on demand.</p>
116+
<p>The details panel shows primary cost, Codex credits, allowance impact, cache, context, pricing, and next-action signals first. It then groups thread narrative, token/pricing breakdowns, collapsed raw identifiers, and source metadata. When served from localhost with the context API enabled, <code>Load context</code> fetches one redacted, size-limited source excerpt on demand. When started with <code>--no-context-api</code>, context buttons stay disabled and the dashboard remains aggregate-only.</p>
116117

117118
<h2>Investigating Long Chat Growth</h2>
118119
<p class="note">Prompt caching helps, but cached input is not free. Long-running chats can carry a large cached prefix into later turns, so usage can climb quickly even when the visible request looks small.</p>

src/codex_usage_tracker/server.py

Lines changed: 84 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@
33
from __future__ import annotations
44

55
import json
6+
import hmac
7+
import secrets
68
import sqlite3
79
import threading
810
import webbrowser
@@ -38,17 +40,23 @@ def serve_dashboard(
3840
open_browser: bool = False,
3941
codex_home: Path = DEFAULT_CODEX_HOME,
4042
include_archived: bool = False,
43+
context_api: str = "explicit",
4144
) -> None:
4245
"""Generate and serve the dashboard plus a localhost-only context endpoint."""
4346

4447
_validate_loopback_host(host)
48+
_validate_context_api_mode(context_api)
49+
api_token = secrets.token_urlsafe(32)
50+
context_api_enabled = context_api != "disabled"
4551
output = generate_dashboard(
4652
db_path=db_path,
4753
output_path=output_path,
4854
limit=limit,
4955
pricing_path=pricing_path,
5056
allowance_path=allowance_path,
5157
since=since,
58+
api_token=api_token,
59+
context_api_enabled=context_api_enabled,
5260
)
5361
handler = partial(
5462
_UsageDashboardHandler,
@@ -62,12 +70,16 @@ def serve_dashboard(
6270
include_archived=include_archived,
6371
dashboard_name=output.name,
6472
context_chars=context_chars,
73+
api_token=api_token,
74+
context_api_enabled=context_api_enabled,
6575
refresh_lock=threading.Lock(),
6676
)
6777
server = ThreadingHTTPServer((host, port), handler)
6878
url = f"http://{_url_host(host)}:{port}/{output.name}"
6979
print(f"Serving Codex usage dashboard at {url}")
70-
print("Aggregate rows refresh through /api/usage; raw context is loaded only through /api/context after a row action.")
80+
context_mode = "enabled for explicit row actions" if context_api_enabled else "disabled"
81+
print("Aggregate rows refresh through /api/usage with a per-server token.")
82+
print(f"Raw context API is {context_mode}; context is never embedded in the dashboard HTML.")
7183
if open_browser:
7284
webbrowser.open(url)
7385
try:
@@ -91,6 +103,8 @@ def __init__(
91103
include_archived: bool,
92104
dashboard_name: str,
93105
context_chars: int,
106+
api_token: str,
107+
context_api_enabled: bool,
94108
refresh_lock: threading.Lock,
95109
**kwargs: object,
96110
) -> None:
@@ -103,11 +117,16 @@ def __init__(
103117
self._include_archived = include_archived
104118
self._dashboard_name = dashboard_name
105119
self._context_chars = context_chars
120+
self._api_token = api_token
121+
self._context_api_enabled = context_api_enabled
106122
self._refresh_lock = refresh_lock
107123
super().__init__(*args, **kwargs)
108124

109125
def do_GET(self) -> None: # noqa: N802 - stdlib hook name
110126
parsed = urlparse(self.path)
127+
if not self._request_origin_allowed():
128+
self._send_json(HTTPStatus.FORBIDDEN, {"error": "Request host or origin is not allowed"})
129+
return
111130
if parsed.path == "/api/context":
112131
self._handle_context(parsed.query)
113132
return
@@ -123,8 +142,8 @@ def end_headers(self) -> None:
123142
self.send_header("Referrer-Policy", "no-referrer")
124143
self.send_header(
125144
"Content-Security-Policy",
126-
"default-src 'self'; script-src 'self' 'unsafe-inline'; "
127-
"style-src 'self' 'unsafe-inline'; connect-src 'self'; "
145+
"default-src 'self'; script-src 'self'; "
146+
"style-src 'self'; connect-src 'self'; "
128147
"img-src 'self' data:; object-src 'none'; base-uri 'none'",
129148
)
130149
super().end_headers()
@@ -136,6 +155,15 @@ def log_message(self, format: str, *args: object) -> None:
136155

137156
def _handle_context(self, query: str) -> None:
138157
params = parse_qs(query)
158+
if not self._context_api_enabled:
159+
self._send_json(
160+
HTTPStatus.FORBIDDEN,
161+
{"error": "Context API is disabled for this dashboard server."},
162+
)
163+
return
164+
if not self._has_valid_api_token(params):
165+
self._send_json(HTTPStatus.FORBIDDEN, {"error": "Valid API token is required"})
166+
return
139167
record_id = _first(params.get("record_id"))
140168
if not record_id:
141169
self._send_json(
@@ -177,6 +205,12 @@ def _handle_usage(self, query: str) -> None:
177205
refresh_result = None
178206
try:
179207
if _truthy(_first(params.get("refresh"))):
208+
if not self._has_valid_api_token(params):
209+
self._send_json(
210+
HTTPStatus.FORBIDDEN,
211+
{"error": "Valid API token is required for refresh"},
212+
)
213+
return
180214
with self._refresh_lock:
181215
result = refresh_usage_index(
182216
codex_home=self._codex_home,
@@ -197,6 +231,8 @@ def _handle_usage(self, query: str) -> None:
197231
pricing_path=self._pricing_path,
198232
allowance_path=self._allowance_path,
199233
since=self._since,
234+
api_token=self._api_token,
235+
context_api_enabled=self._context_api_enabled,
200236
)
201237
except sqlite3.Error as exc:
202238
self._send_json(
@@ -214,6 +250,25 @@ def _handle_usage(self, query: str) -> None:
214250
payload["refresh_result"] = refresh_result
215251
self._send_json(HTTPStatus.OK, payload)
216252

253+
def _request_origin_allowed(self) -> bool:
254+
if not _allowed_loopback_host(_host_header_name(self.headers.get("Host"))):
255+
return False
256+
origin = self.headers.get("Origin")
257+
if not origin:
258+
return True
259+
parsed = urlparse(origin)
260+
if parsed.scheme not in {"http", "https"}:
261+
return False
262+
if not _allowed_loopback_host(parsed.hostname):
263+
return False
264+
if parsed.port is not None and parsed.port != self.server.server_port:
265+
return False
266+
return True
267+
268+
def _has_valid_api_token(self, params: dict[str, list[str]]) -> bool:
269+
provided = self.headers.get("X-Codex-Usage-Token") or _first(params.get("api_token")) or ""
270+
return hmac.compare_digest(str(provided), self._api_token)
271+
217272
def _send_json(self, status: HTTPStatus, payload: dict[str, object]) -> None:
218273
body = json.dumps(payload, ensure_ascii=True).encode("utf-8")
219274
self.send_response(status)
@@ -263,5 +318,31 @@ def _validate_loopback_host(host: str) -> None:
263318
raise ValueError("serve-dashboard refuses to expose raw context off localhost")
264319

265320

321+
def _validate_context_api_mode(mode: str) -> None:
322+
if mode not in {"explicit", "disabled"}:
323+
raise ValueError("--context-api must be explicit or disabled")
324+
325+
326+
def _allowed_loopback_host(host: str | None) -> bool:
327+
if not host:
328+
return False
329+
if host == "localhost":
330+
return True
331+
try:
332+
return ip_address(host).is_loopback
333+
except ValueError:
334+
return False
335+
336+
337+
def _host_header_name(value: str | None) -> str | None:
338+
if not value:
339+
return None
340+
host = value.strip()
341+
if host.startswith("["):
342+
end = host.find("]")
343+
return host[1:end] if end > 0 else None
344+
return host.split(":", 1)[0]
345+
346+
266347
def _url_host(host: str) -> str:
267348
return f"[{host}]" if ":" in host and not host.startswith("[") else host

0 commit comments

Comments
 (0)