openclaw-safeclaw-plugin 0.9.2 → 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,29 +7,45 @@
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
30
45
  try {
31
46
  const errBody = await res.json();
32
- const detail = errBody.detail ?? `HTTP ${res.status}`;
47
+ const rawDetail = errBody.detail ?? `HTTP ${res.status}`;
48
+ const detail = typeof rawDetail === 'string' ? rawDetail : JSON.stringify(rawDetail);
33
49
  const hint = errBody.hint ? ` (${errBody.hint})` : '';
34
50
  console.warn(`[SafeClaw] ${path}: ${detail}${hint}`);
35
51
  }
@@ -42,26 +58,27 @@ async function post(path, body) {
42
58
  }
43
59
  catch (e) {
44
60
  if (e instanceof DOMException && e.name === 'TimeoutError') {
45
- 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})`);
46
62
  }
47
63
  else if (e instanceof TypeError && (e.message.includes('fetch') || e.message.includes('ECONNREFUSED'))) {
48
- 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?`);
49
65
  }
50
66
  else {
51
- console.warn(`[SafeClaw] Service unavailable: ${config.serviceUrl}${path}`);
67
+ console.warn(`[SafeClaw] Service unavailable: ${cfg.serviceUrl}${path}`);
52
68
  }
53
69
  return null; // Caller checks failMode
54
70
  }
55
71
  }
56
72
  let handshakeCompleted = false;
57
73
  async function performHandshake() {
58
- if (!config.apiKey) {
74
+ const cfg = getConfig();
75
+ if (!cfg.apiKey) {
59
76
  console.warn('[SafeClaw] No API key configured — skipping handshake');
60
77
  return false;
61
78
  }
62
79
  const r = await post('/handshake', {
63
- pluginVersion: '0.1.3',
64
- configHash: configHash(config),
80
+ pluginVersion: PLUGIN_VERSION,
81
+ configHash: configHash(cfg),
65
82
  });
66
83
  if (r === null) {
67
84
  console.warn('[SafeClaw] ✗ Handshake failed — API key may be invalid or service unreachable');
@@ -72,12 +89,13 @@ async function performHandshake() {
72
89
  return true;
73
90
  }
74
91
  async function checkConnection() {
92
+ const cfg = getConfig();
75
93
  const label = `[SafeClaw]`;
76
- console.log(`${label} Connecting to ${config.serviceUrl} ...`);
77
- 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}`);
78
96
  try {
79
- const res = await fetch(`${config.serviceUrl}/health`, {
80
- signal: AbortSignal.timeout(config.timeoutMs * 2),
97
+ const res = await fetch(`${cfg.serviceUrl}/health`, {
98
+ signal: AbortSignal.timeout(cfg.timeoutMs * 2),
81
99
  });
82
100
  if (res.ok) {
83
101
  const data = await res.json();
@@ -88,8 +106,8 @@ async function checkConnection() {
88
106
  }
89
107
  }
90
108
  catch {
91
- console.warn(`${label} ✗ Cannot reach service at ${config.serviceUrl}`);
92
- if (config.failMode === 'closed') {
109
+ console.warn(`${label} ✗ Cannot reach service at ${cfg.serviceUrl}`);
110
+ if (cfg.failMode === 'closed') {
93
111
  console.warn(`${label} fail-mode=closed → tool calls will be BLOCKED until service is reachable`);
94
112
  }
95
113
  else {
@@ -100,60 +118,61 @@ async function checkConnection() {
100
118
  export default {
101
119
  id: 'safeclaw',
102
120
  name: 'SafeClaw Neurosymbolic Governance',
103
- version: '0.1.3',
121
+ version: PLUGIN_VERSION,
104
122
  register(api) {
105
- if (!config.enabled) {
123
+ if (!getConfig().enabled) {
106
124
  console.log('[SafeClaw] Plugin disabled');
107
125
  return;
108
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()}`;
109
129
  // Heartbeat watchdog — send config hash to service every 30s
110
130
  const sendHeartbeat = async () => {
111
131
  try {
112
- await fetch(`${config.serviceUrl}/heartbeat`, {
113
- method: 'POST',
114
- headers: { 'Content-Type': 'application/json' },
115
- body: JSON.stringify({
116
- agentId: config.agentId || 'default',
117
- configHash: configHash(config),
118
- status: 'alive',
119
- }),
120
- signal: AbortSignal.timeout(config.timeoutMs),
132
+ await post('/heartbeat', {
133
+ agentId: instanceId,
134
+ configHash: configHash(getConfig()),
135
+ status: 'alive',
121
136
  });
122
137
  }
123
138
  catch {
124
139
  // Heartbeat failure is non-fatal
125
140
  }
126
141
  };
127
- // Start heartbeat after connection check + handshake
142
+ // Start heartbeat only after connection check + handshake completes (#84)
143
+ let heartbeatInterval;
128
144
  checkConnection()
129
145
  .then(() => performHandshake())
130
146
  .then((ok) => {
131
- if (!ok && config.failMode === 'closed') {
132
- 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');
133
149
  }
150
+ heartbeatInterval = setInterval(sendHeartbeat, 30000);
134
151
  return sendHeartbeat();
135
152
  })
136
153
  .catch(() => { });
137
- const heartbeatInterval = setInterval(sendHeartbeat, 30000);
138
154
  // Clean shutdown: send shutdown heartbeat and clear interval
139
- const shutdown = () => {
140
- clearInterval(heartbeatInterval);
141
- fetch(`${config.serviceUrl}/heartbeat`, {
142
- method: 'POST',
143
- headers: { 'Content-Type': 'application/json' },
144
- body: JSON.stringify({
145
- agentId: config.agentId || 'default',
146
- configHash: configHash(config),
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()),
147
163
  status: 'shutdown',
148
- }),
149
- }).catch(() => { });
164
+ });
165
+ }
166
+ catch {
167
+ // Best-effort shutdown notification
168
+ }
150
169
  };
151
- process.on('exit', shutdown);
152
- process.on('SIGINT', () => { shutdown(); process.exit(0); });
153
- 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); });
154
172
  // THE GATE — constraint checking on every tool call
