openclaw-smartmeter 0.5.2 → 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
@@ -5,6 +5,22 @@
5
5
 
6
6
  /* ─── Globals ─── */
7
7
  const API_BASE_URL = `http://localhost:${window.__SMARTMETER_API_PORT || 3001}`;
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
+
8
24
  let analysisData = null;
9
25
  let modelChart = null;
10
26
  let taskChart = null;
@@ -37,7 +53,7 @@ async function initializeDashboard() {
37
53
  async function loadAnalysisData() {
38
54
  try {
39
55
  // First try the API
40
- 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) });
41
57
  if (res.ok) {
42
58
  const json = await res.json();
43
59
  if (json.success && json.analysis) {
@@ -653,7 +669,7 @@ function resetBudgetDefaults() {
653
669
 
654
670
  async function applyBudgetControls() {
655
671
  try {
656
- const res = await fetch(`${API_BASE_URL}/api/apply`, {
672
+ const res = await apiFetch(`${API_BASE_URL}/api/apply`, {
657
673
  method: 'POST',
658
674
  headers: { 'Content-Type': 'application/json' },
659
675
  body: JSON.stringify({
@@ -771,7 +787,7 @@ async function applySelectedRecommendations() {
771
787
  if (!confirmed) return;
772
788
 
773
789
  try {
774
- const res = await fetch(`${API_BASE_URL}/api/apply`, {
790
+ const res = await apiFetch(`${API_BASE_URL}/api/apply`, {
775
791
  method: 'POST',
776
792
  headers: { 'Content-Type': 'application/json' },
777
793
  body: JSON.stringify({ confirm: true })
@@ -856,7 +872,7 @@ async function validateInlineApiKey() {
856
872
 
857
873
  // Try API server first
858
874
  try {
859
- const res = await fetch(`${API_BASE_URL}/api/config/openrouter-key`, {
875
+ const res = await apiFetch(`${API_BASE_URL}/api/config/openrouter-key`, {
860
876
  method: 'POST',
861
877
  headers: { 'Content-Type': 'application/json' },
862
878
  body: JSON.stringify({ apiKey: key })
@@ -866,7 +882,7 @@ async function validateInlineApiKey() {
866
882
  validated = true;
867
883
  // Fetch usage via API server
868
884
  try {
869
- const ur = await fetch(`${API_BASE_URL}/api/openrouter-usage`);
885
+ const ur = await apiFetch(`${API_BASE_URL}/api/openrouter-usage`);
870
886
  const uj = await ur.json();
871
887
  if (uj.success && uj.configured) usageData = uj.data || uj;
872
888
  } catch {}
@@ -901,7 +917,7 @@ async function validateInlineApiKey() {
901
917
  btn.textContent = 'Save & Validate';
902
918
 
903
919
  if (validated) {
904
- localStorage.setItem('smartmeter_openrouter_key', key);
920
+ localStorage.setItem(STORAGE_PREFIX + 'openrouter_key', key);
905
921
  showStatus('✅ API key saved and validated!', 'success');
906
922
 
907
923
  // Show balance section, hide key input
@@ -959,7 +975,7 @@ function showApiKeyInput() {
959
975
 
960
976
  /** On init, check if key is already stored and auto-show balance */
961
977
  async function initGetStartedCard() {
962
- const stored = localStorage.getItem('smartmeter_openrouter_key');
978
+ const stored = localStorage.getItem(STORAGE_PREFIX + 'openrouter_key');
963
979
  if (!stored) return;
964
980
 
965
981
  // Pre-fill the input
@@ -969,7 +985,7 @@ async function initGetStartedCard() {
969
985
  // Try to fetch balance
970
986
  let usageData = null;
971
987
  try {
972
- const res = await fetch(`${API_BASE_URL}/api/openrouter-usage`);
988
+ const res = await apiFetch(`${API_BASE_URL}/api/openrouter-usage`);
973
989
  const json = await res.json();
974
990
  if (json.success && json.configured) {
975
991
  usageData = json.data || json;
@@ -1190,7 +1206,7 @@ async function confirmApplyRecommendations() {
1190
1206
  if (btn) { btn.disabled = true; btn.textContent = 'Applying…'; }
1191
1207
 
1192
1208
  try {
1193
- const res = await fetch(`${API_BASE_URL}/api/apply`, {
1209
+ const res = await apiFetch(`${API_BASE_URL}/api/apply`, {
1194
1210
  method: 'POST',
1195
1211
  headers: { 'Content-Type': 'application/json' },
1196
1212
  body: JSON.stringify({ confirm: true })
@@ -1217,7 +1233,7 @@ async function confirmApplyRecommendations() {
1217
1233
  /* ─── OpenRouter Integration ─── */
1218
1234
  async function checkOpenRouterConfig() {
1219
1235
  try {
1220
- const res = await fetch(`${API_BASE_URL}/api/config/openrouter-key`);
1236
+ const res = await apiFetch(`${API_BASE_URL}/api/config/openrouter-key`);
1221
1237
  if (!res.ok) return;
1222
1238
  const json = await res.json();
1223
1239
  if (json.configured) {
@@ -1231,7 +1247,7 @@ async function checkOpenRouterConfig() {
1231
1247
  async function fetchOpenRouterUsage() {
1232
1248
  const container = document.getElementById('openRouterContent');
1233
1249
  try {
1234
- const res = await fetch(`${API_BASE_URL}/api/openrouter-usage`);
1250
+ const res = await apiFetch(`${API_BASE_URL}/api/openrouter-usage`);
1235
1251
  if (!res.ok) return;
1236
1252
  const json = await res.json();
1237
1253
  if (json.success && json.configured) {
@@ -1274,7 +1290,7 @@ async function fetchOpenRouterUsage() {
1274
1290
  function openConfigModal() {
1275
1291
  document.getElementById('configModal').style.display = 'flex';
1276
1292
  const input = document.getElementById('apiKeyInput');
1277
- const stored = localStorage.getItem('smartmeter_openrouter_key');
1293
+ const stored = localStorage.getItem(STORAGE_PREFIX + 'openrouter_key');
1278
1294
  if (stored && !input.value) input.value = stored;
1279
1295
  input.focus();
1280
1296
  const status = document.getElementById('configStatus');
@@ -1312,7 +1328,7 @@ async function saveApiKey() {
1312
1328
 
1313
1329
  // Try API server first
1314
1330
  try {
1315
- const res = await fetch(`${API_BASE_URL}/api/config/openrouter-key`, {
1331
+ const res = await apiFetch(`${API_BASE_URL}/api/config/openrouter-key`, {
1316
1332
  method: 'POST',
1317
1333
  headers: { 'Content-Type': 'application/json' },
1318
1334
  body: JSON.stringify({ apiKey: key })
@@ -1344,7 +1360,7 @@ async function saveApiKey() {
1344
1360
  }
1345
1361
 
1346
1362
  if (validated) {
1347
- localStorage.setItem('smartmeter_openrouter_key', key);
1363
+ localStorage.setItem(STORAGE_PREFIX + 'openrouter_key', key);
1348
1364
  showStatus('✅ API key saved and validated!', 'success');
1349
1365
  setTimeout(() => {
1350
1366
  closeConfigModal();
@@ -1360,7 +1376,7 @@ async function saveApiKey() {
1360
1376
  /* ─── Preview Modal ─── */
1361
1377
  async function viewConfig() {
1362
1378
  try {
1363
- const res = await fetch(`${API_BASE_URL}/api/preview`);
1379
+ const res = await apiFetch(`${API_BASE_URL}/api/preview`);
1364
1380
  const json = await res.json();
1365
1381
  if (json.success) {
1366
1382
  document.getElementById('previewConfigCode').textContent = JSON.stringify(json.config, null, 2);
@@ -1380,7 +1396,7 @@ async function applyOptimizations() {
1380
1396
  const confirmed = confirm('Apply all optimizations? A backup will be created.');
1381
1397
  if (!confirmed) return;
1382
1398
  try {
1383
- const res = await fetch(`${API_BASE_URL}/api/apply`, {
1399
+ const res = await apiFetch(`${API_BASE_URL}/api/apply`, {
1384
1400
  method: 'POST',
1385
1401
  headers: { 'Content-Type': 'application/json' },
1386
1402
  body: JSON.stringify({ confirm: true })
@@ -1400,7 +1416,7 @@ async function applyOptimizations() {
1400
1416
  /* ─── Export ─── */
1401
1417
  async function exportReport() {
1402
1418
  try {
1403
- const res = await fetch(`${API_BASE_URL}/api/export`);
1419
+ const res = await apiFetch(`${API_BASE_URL}/api/export`);
1404
1420
  if (!res.ok) throw new Error('Export failed');
1405
1421
  const blob = await res.blob();
1406
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.2",
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
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);
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
  ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
@@ -885,10 +900,23 @@ async function findAvailablePort(startPort, maxAttempts = 10) {
885
900
  return null;
886
901
  }
887
902
 
888
- async function startStaticFileServer(directory, port) {
903
+ async function startStaticFileServer(directory, port, options = {}) {
889
904
  const { createServer } = await import("node:http");
890
905
  const { readFile, stat } = await import("node:fs/promises");
891
906
  const { join, extname } = await import("node:path");
907
+ const { userInfo } = await import("node:os");
908
+
909
+ const apiPort = options.apiPort || 3001;
910
+ const sessionToken = options.sessionToken || '';
911
+ const osUser = (() => { try { return userInfo().username; } catch { return ''; } })();
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
+ };
892
920
 
893
921
  const mimeTypes = {
894
922
  '.html': 'text/html',
@@ -906,6 +934,8 @@ async function startStaticFileServer(directory, port) {
906
934
  try {
907
935
  // Parse URL and handle root
908
936
  let filePath = req.url === '/' ? '/index.html' : req.url;
937
+ // Strip query strings (cache busters, etc.)
938
+ filePath = filePath.split('?')[0];
909
939
  filePath = join(directory, filePath);
910
940
 
911
941
  // Check if file exists
@@ -916,11 +946,21 @@ async function startStaticFileServer(directory, port) {
916
946
  }
917
947
 
918
948
  // Read and serve file
919
- const content = await readFile(filePath);
949
+ let content = await readFile(filePath);
920
950
  const ext = extname(filePath);
921
951
  const mimeType = mimeTypes[ext] || 'application/octet-stream';
922
952
 
923
- res.writeHead(200, { 'Content-Type': mimeType });
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
955
+ if (ext === '.html') {
956
+ let html = content.toString('utf-8');
957
+ html = html.replace('</head>', `${injectedScript}\n</head>`);
958
+ res.writeHead(200, { 'Content-Type': mimeType, ...noCacheHeaders });
959
+ res.end(html);
960
+ return;
961
+ }
962
+
963
+ res.writeHead(200, { 'Content-Type': mimeType, ...noCacheHeaders });
924
964
  res.end(content);
925
965
  } catch (error) {
926
966
  res.writeHead(404, { 'Content-Type': 'text/plain' });