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 +11 -0
- package/canvas-template/app.js +28 -13
- package/package.json +2 -2
- package/src/canvas/api-server.js +16 -3
- package/src/cli/commands.js +36 -11
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
|
@@ -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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
+
"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 (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
|
|
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
|
|
929
|
-
// to the correct API server and
|
|
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>', `${
|
|
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' });
|