155
173
  api.on('before_tool_call', async (event, ctx) => {
156
- if (!handshakeCompleted && config.failMode === 'closed' && config.enforcement === 'enforce') {
174
+ const cfg = getConfig();
175
+ if (!handshakeCompleted && cfg.failMode === 'closed' && cfg.enforcement === 'enforce') {
157
176
  return { block: true, blockReason: 'SafeClaw handshake not completed (fail-closed)' };
158
177
  }
159
178
  const r = await post('/evaluate/tool-call', {
@@ -163,21 +182,22 @@ export default {
163
182
  params: event.params ?? {},
164
183
  sessionHistory: event.sessionHistory ?? [],
165
184
  });
166
- if (r === null && config.failMode === 'closed' && config.enforcement === 'enforce') {
167
- 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)` };
168
187
  }
169
- else if (r === null && config.failMode === 'closed' && config.enforcement === 'warn-only') {
170
- 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)`);
171
190
  }
172
- else if (r === null && config.failMode === 'closed' && config.enforcement === 'audit-only') {
173
- 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)`);
174
193
  }
175
194
  if (r?.block) {
176
- if (config.enforcement === 'enforce') {
177
- 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 };
178
198
  }
179
- if (config.enforcement === 'warn-only') {
180
- console.warn(`[SafeClaw] Warning: ${r.reason}`);
199
+ if (cfg.enforcement === 'warn-only') {
200
+ console.warn(`[SafeClaw] Warning: ${blockReason}`);
181
201
  }
182
202
  // audit-only: logged server-side, no action here
183
203
  }
@@ -194,24 +214,29 @@ export default {
194
214
  }, { priority: 100 });
195
215
  // Message governance — check outbound messages
196
216
  api.on('message_sending', async (event, ctx) => {
217
+ const cfg = getConfig();
197
218
  const r = await post('/evaluate/message', {
198
219
  sessionId: ctx.sessionId ?? event.sessionId,
199
220
  userId: ctx.userId ?? event.userId,
200
221
  to: event.to,
201
222
  content: event.content,
202
223
  });
203
- if (r === null && config.failMode === 'closed' && config.enforcement === 'enforce') {
204
- return { cancel: true };
224
+ if (r === null && cfg.failMode === 'closed' && cfg.enforcement === 'enforce') {
225
+ return { cancel: true, cancelReason: 'SafeClaw service unavailable (fail-closed mode)' };
205
226
  }
206
- else if (r === null && config.failMode === 'closed' && config.enforcement === 'warn-only') {
227
+ else if (r === null && cfg.failMode === 'closed' && cfg.enforcement === 'warn-only') {
207
228
  console.warn('[SafeClaw] Service unavailable (fail-closed mode, warn-only)');
208
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
+ }
209
233
  if (r?.block) {
210
- if (config.enforcement === 'enforce') {
211
- 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 };
212
237
  }
213
- if (config.enforcement === 'warn-only') {
214
- console.warn(`[SafeClaw] Warning: ${r.reason}`);
238
+ if (cfg.enforcement === 'warn-only') {
239
+ console.warn(`[SafeClaw] Warning: ${blockReason}`);
215
240
  }
216
241
  // audit-only: logged server-side, no action here
217
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,20 +117,35 @@ 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
- mkdirSync(dirname(CONFIG_PATH), { recursive: true });
109
- writeFileSync(CONFIG_PATH, JSON.stringify(existing, null, 2) + '\n', 'utf-8');
147
+ mkdirSync(dirname(CONFIG_PATH), { recursive: true, mode: 0o700 });
148
+ writeFileSync(CONFIG_PATH, JSON.stringify(existing, null, 2) + '\n', { encoding: 'utf-8', mode: 0o600 });
110
149
  }
111
150
  /**
112
151
  * SHA-256 hash of the four TUI-managed config fields.