openclaw-smartmeter 0.5.3 → 0.5.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -5,6 +5,17 @@ All notable changes to this project will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [0.5.4] - 2025-07-14
9
+
10
+ ### Fixed
11
+
12
+ - **Multi-user key isolation (defense in depth)**: Completely prevents API key contamination between macOS users on shared machines
13
+ - **Session token authentication**: Each `smartmeter analyze` invocation generates a unique cryptographic token; the API server rejects requests without a matching token, making cross-user API calls impossible
14
+ - **CORS origin locking**: API server restricts CORS to the exact dashboard origin (e.g., `http://localhost:8081`) instead of wildcard `*`
15
+ - **Cache-busting headers**: All served files include `Cache-Control: no-store` to prevent stale dashboard pages from being cached
16
+ - **Fixed `cmdServe` missing apiPort**: The `smartmeter serve` command now correctly passes the API port to the static server (was defaulting to 3001)
17
+ - All API calls from the dashboard now go through an authenticated `apiFetch()` wrapper that attaches the session token header
18
+
8
19
  ## [0.3.0] - 2026-02-11
9
20
 
10
21
  ### Added
@@ -6,6 +6,21 @@
6
6
  /* ─── Globals ─── */
7
7
  const API_BASE_URL = `http://localhost:${window.__SMARTMETER_API_PORT || 3001}`;
8
8
  const STORAGE_PREFIX = window.__SMARTMETER_USER ? `smartmeter_${window.__SMARTMETER_USER}_` : 'smartmeter_';
9
+ const SESSION_TOKEN = window.__SMARTMETER_TOKEN || '';
10
+
11
+ /**
12
+ * Authenticated fetch wrapper — attaches the session token so the API server
13
+ * can verify that this dashboard belongs to the same SmartMeter session.
14
+ * Prevents cross-user contamination on shared machines.
15
+ */
16
+ function apiFetch(url, opts = {}) {
17
+ const headers = { ...(opts.headers || {}) };
18
+ if (SESSION_TOKEN) {
19
+ headers['X-SmartMeter-Token'] = SESSION_TOKEN;
20
+ }
21
+ return fetch(url, { ...opts, headers });
22
+ }
23
+
9
24
  let analysisData = null;
10
25
  let modelChart = null;
11
26
  let taskChart = null;
