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/README.md +9 -7
- package/cli.tsx +116 -26
- package/dist/cli.js +120 -26
- package/dist/index.js +91 -66
- package/dist/tui/About.js +1 -1
- package/dist/tui/App.js +1 -1
- package/dist/tui/Settings.js +25 -6
- package/dist/tui/Status.js +94 -2
- package/dist/tui/config.d.ts +4 -0
- package/dist/tui/config.js +44 -5
- package/index.ts +92 -66
- package/package.json +2 -2
- package/tui/About.tsx +21 -4
- package/tui/App.tsx +1 -1
- package/tui/Settings.tsx +31 -7
- package/tui/Status.tsx +128 -2
- package/tui/config.ts +44 -4
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
|
|
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
|
-
|
|
28
|
+
const cfg = getConfig();
|
|
29
|
+
if (!cfg.enabled)
|
|
15
30
|
return null;
|
|
16
31
|
const headers = { 'Content-Type': 'application/json' };
|
|
17
|
-
if (
|
|
18
|
-
headers['Authorization'] = `Bearer ${
|
|
32
|
+
if (cfg.apiKey) {
|
|
33
|
+
headers['Authorization'] = `Bearer ${cfg.apiKey}`;
|
|
19
34
|
}
|
|
20
|
-
const agentFields =
|
|
35
|
+
const agentFields = cfg.agentId ? { agentId: cfg.agentId, agentToken: cfg.agentToken } : {};
|
|
21
36
|
try {
|
|
22
|
-
const res = await fetch(`${
|
|
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(
|
|
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
|
|
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 ${
|
|
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: ${
|
|
64
|
+
console.warn(`[SafeClaw] Connection refused: ${cfg.serviceUrl}${path} — is the service running?`);
|
|
49
65
|
}
|
|
50
66
|
else {
|
|
51
|
-
console.warn(`[SafeClaw] Service unavailable: ${
|
|
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
|
-
|
|
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:
|
|
64
|
-
configHash: configHash(
|
|
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 ${
|
|
77
|
-
console.log(`${label} Mode: enforcement=${
|
|
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(`${
|
|
80
|
-
signal: AbortSignal.timeout(
|
|
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 ${
|
|
92
|
-
if (
|
|
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:
|
|
121
|
+
version: PLUGIN_VERSION,
|
|
104
122
|
register(api) {
|
|
105
|
-
if (!
|
|
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
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
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 &&
|
|
132
|
-
console.warn('[SafeClaw]
|
|
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
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
agentId:
|
|
146
|
-
configHash: configHash(
|
|
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
|
-
}
|
|
164
|
+
});
|
|
165
|
+
}
|
|
166
|
+
catch {
|
|
167
|
+
// Best-effort shutdown notification
|
|
168
|
+
}
|
|
150
169
|
};
|
|
151
|
-
process.on('
|
|
152
|
-
process.on('
|
|
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
|
-
|
|
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 &&
|
|
167
|
-
return { block: true, blockReason: `SafeClaw service unavailable at ${
|
|
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 &&
|
|
170
|
-
console.warn(`[SafeClaw] Service unavailable at ${
|
|
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 &&
|
|
173
|
-
console.warn(`[SafeClaw] Service unavailable at ${
|
|
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
|
-
|
|
177
|
-
|
|
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 (
|
|
180
|
-
console.warn(`[SafeClaw] Warning: ${
|
|
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 &&
|
|
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 &&
|
|
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
|
-
|
|
211
|
-
|
|
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 (
|
|
214
|
-
console.warn(`[SafeClaw] Warning: ${
|
|
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: "
|
|
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
|
|
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
|
}
|
package/dist/tui/Settings.js
CHANGED
|
@@ -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
|
-
|
|
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.
|
|
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.
|
|
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
|
-
?
|
|
83
|
-
|
|
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
|
}
|
package/dist/tui/Status.js
CHANGED
|
@@ -1,13 +1,22 @@
|
|
|
1
|
-
import { jsx as _jsx,
|
|
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
|
-
|
|
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
|
}
|
package/dist/tui/config.d.ts
CHANGED
|
@@ -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,
|
package/dist/tui/config.js
CHANGED
|
@@ -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: '
|
|
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
|
-
|
|
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.
|