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 +11 -0
- package/canvas-template/app.js +33 -17
- package/package.json +2 -2
- package/src/canvas/api-server.js +16 -3
- package/src/cli/commands.js +48 -8
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
|
package/canvas-template/app.js
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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('
|
|
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('
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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('
|
|
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
|
|
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('
|
|
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
|
|
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
|
|
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
|
|
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.
|
|
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
|
+
}
|
package/src/canvas/api-server.js
CHANGED
|
@@ -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
|
-
//
|
|
72
|
-
|
|
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) {
|
package/src/cli/commands.js
CHANGED
|
@@ -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
|
-
|
|
949
|
+
let content = await readFile(filePath);
|
|
920
950
|
const ext = extname(filePath);
|
|
921
951
|
const mimeType = mimeTypes[ext] || 'application/octet-stream';
|
|
922
952
|
|
|
923
|
-
|
|
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' });
|