openclaw-safeclaw-plugin 1.0.0 → 1.1.0

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/dist/index.js CHANGED
@@ -7,23 +7,38 @@
7
7
  * to the SafeClaw service and acts on the responses.
8
8
  */
9
9
  import { loadConfig, configHash } from './tui/config.js';
10
+ import crypto from 'crypto';
11
+ import { createRequire } from 'module';
12
+ const require = createRequire(import.meta.url);
13
+ const { version: PLUGIN_VERSION } = require('./package.json');
10
14
  // --- Configuration ---
11
- const config = loadConfig();
15
+ const CONFIG_RELOAD_INTERVAL_MS = 60_000; // Reload config every 60 seconds
16
+ let config = loadConfig();
17
+ let configLoadedAt = Date.now();
18
+ function getConfig() {
19
+ const now = Date.now();
20
+ if (now - configLoadedAt >= CONFIG_RELOAD_INTERVAL_MS) {
21
+ config = loadConfig();
22
+ configLoadedAt = now;
23
+ }
24
+ return config;
25
+ }
12
26
  // --- HTTP Client ---
13
27
  async function post(path, body) {
14
- if (!config.enabled)
28
+ const cfg = getConfig();
29
+ if (!cfg.enabled)
15
30
  return null;
16
31
  const headers = { 'Content-Type': 'application/json' };
17
- if (config.apiKey) {
18
- headers['Authorization'] = `Bearer ${config.apiKey}`;
32
+ if (cfg.apiKey) {
33
+ headers['Authorization'] = `Bearer ${cfg.apiKey}`;
19
34
  }
20
- const agentFields = config.agentId ? { agentId: config.agentId, agentToken: config.agentToken } : {};
35
+ const agentFields = cfg.agentId ? { agentId: cfg.agentId, agentToken: cfg.agentToken } : {};
21
36
  try {
22
- const res = await fetch(`${config.serviceUrl}${path}`, {
37
+ const res = await fetch(`${cfg.serviceUrl}${path}`, {
23
38
  method: 'POST',
24
39
  headers,
25
40
  body: JSON.stringify({ ...body, ...agentFields }),
26
- signal: AbortSignal.timeout(config.timeoutMs),
41
+ signal: AbortSignal.timeout(cfg.timeoutMs),
27
42
  });
28
43
  if (!res.ok) {
29
44
  // Try to parse structured error body from service
@@ -43,26 +58,27 @@ async function post(path, body) {
43
58
  }
44
59
  catch (e) {
45
60
  if (e instanceof DOMException && e.name === 'TimeoutError') {
46
- console.warn(`[SafeClaw] Timeout after ${config.timeoutMs}ms on ${path} (${config.serviceUrl})`);
61
+ console.warn(`[SafeClaw] Timeout after ${cfg.timeoutMs}ms on ${path} (${cfg.serviceUrl})`);
47
62
  }
48
63
  else if (e instanceof TypeError && (e.message.includes('fetch') || e.message.includes('ECONNREFUSED'))) {
49
- console.warn(`[SafeClaw] Connection refused: ${config.serviceUrl}${path} — is the service running?`);
64
+ console.warn(`[SafeClaw] Connection refused: ${cfg.serviceUrl}${path} — is the service running?`);
50
65
  }
51
66
  else {
52
- console.warn(`[SafeClaw] Service unavailable: ${config.serviceUrl}${path}`);
67
+ console.warn(`[SafeClaw] Service unavailable: ${cfg.serviceUrl}${path}`);
53
68
  }
54
69
  return null; // Caller checks failMode
55
70
  }
56
71
  }
57
72
  let handshakeCompleted = false;
58
73
  async function performHandshake() {
59
- if (!config.apiKey) {
74
+ const cfg = getConfig();
75
+ if (!cfg.apiKey) {
60
76
  console.warn('[SafeClaw] No API key configured — skipping handshake');
61
77
  return false;
62
78
  }
63
79
  const r = await post('/handshake', {
64
- pluginVersion: '0.1.3',
65
- configHash: configHash(config),
80
+ pluginVersion: PLUGIN_VERSION,
81
+ configHash: configHash(cfg),
66
82
  });
67
83
  if (r === null) {
68
84
  console.warn('[SafeClaw] ✗ Handshake failed — API key may be invalid or service unreachable');
@@ -73,12 +89,13 @@ async function performHandshake() {
73
89
  return true;
74
90
  }
75
91
  async function checkConnection() {
92
+ const cfg = getConfig();
76
93
  const label = `[SafeClaw]`;
77
- console.log(`${label} Connecting to ${config.serviceUrl} ...`);
78
- console.log(`${label} Mode: enforcement=${config.enforcement}, failMode=${config.failMode}`);
94
+ console.log(`${label} Connecting to ${cfg.serviceUrl} ...`);
95
+ console.log(`${label} Mode: enforcement=${cfg.enforcement}, failMode=${cfg.failMode}`);
79
96
  try {
80
- const res = await fetch(`${config.serviceUrl}/health`, {
81
- signal: AbortSignal.timeout(config.timeoutMs * 2),
97
+ const res = await fetch(`${cfg.serviceUrl}/health`, {
98
+ signal: AbortSignal.timeout(cfg.timeoutMs * 2),
82
99
  });
83
100
  if (res.ok) {
84
101
  const data = await res.json();
@@ -89,8 +106,8 @@ async function checkConnection() {
89
106
  }
90
107
  }
91
108
  catch {
92
- console.warn(`${label} ✗ Cannot reach service at ${config.serviceUrl}`);
93
- if (config.failMode === 'closed') {
109
+ console.warn(`${label} ✗ Cannot reach service at ${cfg.serviceUrl}`);
110
+ if (cfg.failMode === 'closed') {
94
111
  console.warn(`${label} fail-mode=closed → tool calls will be BLOCKED until service is reachable`);
95
112
  }
96
113
  else {
@@ -101,18 +118,20 @@ async function checkConnection() {
101
118
  export default {
102
119
  id: 'safeclaw',
103
120
  name: 'SafeClaw Neurosymbolic Governance',
104
- version: '0.1.3',
121
+ version: PLUGIN_VERSION,
105
122
  register(api) {
106
- if (!config.enabled) {
123
+ if (!getConfig().enabled) {
107
124
  console.log('[SafeClaw] Plugin disabled');
108
125
  return;
109
126
  }
127
+ // Generate a unique instance ID for this plugin run (fallback when agentId is not configured)
128
+ const instanceId = getConfig().agentId || `instance-${crypto.randomUUID()}`;
110
129
  // Heartbeat watchdog — send config hash to service every 30s
111
130
  const sendHeartbeat = async () => {
112
131
  try {
113
132
  await post('/heartbeat', {
114
- agentId: config.agentId || 'default',
115
- configHash: configHash(config),
133
+ agentId: instanceId,
134
+ configHash: configHash(getConfig()),
116
135
  status: 'alive',
117
136
  });
118
137
  }
@@ -120,32 +139,40 @@ export default {
120
139
  // Heartbeat failure is non-fatal
121
140
  }
122
141
  };
123
- // Start heartbeat after connection check + handshake
142
+ // Start heartbeat only after connection check + handshake completes (#84)
143
+ let heartbeatInterval;
124
144
  checkConnection()
125
145
  .then(() => performHandshake())
126
146
  .then((ok) => {
127
- if (!ok && config.failMode === 'closed') {
128
- console.warn('[SafeClaw] Handshake failed with fail-mode=closed — tool calls will be BLOCKED');
147
+ if (!ok && getConfig().failMode === 'closed') {
148
+ console.warn('[SafeClaw] Handshake failed with fail-mode=closed — tool calls will be BLOCKED');
129
149
  }
150
+ heartbeatInterval = setInterval(sendHeartbeat, 30000);
130
151
  return sendHeartbeat();
131
152
  })
132
153
  .catch(() => { });
133
- const heartbeatInterval = setInterval(sendHeartbeat, 30000);
134
154
  // Clean shutdown: send shutdown heartbeat and clear interval
135
- const shutdown = () => {
136
- clearInterval(heartbeatInterval);
137
- post('/heartbeat', {
138
- agentId: config.agentId || 'default',
139
- configHash: configHash(config),
140
- status: 'shutdown',
141
- }).catch(() => { });
155
+ // Use async shutdown for SIGINT/SIGTERM where async is supported (#55)
156
+ const shutdown = async () => {
157
+ if (heartbeatInterval)
158
+ clearInterval(heartbeatInterval);
159
+ try {
160
+ await post('/heartbeat', {
161
+ agentId: instanceId,
162
+ configHash: configHash(getConfig()),
163
+ status: 'shutdown',
164
+ });
165
+ }
166
+ catch {
167
+ // Best-effort shutdown notification
168
+ }
142
169
  };
143
- process.on('exit', shutdown);
144
- process.on('SIGINT', () => { shutdown(); process.exit(0); });
145
- process.on('SIGTERM', () => { shutdown(); process.exit(0); });
170
+ process.on('SIGINT', async () => { await shutdown(); process.exit(0); });
171
+ process.on('SIGTERM', async () => { await shutdown(); process.exit(0); });
146
172
  // THE GATE — constraint checking on every tool call
147
173
  api.on('before_tool_call', async (event, ctx) => {
148
- if (!handshakeCompleted && config.failMode === 'closed' && config.enforcement === 'enforce') {
174
+ const cfg = getConfig();
175
+ if (!handshakeCompleted && cfg.failMode === 'closed' && cfg.enforcement === 'enforce') {
149
176
  return { block: true, blockReason: 'SafeClaw handshake not completed (fail-closed)' };
150
177
  }
151
178
  const r = await post('/evaluate/tool-call', {
@@ -155,21 +182,22 @@ export default {
155
182
  params: event.params ?? {},
156
183
  sessionHistory: event.sessionHistory ?? [],
157
184
  });
158
- if (r === null && config.failMode === 'closed' && config.enforcement === 'enforce') {
159
- return { block: true, blockReason: `SafeClaw service unavailable at ${config.serviceUrl} (fail-closed)` };
185
+ if (r === null && cfg.failMode === 'closed' && cfg.enforcement === 'enforce') {
186
+ return { block: true, blockReason: `SafeClaw service unavailable at ${cfg.serviceUrl} (fail-closed)` };
160
187
  }
161
- else if (r === null && config.failMode === 'closed' && config.enforcement === 'warn-only') {
162
- console.warn(`[SafeClaw] Service unavailable at ${config.serviceUrl} (fail-closed mode, warn-only)`);
188
+ else if (r === null && cfg.failMode === 'closed' && cfg.enforcement === 'warn-only') {
189
+ console.warn(`[SafeClaw] Service unavailable at ${cfg.serviceUrl} (fail-closed mode, warn-only)`);
163
190
  }
164
- else if (r === null && config.failMode === 'closed' && config.enforcement === 'audit-only') {
165
- console.warn(`[SafeClaw] Service unavailable at ${config.serviceUrl} (fail-closed mode, audit-only)`);
191
+ else if (r === null && cfg.failMode === 'closed' && cfg.enforcement === 'audit-only') {
192
+ console.warn(`[SafeClaw] Service unavailable at ${cfg.serviceUrl} (fail-closed mode, audit-only)`);
166
193
  }
167
194
  if (r?.block) {
168
- if (config.enforcement === 'enforce') {
169
- return { block: true, blockReason: r.reason };
195
+ const blockReason = r.reason || 'Blocked by SafeClaw (no reason provided)';
196
+ if (cfg.enforcement === 'enforce') {
197
+ return { block: true, blockReason };
170
198
  }
171
- if (config.enforcement === 'warn-only') {
172
- console.warn(`[SafeClaw] Warning: ${r.reason}`);
199
+ if (cfg.enforcement === 'warn-only') {
200
+ console.warn(`[SafeClaw] Warning: ${blockReason}`);
173
201
  }
174
202
  // audit-only: logged server-side, no action here
175
203
  }
@@ -186,24 +214,29 @@ export default {
186
214
  }, { priority: 100 });
187
215
  // Message governance — check outbound messages
188
216
  api.on('message_sending', async (event, ctx) => {
217
+ const cfg = getConfig();
189
218
  const r = await post('/evaluate/message', {
190
219
  sessionId: ctx.sessionId ?? event.sessionId,
191
220
  userId: ctx.userId ?? event.userId,
192
221
  to: event.to,
193
222
  content: event.content,
194
223
  });
195
- if (r === null && config.failMode === 'closed' && config.enforcement === 'enforce') {
196
- return { cancel: true };
224
+ if (r === null && cfg.failMode === 'closed' && cfg.enforcement === 'enforce') {
225
+ return { cancel: true, cancelReason: 'SafeClaw service unavailable (fail-closed mode)' };
197
226
  }
198
- else if (r === null && config.failMode === 'closed' && config.enforcement === 'warn-only') {
227
+ else if (r === null && cfg.failMode === 'closed' && cfg.enforcement === 'warn-only') {
199
228
  console.warn('[SafeClaw] Service unavailable (fail-closed mode, warn-only)');
200
229
  }
230
+ else if (r === null && cfg.failMode === 'closed' && cfg.enforcement === 'audit-only') {
231
+ console.info('[SafeClaw] audit-only: service unreachable, allowing message (fail-closed)');
232
+ }
201
233
  if (r?.block) {
202
- if (config.enforcement === 'enforce') {
203
- return { cancel: true };
234
+ const blockReason = r.reason || 'Blocked by SafeClaw (no reason provided)';
235
+ if (cfg.enforcement === 'enforce') {
236
+ return { cancel: true, cancelReason: blockReason };
204
237
  }
205
- if (config.enforcement === 'warn-only') {
206
- console.warn(`[SafeClaw] Warning: ${r.reason}`);
238
+ if (cfg.enforcement === 'warn-only') {
239
+ console.warn(`[SafeClaw] Warning: ${blockReason}`);
207
240
  }
208
241
  // audit-only: logged server-side, no action here
209
242
  }
package/dist/tui/About.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { Text, Box } from 'ink';
3
3
  export default function About() {
4
- return (_jsxs(Box, { flexDirection: "column", paddingX: 1, children: [_jsx(Box, { marginBottom: 1, children: _jsx(Text, { bold: true, children: "About" }) }), _jsx(Text, { children: " SafeClaw Neurosymbolic Governance" }), _jsx(Text, { dimColor: true, children: " Validates AI agent actions against OWL" }), _jsx(Text, { dimColor: true, children: " ontologies and SHACL constraints." }), _jsxs(Box, { marginTop: 1, children: [_jsx(Text, { children: " Web: " }), _jsx(Text, { color: "cyan", children: "https://safeclaw.eu" })] }), _jsxs(Box, { children: [_jsx(Text, { children: " Docs: " }), _jsx(Text, { color: "cyan", children: "https://safeclaw.eu/docs" })] }), _jsxs(Box, { children: [_jsx(Text, { children: " Repo: " }), _jsx(Text, { color: "cyan", children: "https://github.com/tendlyeu/SafeClaw" })] }), _jsx(Box, { marginTop: 1, children: _jsx(Text, { dimColor: true, children: " q to quit" }) })] }));
4
+ return (_jsxs(Box, { flexDirection: "column", paddingX: 1, children: [_jsx(Box, { marginBottom: 1, children: _jsx(Text, { bold: true, children: "About SafeClaw" }) }), _jsx(Text, { children: " Neurosymbolic governance for AI agents." }), _jsx(Text, { dimColor: true, children: " Validates tool calls, messages, and actions against" }), _jsx(Text, { dimColor: true, children: " OWL ontologies and SHACL constraints before execution." }), _jsx(Box, { marginTop: 1, marginBottom: 1, children: _jsx(Text, { bold: true, children: " Features" }) }), _jsx(Text, { dimColor: true, children: " - 9-step constraint pipeline (SHACL, policies, preferences, dependencies)" }), _jsx(Text, { dimColor: true, children: " - Role-based access control with per-user preferences" }), _jsx(Text, { dimColor: true, children: " - Multi-agent governance with delegation detection" }), _jsx(Text, { dimColor: true, children: " - Append-only audit trail with compliance reporting" }), _jsx(Text, { dimColor: true, children: " - Passive LLM security reviewer and classification observer" }), _jsx(Text, { dimColor: true, children: " - Natural language policy compilation" }), _jsx(Box, { marginTop: 1, marginBottom: 1, children: _jsx(Text, { bold: true, children: " CLI commands" }) }), _jsx(Text, { dimColor: true, children: " safeclaw-plugin This plugin CLI (connect, status, config, tui)" }), _jsx(Text, { dimColor: true, children: " safeclaw Service CLI (serve, audit, policy, pref, status)" }), _jsxs(Box, { marginTop: 1, children: [_jsx(Text, { children: " Web: " }), _jsx(Text, { color: "cyan", children: "https://safeclaw.eu" })] }), _jsxs(Box, { children: [_jsx(Text, { children: " Docs: " }), _jsx(Text, { color: "cyan", children: "https://safeclaw.eu/docs" })] }), _jsxs(Box, { children: [_jsx(Text, { children: " Repo: " }), _jsx(Text, { color: "cyan", children: "https://github.com/tendlyeu/SafeClaw" })] }), _jsx(Box, { marginTop: 1, children: _jsx(Text, { dimColor: true, children: " q to quit" }) })] }));
5
5
  }
package/dist/tui/App.js CHANGED
@@ -33,5 +33,5 @@ export default function App() {
33
33
  }
34
34
  }
35
35
  });
36
- return (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Box, { borderStyle: "single", borderColor: "green", paddingX: 1, children: [_jsx(Text, { bold: true, color: "green", children: "SafeClaw " }), _jsxs(Text, { dimColor: true, children: ["v", PKG_VERSION] })] }), _jsxs(Box, { paddingX: 1, gap: 2, children: [TABS.map((t, i) => (_jsx(Text, { bold: tab === t, color: tab === t ? 'cyan' : 'white', dimColor: tab !== t, children: `${i + 1}:${t}` }, t))), _jsx(Text, { dimColor: true, children: " tab/1-3 to switch" })] }), _jsxs(Box, { marginTop: 1, children: [tab === 'Status' && _jsx(Status, { config: config }), tab === 'Settings' && (_jsx(Settings, { config: config, onConfigChange: setConfig })), tab === 'About' && _jsx(About, {})] })] }));
36
+ return (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Box, { borderStyle: "single", borderColor: "green", paddingX: 1, children: [_jsx(Text, { bold: true, color: "green", children: "SafeClaw " }), _jsxs(Text, { dimColor: true, children: ["v", PKG_VERSION] })] }), _jsxs(Box, { paddingX: 1, gap: 2, children: [TABS.map((t, i) => (_jsx(Text, { bold: tab === t, color: tab === t ? 'cyan' : 'white', dimColor: tab !== t, children: `${i + 1}:${t}` }, t))), _jsx(Text, { dimColor: true, children: " tab/1-3 switch q quit" })] }), _jsxs(Box, { marginTop: 1, children: [tab === 'Status' && _jsx(Status, { config: config }), tab === 'Settings' && (_jsx(Settings, { config: config, onConfigChange: setConfig })), tab === 'About' && _jsx(About, {})] })] }));
37
37
  }
@@ -9,6 +9,7 @@ const SETTINGS = [
9
9
  { key: 'enforcement', label: 'Enforcement', type: 'cycle', values: ENFORCEMENT_MODES },
10
10
  { key: 'failMode', label: 'Fail Mode', type: 'cycle', values: FAIL_MODES },
11
11
  { key: 'serviceUrl', label: 'Service URL', type: 'text' },
12
+ { key: 'apiKey', label: 'API Key', type: 'text' },
12
13
  ];
13
14
  export default function Settings({ config, onConfigChange }) {
14
15
  const [selected, setSelected] = useState(0);
@@ -22,7 +23,18 @@ export default function Settings({ config, onConfigChange }) {
22
23
  useInput((input, key) => {
23
24
  if (editing) {
24
25
  if (key.return) {
25
- updateConfig({ serviceUrl: editBuffer });
26
+ const editingKey = SETTINGS[selected].key;
27
+ if (editingKey === 'apiKey' && editBuffer && !editBuffer.startsWith('sc_')) {
28
+ // Reject invalid API keys — must start with sc_
29
+ return;
30
+ }
31
+ try {
32
+ updateConfig({ [editingKey]: editBuffer });
33
+ }
34
+ catch {
35
+ // saveConfig may throw on invalid URL — stay in edit mode
36
+ return;
37
+ }
26
38
  setEditing(false);
27
39
  }
28
40
  else if (key.escape) {
@@ -57,7 +69,7 @@ export default function Settings({ config, onConfigChange }) {
57
69
  }
58
70
  else if (setting.type === 'text' && key.return) {
59
71
  setEditing(true);
60
- setEditBuffer(config.serviceUrl);
72
+ setEditBuffer(String(config[setting.key] ?? ''));
61
73
  }
62
74
  }
63
75
  });
@@ -68,8 +80,13 @@ export default function Settings({ config, onConfigChange }) {
68
80
  if (setting.key === 'enabled') {
69
81
  value = config.enabled ? 'ON' : 'OFF';
70
82
  }
71
- else if (setting.key === 'serviceUrl' && editing && isSelected) {
72
- value = editBuffer + '█';
83
+ else if (setting.type === 'text' && editing && isSelected) {
84
+ value = (setting.key === 'apiKey' ? '*'.repeat(editBuffer.length) : editBuffer) + '█';
85
+ }
86
+ else if (setting.key === 'apiKey') {
87
+ value = config.apiKey
88
+ ? `${config.apiKey.slice(0, 6)}..${config.apiKey.slice(-4)}`
89
+ : '(not set)';
73
90
  }
74
91
  else {
75
92
  value = String(config[setting.key]);
@@ -79,6 +96,8 @@ export default function Settings({ config, onConfigChange }) {
79
96
  ? config.enabled ? 'green' : 'red'
80
97
  : undefined, children: value }), showArrows && _jsx(Text, { dimColor: true, children: ' ▶' })] }, setting.key));
81
98
  }), _jsx(Box, { marginTop: 1, children: _jsx(Text, { dimColor: true, children: editing
82
- ? ' type to edit · enter to save · esc to cancel'
83
- : ' ↑↓ navigate · ←→ change · enter to edit URL · q quit' }) }), _jsx(Box, { children: _jsx(Text, { dimColor: true, children: ' Saves to ~/.safeclaw/config.json' }) })] }));
99
+ ? SETTINGS[selected].key === 'apiKey' && editBuffer && !editBuffer.startsWith('sc_')
100
+ ? ' API key must start with sc_ · esc to cancel'
101
+ : ' type to edit · enter to save · esc to cancel'
102
+ : ' ↑↓ navigate · ←→/enter cycle/toggle · enter to edit text fields · q quit' }) }), _jsx(Box, { marginTop: 1, children: _jsx(Text, { dimColor: true, children: ' Enforcement: enforce=block violations, warn-only=log only, audit-only=record only, disabled=off' }) }), _jsx(Box, { children: _jsx(Text, { dimColor: true, children: ' Fail Mode: open=allow if service unreachable, closed=block if service unreachable' }) }), _jsx(Box, { marginTop: 1, children: _jsx(Text, { dimColor: true, children: ' All changes save to ~/.safeclaw/config.json immediately.' }) })] }));
84
103
  }
@@ -1,13 +1,22 @@
1
- import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
1
+ import { jsxs as _jsxs, jsx as _jsx, Fragment as _Fragment } from "react/jsx-runtime";
2
2
  import { useState, useEffect } from 'react';
3
3
  import { Text, Box, useInput } from 'ink';
4
4
  import { exec } from 'child_process';
5
+ import { existsSync, readFileSync } from 'fs';
6
+ import { join, dirname } from 'path';
7
+ import { homedir } from 'os';
8
+ import { fileURLToPath } from 'url';
9
+ const __dirname = dirname(fileURLToPath(import.meta.url));
10
+ const PKG_VERSION = JSON.parse(readFileSync(join(__dirname, '..', '..', 'package.json'), 'utf-8')).version;
5
11
  export default function Status({ config }) {
6
12
  const [health, setHealth] = useState(null);
7
13
  const [error, setError] = useState(null);
8
14
  const [lastCheck, setLastCheck] = useState(null);
9
15
  const [openclawStatus, setOpenclawStatus] = useState('checking');
10
16
  const [restartMsg, setRestartMsg] = useState(null);
17
+ const [evaluateStatus, setEvaluateStatus] = useState('checking');
18
+ const [handshakeStatus, setHandshakeStatus] = useState('checking');
19
+ const [handshakeDetail, setHandshakeDetail] = useState('');
11
20
  const checkHealth = async () => {
12
21
  try {
13
22
  const res = await fetch(`${config.serviceUrl}/health`, {
@@ -17,18 +26,76 @@ export default function Status({ config }) {
17
26
  const data = await res.json();
18
27
  setHealth(data);
19
28
  setError(null);
29
+ // If healthy, run deeper checks
30
+ checkEvaluate();
31
+ if (config.apiKey)
32
+ checkHandshake();
33
+ else {
34
+ setHandshakeStatus('fail');
35
+ setHandshakeDetail('No API key');
36
+ }
20
37
  }
21
38
  else {
22
39
  setHealth(null);
23
40
  setError(`HTTP ${res.status}`);
41
+ setEvaluateStatus('fail');
42
+ setHandshakeStatus('fail');
24
43
  }
25
44
  }
26
45
  catch {
27
46
  setHealth(null);
28
47
  setError('Cannot connect');
48
+ setEvaluateStatus('fail');
49
+ setHandshakeStatus('fail');
29
50
  }
30
51
  setLastCheck(new Date());
31
52
  };
53
+ const checkEvaluate = async () => {
54
+ try {
55
+ const res = await fetch(`${config.serviceUrl}/evaluate/tool-call`, {
56
+ method: 'POST',
57
+ headers: {
58
+ 'Content-Type': 'application/json',
59
+ ...(config.apiKey ? { 'Authorization': `Bearer ${config.apiKey}` } : {}),
60
+ },
61
+ body: JSON.stringify({
62
+ sessionId: 'tui-check', userId: 'tui-check',
63
+ toolName: 'echo', params: { message: 'tui-check' },
64
+ }),
65
+ signal: AbortSignal.timeout(config.timeoutMs),
66
+ });
67
+ setEvaluateStatus(res.ok || res.status === 422 || res.status === 401 || res.status === 403 ? 'ok' : 'fail');
68
+ }
69
+ catch {
70
+ setEvaluateStatus('fail');
71
+ }
72
+ };
73
+ const checkHandshake = async () => {
74
+ try {
75
+ const res = await fetch(`${config.serviceUrl}/handshake`, {
76
+ method: 'POST',
77
+ headers: {
78
+ 'Content-Type': 'application/json',
79
+ 'Authorization': `Bearer ${config.apiKey}`,
80
+ },
81
+ body: JSON.stringify({ pluginVersion: PKG_VERSION, configHash: '' }),
82
+ signal: AbortSignal.timeout(config.timeoutMs),
83
+ });
84
+ if (res.ok) {
85
+ const data = await res.json();
86
+ setHandshakeStatus('ok');
87
+ setHandshakeDetail(`org=${data.orgId ?? '?'}`);
88
+ }
89
+ else {
90
+ setHandshakeStatus('fail');
91
+ setHandshakeDetail(`HTTP ${res.status}`);
92
+ }
93
+ }
94
+ catch {
95
+ setHandshakeStatus('fail');
96
+ setHandshakeDetail('timeout');
97
+ }
98
+ };
32
99
  const checkOpenClaw = () => {
33
100
  exec('openclaw daemon status', { timeout: 10000 }, (err, stdout) => {
34
101
  if (err) {
@@ -67,6 +134,7 @@ export default function Status({ config }) {
67
134
  restartOpenClaw();
68
135
  }
69
136
  });
137
+ // Derived display values
70
138
  const connected = health !== null;
71
139
  const dot = '●';
72
140
  const serviceDotColor = connected ? 'green' : 'red';
@@ -77,5 +145,29 @@ export default function Status({ config }) {
77
145
  const openclawText = openclawStatus === 'checking' ? 'Checking...'
78
146
  : openclawStatus === 'running' ? 'Running'
79
147
  : 'Not running';
80
- return (_jsxs(Box, { flexDirection: "column", paddingX: 1, children: [_jsx(Box, { marginBottom: 1, children: _jsx(Text, { bold: true, children: "Status" }) }), _jsxs(Box, { children: [_jsx(Text, { dimColor: true, children: ' Service ' }), _jsxs(Text, { color: serviceDotColor, children: [dot, " "] }), _jsx(Text, { children: serviceText })] }), _jsxs(Box, { children: [_jsx(Text, { dimColor: true, children: ' OpenClaw ' }), _jsxs(Text, { color: openclawDotColor, children: [dot, " "] }), _jsx(Text, { children: openclawText }), restartMsg && _jsx(Text, { dimColor: true, children: ` (${restartMsg})` })] }), _jsxs(Box, { children: [_jsx(Text, { dimColor: true, children: ' Enforcement ' }), _jsx(Text, { children: config.enforcement })] }), _jsxs(Box, { children: [_jsx(Text, { dimColor: true, children: ' Fail Mode ' }), _jsx(Text, { children: config.failMode })] }), _jsxs(Box, { children: [_jsx(Text, { dimColor: true, children: ' Enabled ' }), _jsx(Text, { color: config.enabled ? 'green' : 'red', children: config.enabled ? 'ON' : 'OFF' })] }), health?.version && (_jsxs(Box, { marginTop: 1, children: [_jsx(Text, { dimColor: true, children: ' Service v' }), _jsx(Text, { children: health.version })] })), lastCheck && (_jsx(Box, { marginTop: 1, children: _jsxs(Text, { dimColor: true, children: [' Last check: ', lastCheck.toLocaleTimeString()] }) })), _jsxs(Box, { marginTop: 1, children: [_jsx(Text, { dimColor: true, children: ' Press ' }), _jsx(Text, { bold: true, children: "r" }), _jsx(Text, { dimColor: true, children: ' to restart OpenClaw daemon' })] })] }));
148
+ // Plugin file checks
149
+ const extensionDir = join(homedir(), '.openclaw', 'extensions', 'safeclaw');
150
+ const pluginInstalled = existsSync(join(extensionDir, 'openclaw.plugin.json'))
151
+ && existsSync(join(extensionDir, 'index.js'));
152
+ // Plugin enabled in OpenClaw config
153
+ let pluginEnabled = false;
154
+ const ocConfigPath = join(homedir(), '.openclaw', 'openclaw.json');
155
+ if (existsSync(ocConfigPath)) {
156
+ try {
157
+ const ocConfig = JSON.parse(readFileSync(ocConfigPath, 'utf-8'));
158
+ pluginEnabled = !!ocConfig?.plugins?.entries?.safeclaw?.enabled;
159
+ }
160
+ catch { /* ignore */ }
161
+ }
162
+ const apiKeyMasked = config.apiKey
163
+ ? `${config.apiKey.slice(0, 6)}..${config.apiKey.slice(-4)}`
164
+ : '(not set)';
165
+ const statusDot = (status) => {
166
+ if (status === 'checking')
167
+ return _jsxs(Text, { color: "yellow", children: [dot, " "] });
168
+ if (status === 'ok' || status === true)
169
+ return _jsxs(Text, { color: "green", children: [dot, " "] });
170
+ return _jsxs(Text, { color: "red", children: [dot, " "] });
171
+ };
172
+ return (_jsxs(Box, { flexDirection: "column", paddingX: 1, children: [_jsx(Box, { marginBottom: 1, children: _jsx(Text, { bold: true, children: "Status" }) }), _jsxs(Box, { children: [_jsx(Text, { dimColor: true, children: ' Service ' }), _jsxs(Text, { color: serviceDotColor, children: [dot, " "] }), _jsx(Text, { children: serviceText })] }), connected && (_jsxs(_Fragment, { children: [_jsxs(Box, { children: [_jsx(Text, { dimColor: true, children: ' Evaluate ' }), statusDot(evaluateStatus), _jsx(Text, { children: evaluateStatus === 'ok' ? 'Responding' : evaluateStatus === 'checking' ? 'Checking...' : 'Not responding' })] }), _jsxs(Box, { children: [_jsx(Text, { dimColor: true, children: ' Handshake ' }), statusDot(handshakeStatus), _jsx(Text, { children: handshakeStatus === 'ok' ? handshakeDetail : handshakeStatus === 'checking' ? 'Checking...' : handshakeDetail })] })] })), _jsxs(Box, { children: [_jsx(Text, { dimColor: true, children: ' OpenClaw ' }), _jsxs(Text, { color: openclawDotColor, children: [dot, " "] }), _jsx(Text, { children: openclawText }), restartMsg && _jsx(Text, { dimColor: true, children: ` (${restartMsg})` })] }), _jsxs(Box, { children: [_jsx(Text, { dimColor: true, children: ' Plugin ' }), statusDot(pluginInstalled), _jsx(Text, { children: pluginInstalled ? 'Installed' : 'Not installed' })] }), _jsxs(Box, { children: [_jsx(Text, { dimColor: true, children: ' Config ' }), statusDot(pluginEnabled), _jsx(Text, { children: pluginEnabled ? 'Enabled' : 'Not enabled' })] }), _jsxs(Box, { children: [_jsx(Text, { dimColor: true, children: ' API Key ' }), statusDot(!!config.apiKey), _jsx(Text, { children: apiKeyMasked })] }), _jsxs(Box, { children: [_jsx(Text, { dimColor: true, children: ' Enforcement ' }), _jsx(Text, { children: config.enforcement })] }), _jsxs(Box, { children: [_jsx(Text, { dimColor: true, children: ' Fail Mode ' }), _jsx(Text, { children: config.failMode })] }), _jsxs(Box, { children: [_jsx(Text, { dimColor: true, children: ' Enabled ' }), _jsx(Text, { color: config.enabled ? 'green' : 'red', children: config.enabled ? 'ON' : 'OFF' })] }), health?.version && (_jsxs(Box, { marginTop: 1, children: [_jsx(Text, { dimColor: true, children: ' Service v' }), _jsx(Text, { children: health.version })] })), lastCheck && (_jsx(Box, { marginTop: 1, children: _jsxs(Text, { dimColor: true, children: [' Last check: ', lastCheck.toLocaleTimeString()] }) })), _jsxs(Box, { marginTop: 1, children: [_jsx(Text, { dimColor: true, children: ' Press ' }), _jsx(Text, { bold: true, children: "r" }), _jsx(Text, { dimColor: true, children: ' to restart OpenClaw daemon' })] })] }));
81
173
  }
@@ -17,6 +17,10 @@ export interface SafeClawConfig {
17
17
  }
18
18
  export declare const CONFIG_PATH: string;
19
19
  export declare function loadConfig(): SafeClawConfig;
20
+ /**
21
+ * Check whether a URL is a valid http:// or https:// URL.
22
+ */
23
+ export declare function isValidServiceUrl(url: string): boolean;
20
24
  /**
21
25
  * Persist managed config fields back to ~/.safeclaw/config.json.
22
26
  * Reads the existing file (if any), merges the fields SafeClaw manages,
@@ -14,7 +14,7 @@ export const CONFIG_PATH = join(homedir(), '.safeclaw', 'config.json');
14
14
  // --- Functions ---
15
15
  export function loadConfig() {
16
16
  const defaults = {
17
- serviceUrl: 'https://api.safeclaw.eu/api/v1',
17
+ serviceUrl: 'http://localhost:8420/api/v1',
18
18
  apiKey: '',
19
19
  timeoutMs: 5000,
20
20
  enabled: true,
@@ -48,13 +48,23 @@ export function loadConfig() {
48
48
  // Config file unreadable — use defaults
49
49
  }
50
50
  }
51
+ else {
52
+ console.warn(`[SafeClaw] No config file found at ${CONFIG_PATH} — using defaults (serviceUrl=${defaults.serviceUrl})`);
53
+ }
51
54
  // Env vars override config file
52
55
  if (process.env.SAFECLAW_URL)
53
56
  defaults.serviceUrl = process.env.SAFECLAW_URL;
54
57
  if (process.env.SAFECLAW_API_KEY)
55
58
  defaults.apiKey = process.env.SAFECLAW_API_KEY;
56
- if (process.env.SAFECLAW_TIMEOUT_MS)
57
- defaults.timeoutMs = parseInt(process.env.SAFECLAW_TIMEOUT_MS, 10);
59
+ if (process.env.SAFECLAW_TIMEOUT_MS) {
60
+ const parsed = parseInt(process.env.SAFECLAW_TIMEOUT_MS, 10);
61
+ if (!Number.isNaN(parsed) && parsed > 0) {
62
+ defaults.timeoutMs = parsed;
63
+ }
64
+ else {
65
+ console.warn(`[SafeClaw] Invalid SAFECLAW_TIMEOUT_MS="${process.env.SAFECLAW_TIMEOUT_MS}", using default ${defaults.timeoutMs}ms`);
66
+ }
67
+ }
58
68
  if (process.env.SAFECLAW_ENABLED === 'false')
59
69
  defaults.enabled = false;
60
70
  if (process.env.SAFECLAW_ENFORCEMENT)
@@ -78,6 +88,20 @@ export function loadConfig() {
78
88
  }
79
89
  return defaults;
80
90
  }
91
+ /**
92
+ * Check whether a URL is a valid http:// or https:// URL.
93
+ */
94
+ export function isValidServiceUrl(url) {
95
+ if (!url || url.trim() === '')
96
+ return false;
97
+ try {
98
+ const parsed = new URL(url);
99
+ return parsed.protocol === 'http:' || parsed.protocol === 'https:';
100
+ }
101
+ catch {
102
+ return false;
103
+ }
104
+ }
81
105
  /**
82
106
  * Persist managed config fields back to ~/.safeclaw/config.json.
83
107
  * Reads the existing file (if any), merges the fields SafeClaw manages,
@@ -93,17 +117,32 @@ export function saveConfig(config) {
93
117
  // Unreadable — start fresh
94
118
  }
95
119
  }
120
+ // Normalize serviceUrl: strip trailing slashes (consistent with loadConfig)
121
+ config.serviceUrl = config.serviceUrl.replace(/\/+$/, '');
122
+ // Validate serviceUrl before saving
123
+ if (!isValidServiceUrl(config.serviceUrl)) {
124
+ throw new Error(`Invalid service URL: "${config.serviceUrl}" — must be a valid http:// or https:// URL`);
125
+ }
96
126
  // Merge managed fields into existing structure
97
127
  existing.enabled = config.enabled;
98
128
  if (!existing.remote || typeof existing.remote !== 'object') {
99
129
  existing.remote = {};
100
130
  }
101
131
  existing.remote.serviceUrl = config.serviceUrl;
132
+ if (config.apiKey) {
133
+ existing.remote.apiKey = config.apiKey;
134
+ }
102
135
  if (!existing.enforcement || typeof existing.enforcement !== 'object') {
103
136
  existing.enforcement = {};
104
137
  }
105
138
  existing.enforcement.mode = config.enforcement;
106
139
  existing.enforcement.failMode = config.failMode;
140
+ if (config.agentId) {
141
+ existing.agentId = config.agentId;
142
+ }
143
+ if (config.agentToken) {
144
+ existing.agentToken = config.agentToken;
145
+ }
107
146
  // Ensure parent directory exists
108
147
  mkdirSync(dirname(CONFIG_PATH), { recursive: true, mode: 0o700 });
109
148
  writeFileSync(CONFIG_PATH, JSON.stringify(existing, null, 2) + '\n', { encoding: 'utf-8', mode: 0o600 });