@@ -38,7 +53,7 @@ async function initializeDashboard() {
38
53
  async function loadAnalysisData() {
39
54
  try {
40
55
  // First try the API
41
- const res = await fetch(`${API_BASE_URL}/api/status`, { signal: AbortSignal.timeout(3000) });
56
+ const res = await apiFetch(`${API_BASE_URL}/api/status`, { signal: AbortSignal.timeout(3000) });
42
57
  if (res.ok) {
43
58
  const json = await res.json();
44
59
  if (json.success && json.analysis) {
@@ -654,7 +669,7 @@ function resetBudgetDefaults() {
654
669
 
655
670
  async function applyBudgetControls() {
656
671
  try {
657
- const res = await fetch(`${API_BASE_URL}/api/apply`, {
672
+ const res = await apiFetch(`${API_BASE_URL}/api/apply`, {
658
673
  method: 'POST',
659
674
  headers: { 'Content-Type': 'application/json' },
660
675
  body: JSON.stringify({
@@ -772,7 +787,7 @@ async function applySelectedRecommendations() {
772
787
  if (!confirmed) return;
773
788
 
774
789
  try {
775
- const res = await fetch(`${API_BASE_URL}/api/apply`, {
790
+ const res = await apiFetch(`${API_BASE_URL}/api/apply`, {
776
791
  method: 'POST',
777
792
  headers: { 'Content-Type': 'application/json' },
778
793
  body: JSON.stringify({ confirm: true })
@@ -857,7 +872,7 @@ async function validateInlineApiKey() {
857
872
 
858
873
  // Try API server first
859
874
  try {
860
- const res = await fetch(`${API_BASE_URL}/api/config/openrouter-key`, {
875
+ const res = await apiFetch(`${API_BASE_URL}/api/config/openrouter-key`, {
861
876
  method: 'POST',
862
877
  headers: { 'Content-Type': 'application/json' },
863
878
  body: JSON.stringify({ apiKey: key })
@@ -867,7 +882,7 @@ async function validateInlineApiKey() {
867
882
  validated = true;
868
883
  // Fetch usage via API server
869
884
  try {
870
- const ur = await fetch(`${API_BASE_URL}/api/openrouter-usage`);
885
+ const ur = await apiFetch(`${API_BASE_URL}/api/openrouter-usage`);
871
886
  const uj = await ur.json();
872
887
  if (uj.success && uj.configured) usageData = uj.data || uj;
873
888
  } catch {}
@@ -970,7 +985,7 @@ async function initGetStartedCard() {
970
985
  // Try to fetch balance
971
986
  let usageData = null;
972
987
  try {
973
- const res = await fetch(`${API_BASE_URL}/api/openrouter-usage`);
988
+ const res = await apiFetch(`${API_BASE_URL}/api/openrouter-usage`);
974
989
  const json = await res.json();
975
990
  if (json.success && json.configured) {
976
991
  usageData = json.data || json;
@@ -1191,7 +1206,7 @@ async function confirmApplyRecommendations() {
1191
1206
  if (btn) { btn.disabled = true; btn.textContent = 'Applying…'; }
1192
1207
 
1193
1208
  try {
1194
- const res = await fetch(`${API_BASE_URL}/api/apply`, {
1209
+ const res = await apiFetch(`${API_BASE_URL}/api/apply`, {
1195
1210
  method: 'POST',
1196
1211
  headers: { 'Content-Type': 'application/json' },
1197
1212
  body: JSON.stringify({ confirm: true })
@@ -1218,7 +1233,7 @@ async function confirmApplyRecommendations() {
1218
1233
  /* ─── OpenRouter Integration ─── */
1219
1234
  async function checkOpenRouterConfig() {
1220
1235
  try {
1221
- const res = await fetch(`${API_BASE_URL}/api/config/openrouter-key`);
1236
+ const res = await apiFetch(`${API_BASE_URL}/api/config/openrouter-key`);
1222
1237
  if (!res.ok) return;
1223
1238
  const json = await res.json();
1224
1239
  if (json.configured) {
@@ -1232,7 +1247,7 @@ async function checkOpenRouterConfig() {
1232
1247
  async function fetchOpenRouterUsage() {
1233
1248
  const container = document.getElementById('openRouterContent');
1234
1249
  try {
1235
- const res = await fetch(`${API_BASE_URL}/api/openrouter-usage`);
1250
+ const res = await apiFetch(`${API_BASE_URL}/api/openrouter-usage`);
1236
1251
  if (!res.ok) return;
1237
1252
  const json = await res.json();
1238
1253
  if (json.success && json.configured) {
@@ -1313,7 +1328,7 @@ async function saveApiKey() {
1313
1328
 
1314
1329
  // Try API server first
1315
1330
  try {
1316
- const res = await fetch(`${API_BASE_URL}/api/config/openrouter-key`, {
1331
+ const res = await apiFetch(`${API_BASE_URL}/api/config/openrouter-key`, {
1317
1332
  method: 'POST',
1318
1333
  headers: { 'Content-Type': 'application/json' },
1319
1334
  body: JSON.stringify({ apiKey: key })
@@ -1361,7 +1376,7 @@ async function saveApiKey() {
1361
1376
  /* ─── Preview Modal ─── */
1362
1377
  async function viewConfig() {
1363
1378
  try {
1364
- const res = await fetch(`${API_BASE_URL}/api/preview`);
1379
+ const res = await apiFetch(`${API_BASE_URL}/api/preview`);
1365
1380
  const json = await res.json();
1366
1381
  if (json.success) {
1367
1382
  document.getElementById('previewConfigCode').textContent = JSON.stringify(json.config, null, 2);
@@ -1381,7 +1396,7 @@ async function applyOptimizations() {
1381
1396
  const confirmed = confirm('Apply all optimizations? A backup will be created.');
1382
1397
  if (!confirmed) return;
1383
1398
  try {
1384
- const res = await fetch(`${API_BASE_URL}/api/apply`, {
1399
+ const res = await apiFetch(`${API_BASE_URL}/api/apply`, {
1385
1400
  method: 'POST',
1386
1401
  headers: { 'Content-Type': 'application/json' },
1387
1402
  body: JSON.stringify({ confirm: true })
@@ -1401,7 +1416,7 @@ async function applyOptimizations() {
1401
1416
  /* ─── Export ─── */
1402
1417
  async function exportReport() {
1403
1418
  try {
1404
- const res = await fetch(`${API_BASE_URL}/api/export`);
1419
+ const res = await apiFetch(`${API_BASE_URL}/api/export`);
1405
1420
  if (!res.ok) throw new Error('Export failed');
1406
1421
  const blob = await res.blob();
1407
1422
  const url = URL.createObjectURL(blob);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "openclaw-smartmeter",
3
- "version": "0.5.3",
3
+ "version": "0.5.4",
4
4
  "description": "AI cost optimization for OpenClaw - analyze usage and reduce costs by 48%",
5
5
  "main": "src/cli/index.js",
6
6
  "bin": {
@@ -54,4 +54,4 @@
54
54
  "fs-extra": "^11.2.0",
55
55
  "open": "^9.1.0"
56
56
  }
57
- }
57
+ }
@@ -61,6 +61,8 @@ export class ApiServer {
61
61
  this.port = opts.port || 3001;
62
62
  this.canvasDir = opts.canvasDir;
63
63
  this.server = null;
64
+ this.sessionToken = opts.sessionToken || '';
65
+ this.dashboardOrigin = opts.dashboardOrigin || null;
64
66
  }
65
67
 
66
68
  /**
@@ -68,13 +70,14 @@ export class ApiServer {
68
70
  */
69
71
  async start() {
70
72
  this.server = createServer(async (req, res) => {
71
- // Enable CORS for local development
72
- res.setHeader("Access-Control-Allow-Origin", "*");
73
+ // CORS restrict to the specific dashboard origin when available
74
+ const allowedOrigin = this.dashboardOrigin || '*';
75
+ res.setHeader("Access-Control-Allow-Origin", allowedOrigin);
73
76
  res.setHeader(
74
77
  "Access-Control-Allow-Methods",
75
78
  "GET, POST, PUT, DELETE, OPTIONS",
76
79
  );
77
- res.setHeader("Access-Control-Allow-Headers", "Content-Type");
80
+ res.setHeader("Access-Control-Allow-Headers", "Content-Type, X-SmartMeter-Token");
78
81
 
79
82
  if (req.method === "OPTIONS") {
80
83
  res.writeHead(200);
@@ -82,6 +85,16 @@ export class ApiServer {
82
85
  return;
83
86
  }
84
87
 
88
+ // Session-token authentication: reject requests from other users' dashboards
89
+ if (this.sessionToken) {
90
+ const reqToken = req.headers['x-smartmeter-token'] || '';
91
+ if (reqToken !== this.sessionToken) {
92
+ res.writeHead(403, { 'Content-Type': 'application/json' });
93
+ res.end(JSON.stringify({ success: false, error: 'Invalid session — this API server belongs to a different SmartMeter session.' }));
94
+ return;
95
+ }
96
+ }
97
+
85
98
  try {
86
99
  await this.handleRequest(req, res);
87
100
  } catch (error) {
@@ -1,6 +1,7 @@
1
1
  import { readFile, writeFile, readdir, copyFile, mkdir } from "node:fs/promises";
2
2
  import { join } from "node:path";
3
3
  import { homedir } from "node:os";
4
+ import { randomBytes } from "node:crypto";
4
5
 
5
6
  import { parseAllSessions } from "../analyzer/parser.js";
6
7
  import { classifyTasks } from "../analyzer/classifier.js";
@@ -87,16 +88,24 @@ export async function cmdAnalyze(opts = {}) {
87
88
  const port = opts.port || 8080;
88
89
  const apiPort = opts.apiPort || 3001;
89
90
 
91
+ // Generate a unique session token for this invocation.
92
+ // The API server rejects requests without a matching token,
93
+ // preventing cross-user contamination on shared machines.
94
+ const sessionToken = randomBytes(16).toString('hex');
95
+
90
96
  // Start API server in background
91
97
  console.log("✓ Starting API server...");
92
- const apiServer = await startApiServer({ port: apiPort });
98
+ const apiServer = await startApiServer({ port: apiPort, sessionToken });
93
99
  const actualApiPort = apiServer.server.address().port;
94
100
 
95
- // Start static file server in background (pass API port for injection)
101
+ // Start static file server in background (pass API port + token for injection)
96
102
  console.log("✓ Starting dashboard server...");
97
- const staticServer = await startStaticFileServer(deployer.canvasDir, port, { apiPort: actualApiPort });
103
+ const staticServer = await startStaticFileServer(deployer.canvasDir, port, { apiPort: actualApiPort, sessionToken });
98
104
  const actualPort = staticServer.port;
99
105
 
106
+ // Now that we know both ports, update the API server's CORS allowedOrigin
107
+ apiServer.dashboardOrigin = `http://localhost:${actualPort}`;
108
+
100
109
  const url = `http://localhost:${actualPort}`;
101
110
 
102
111
  console.log(`
@@ -788,16 +797,22 @@ export async function cmdServe(opts = {}) {
788
797
  }
789
798
 
790
799
  try {
800
+ // Generate session token for cross-server authentication
801
+ const sessionToken = randomBytes(16).toString('hex');
802
+
791
803
  // Start API server
792
804
  console.log("\n🚀 Starting API server...");
793
- const apiServer = await startApiServer({ port: apiPort });
805
+ const apiServer = await startApiServer({ port: apiPort, sessionToken });
794
806
  const actualApiPort = apiServer.server.address().port;
795
807
 
796
808
  // Start static file server (using Node.js)
797
809
  console.log(`\n🚀 Starting dashboard server on port ${port}...`);
798
- const staticServer = await startStaticFileServer(deployer.canvasDir, port);
810
+ const staticServer = await startStaticFileServer(deployer.canvasDir, port, { apiPort: actualApiPort, sessionToken });
799
811
  const actualPort = staticServer.port;
800
812
 
813
+ // Lock CORS to the actual dashboard origin
814
+ apiServer.dashboardOrigin = `http://localhost:${actualPort}`;
815
+
801
816
  const url = `http://localhost:${actualPort}`;
802
817
  console.log(`
803
818
  ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
@@ -892,8 +907,16 @@ async function startStaticFileServer(directory, port, options = {}) {
892
907
  const { userInfo } = await import("node:os");
893
908
 
894
909
  const apiPort = options.apiPort || 3001;
910
+ const sessionToken = options.sessionToken || '';
895
911
  const osUser = (() => { try { return userInfo().username; } catch { return ''; } })();
896
- const portScript = `<script>window.__SMARTMETER_API_PORT=${apiPort};window.__SMARTMETER_USER=${JSON.stringify(osUser)};</script>`;
912
+ const injectedScript = `<script>window.__SMARTMETER_API_PORT=${apiPort};window.__SMARTMETER_USER=${JSON.stringify(osUser)};window.__SMARTMETER_TOKEN=${JSON.stringify(sessionToken)};</script>`;
913
+
914
+ // Cache-busting: force no-cache on all responses to prevent stale dashboards
915
+ const noCacheHeaders = {
916
+ 'Cache-Control': 'no-store, no-cache, must-revalidate, max-age=0',
917
+ 'Pragma': 'no-cache',
918
+ 'Expires': '0',
919
+ };
897
920
 
898
921
  const mimeTypes = {
899
922
  '.html': 'text/html',
@@ -911,6 +934,8 @@ async function startStaticFileServer(directory, port, options = {}) {
911
934
  try {
912
935
  // Parse URL and handle root
913
936
  let filePath = req.url === '/' ? '/index.html' : req.url;
937
+ // Strip query strings (cache busters, etc.)
938
+ filePath = filePath.split('?')[0];
914
939
  filePath = join(directory, filePath);
915
940
 
916
941
  // Check if file exists
@@ -925,17 +950,17 @@ async function startStaticFileServer(directory, port, options = {}) {
925
950
  const ext = extname(filePath);
926
951
  const mimeType = mimeTypes[ext] || 'application/octet-stream';
927
952
 
928
- // Inject API port and OS username into index.html so dashboard connects
929
- // to the correct API server and scopes localStorage per user
953
+ // Inject API port, session token, and OS username into index.html so
954
+ // dashboard connects to the correct API server and can't cross-contaminate
930
955
  if (ext === '.html') {
931
956
  let html = content.toString('utf-8');
932
- html = html.replace('</head>', `${portScript}\n</head>`);
933
- res.writeHead(200, { 'Content-Type': mimeType });
957
+ html = html.replace('</head>', `${injectedScript}\n</head>`);
958
+ res.writeHead(200, { 'Content-Type': mimeType, ...noCacheHeaders });
934
959
  res.end(html);
935
960
  return;
936
961
  }
937
962
 
938
- res.writeHead(200, { 'Content-Type': mimeType });
963
+ res.writeHead(200, { 'Content-Type': mimeType, ...noCacheHeaders });
939
964
  res.end(content);
940
965
  } catch (error) {
941
966
  res.writeHead(404, { 'Content-Type': 'text/plain' });