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/index.ts
CHANGED
|
@@ -8,35 +8,54 @@
|
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
10
|
import { loadConfig, configHash } from './tui/config.js';
|
|
11
|
+
import crypto from 'crypto';
|
|
12
|
+
import { createRequire } from 'module';
|
|
13
|
+
|
|
14
|
+
const require = createRequire(import.meta.url);
|
|
15
|
+
const { version: PLUGIN_VERSION } = require('./package.json') as { version: string };
|
|
11
16
|
|
|
12
17
|
// --- Configuration ---
|
|
13
18
|
|
|
14
|
-
const
|
|
19
|
+
const CONFIG_RELOAD_INTERVAL_MS = 60_000; // Reload config every 60 seconds
|
|
20
|
+
|
|
21
|
+
let config = loadConfig();
|
|
22
|
+
let configLoadedAt = Date.now();
|
|
23
|
+
|
|
24
|
+
function getConfig(): typeof config {
|
|
25
|
+
const now = Date.now();
|
|
26
|
+
if (now - configLoadedAt >= CONFIG_RELOAD_INTERVAL_MS) {
|
|
27
|
+
config = loadConfig();
|
|
28
|
+
configLoadedAt = now;
|
|
29
|
+
}
|
|
30
|
+
return config;
|
|
31
|
+
}
|
|
15
32
|
|
|
16
33
|
// --- HTTP Client ---
|
|
17
34
|
|
|
18
35
|
async function post(path: string, body: Record<string, unknown>): Promise<Record<string, unknown> | null> {
|
|
19
|
-
|
|
36
|
+
const cfg = getConfig();
|
|
37
|
+
if (!cfg.enabled) return null;
|
|
20
38
|
|
|
21
39
|
const headers: Record<string, string> = { 'Content-Type': 'application/json' };
|
|
22
|
-
if (
|
|
23
|
-
headers['Authorization'] = `Bearer ${
|
|
40
|
+
if (cfg.apiKey) {
|
|
41
|
+
headers['Authorization'] = `Bearer ${cfg.apiKey}`;
|
|
24
42
|
}
|
|
25
43
|
|
|
26
|
-
const agentFields =
|
|
44
|
+
const agentFields = cfg.agentId ? { agentId: cfg.agentId, agentToken: cfg.agentToken } : {};
|
|
27
45
|
|
|
28
46
|
try {
|
|
29
|
-
const res = await fetch(`${
|
|
47
|
+
const res = await fetch(`${cfg.serviceUrl}${path}`, {
|
|
30
48
|
method: 'POST',
|
|
31
49
|
headers,
|
|
32
50
|
body: JSON.stringify({ ...body, ...agentFields }),
|
|
33
|
-
signal: AbortSignal.timeout(
|
|
51
|
+
signal: AbortSignal.timeout(cfg.timeoutMs),
|
|
34
52
|
});
|
|
35
53
|
if (!res.ok) {
|
|
36
54
|
// Try to parse structured error body from service
|
|
37
55
|
try {
|
|
38
56
|
const errBody = await res.json() as Record<string, unknown>;
|
|
39
|
-
const
|
|
57
|
+
const rawDetail = errBody.detail ?? `HTTP ${res.status}`;
|
|
58
|
+
const detail = typeof rawDetail === 'string' ? rawDetail : JSON.stringify(rawDetail);
|
|
40
59
|
const hint = errBody.hint ? ` (${errBody.hint})` : '';
|
|
41
60
|
console.warn(`[SafeClaw] ${path}: ${detail}${hint}`);
|
|
42
61
|
} catch {
|
|
@@ -47,11 +66,11 @@ async function post(path: string, body: Record<string, unknown>): Promise<Record
|
|
|
47
66
|
return await res.json() as Record<string, unknown>;
|
|
48
67
|
} catch (e) {
|
|
49
68
|
if (e instanceof DOMException && e.name === 'TimeoutError') {
|
|
50
|
-
console.warn(`[SafeClaw] Timeout after ${
|
|
69
|
+
console.warn(`[SafeClaw] Timeout after ${cfg.timeoutMs}ms on ${path} (${cfg.serviceUrl})`);
|
|
51
70
|
} else if (e instanceof TypeError && (e.message.includes('fetch') || e.message.includes('ECONNREFUSED'))) {
|
|
52
|
-
console.warn(`[SafeClaw] Connection refused: ${
|
|
71
|
+
console.warn(`[SafeClaw] Connection refused: ${cfg.serviceUrl}${path} — is the service running?`);
|
|
53
72
|
} else {
|
|
54
|
-
console.warn(`[SafeClaw] Service unavailable: ${
|
|
73
|
+
console.warn(`[SafeClaw] Service unavailable: ${cfg.serviceUrl}${path}`);
|
|
55
74
|
}
|
|
56
75
|
return null; // Caller checks failMode
|
|
57
76
|
}
|
|
@@ -82,14 +101,15 @@ interface PluginApi {
|
|
|
82
101
|
let handshakeCompleted = false;
|
|
83
102
|
|
|
84
103
|
async function performHandshake(): Promise<boolean> {
|
|
85
|
-
|
|
104
|
+
const cfg = getConfig();
|
|
105
|
+
if (!cfg.apiKey) {
|
|
86
106
|
console.warn('[SafeClaw] No API key configured — skipping handshake');
|
|
87
107
|
return false;
|
|
88
108
|
}
|
|
89
109
|
|
|
90
110
|
const r = await post('/handshake', {
|
|
91
|
-
pluginVersion:
|
|
92
|
-
configHash: configHash(
|
|
111
|
+
pluginVersion: PLUGIN_VERSION,
|
|
112
|
+
configHash: configHash(cfg),
|
|
93
113
|
});
|
|
94
114
|
|
|
95
115
|
if (r === null) {
|
|
@@ -103,13 +123,14 @@ async function performHandshake(): Promise<boolean> {
|
|
|
103
123
|
}
|
|
104
124
|
|
|
105
125
|
async function checkConnection(): Promise<void> {
|
|
126
|
+
const cfg = getConfig();
|
|
106
127
|
const label = `[SafeClaw]`;
|
|
107
|
-
console.log(`${label} Connecting to ${
|
|
108
|
-
console.log(`${label} Mode: enforcement=${
|
|
128
|
+
console.log(`${label} Connecting to ${cfg.serviceUrl} ...`);
|
|
129
|
+
console.log(`${label} Mode: enforcement=${cfg.enforcement}, failMode=${cfg.failMode}`);
|
|
109
130
|
|
|
110
131
|
try {
|
|
111
|
-
const res = await fetch(`${
|
|
112
|
-
signal: AbortSignal.timeout(
|
|
132
|
+
const res = await fetch(`${cfg.serviceUrl}/health`, {
|
|
133
|
+
signal: AbortSignal.timeout(cfg.timeoutMs * 2),
|
|
113
134
|
});
|
|
114
135
|
if (res.ok) {
|
|
115
136
|
const data = await res.json() as Record<string, unknown>;
|
|
@@ -118,8 +139,8 @@ async function checkConnection(): Promise<void> {
|
|
|
118
139
|
console.warn(`${label} ✗ Service responded with HTTP ${res.status}`);
|
|
119
140
|
}
|
|
120
141
|
} catch {
|
|
121
|
-
console.warn(`${label} ✗ Cannot reach service at ${
|
|
122
|
-
if (
|
|
142
|
+
console.warn(`${label} ✗ Cannot reach service at ${cfg.serviceUrl}`);
|
|
143
|
+
if (cfg.failMode === 'closed') {
|
|
123
144
|
console.warn(`${label} fail-mode=closed → tool calls will be BLOCKED until service is reachable`);
|
|
124
145
|
} else {
|
|
125
146
|
console.warn(`${label} fail-mode=open → tool calls will be ALLOWED despite no connection`);
|
|
@@ -130,64 +151,64 @@ async function checkConnection(): Promise<void> {
|
|
|
130
151
|
export default {
|
|
131
152
|
id: 'safeclaw',
|
|
132
153
|
name: 'SafeClaw Neurosymbolic Governance',
|
|
133
|
-
version:
|
|
154
|
+
version: PLUGIN_VERSION,
|
|
134
155
|
|
|
135
156
|
register(api: PluginApi) {
|
|
136
|
-
if (!
|
|
157
|
+
if (!getConfig().enabled) {
|
|
137
158
|
console.log('[SafeClaw] Plugin disabled');
|
|
138
159
|
return;
|
|
139
160
|
}
|
|
140
161
|
|
|
162
|
+
// Generate a unique instance ID for this plugin run (fallback when agentId is not configured)
|
|
163
|
+
const instanceId = getConfig().agentId || `instance-${crypto.randomUUID()}`;
|
|
164
|
+
|
|
141
165
|
// Heartbeat watchdog — send config hash to service every 30s
|
|
142
166
|
const sendHeartbeat = async () => {
|
|
143
167
|
try {
|
|
144
|
-
await
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
agentId: config.agentId || 'default',
|
|
149
|
-
configHash: configHash(config),
|
|
150
|
-
status: 'alive',
|
|
151
|
-
}),
|
|
152
|
-
signal: AbortSignal.timeout(config.timeoutMs),
|
|
168
|
+
await post('/heartbeat', {
|
|
169
|
+
agentId: instanceId,
|
|
170
|
+
configHash: configHash(getConfig()),
|
|
171
|
+
status: 'alive',
|
|
153
172
|
});
|
|
154
173
|
} catch {
|
|
155
174
|
// Heartbeat failure is non-fatal
|
|
156
175
|
}
|
|
157
176
|
};
|
|
158
177
|
|
|
159
|
-
// Start heartbeat after connection check + handshake
|
|
178
|
+
// Start heartbeat only after connection check + handshake completes (#84)
|
|
179
|
+
let heartbeatInterval: ReturnType<typeof setInterval> | undefined;
|
|
160
180
|
checkConnection()
|
|
161
181
|
.then(() => performHandshake())
|
|
162
182
|
.then((ok) => {
|
|
163
|
-
if (!ok &&
|
|
164
|
-
console.warn('[SafeClaw]
|
|
183
|
+
if (!ok && getConfig().failMode === 'closed') {
|
|
184
|
+
console.warn('[SafeClaw] Handshake failed with fail-mode=closed — tool calls will be BLOCKED');
|
|
165
185
|
}
|
|
186
|
+
heartbeatInterval = setInterval(sendHeartbeat, 30000);
|
|
166
187
|
return sendHeartbeat();
|
|
167
188
|
})
|
|
168
189
|
.catch(() => {});
|
|
169
|
-
const heartbeatInterval = setInterval(sendHeartbeat, 30000);
|
|
170
190
|
|
|
171
191
|
// Clean shutdown: send shutdown heartbeat and clear interval
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
configHash: configHash(config),
|
|
192
|
+
// Use async shutdown for SIGINT/SIGTERM where async is supported (#55)
|
|
193
|
+
const shutdown = async () => {
|
|
194
|
+
if (heartbeatInterval) clearInterval(heartbeatInterval);
|
|
195
|
+
try {
|
|
196
|
+
await post('/heartbeat', {
|
|
197
|
+
agentId: instanceId,
|
|
198
|
+
configHash: configHash(getConfig()),
|
|
180
199
|
status: 'shutdown',
|
|
181
|
-
})
|
|
182
|
-
}
|
|
200
|
+
});
|
|
201
|
+
} catch {
|
|
202
|
+
// Best-effort shutdown notification
|
|
203
|
+
}
|
|
183
204
|
};
|
|
184
|
-
process.on('
|
|
185
|
-
process.on('
|
|
186
|
-
process.on('SIGTERM', () => { shutdown(); process.exit(0); });
|
|
205
|
+
process.on('SIGINT', async () => { await shutdown(); process.exit(0); });
|
|
206
|
+
process.on('SIGTERM', async () => { await shutdown(); process.exit(0); });
|
|
187
207
|
|
|
188
208
|
// THE GATE — constraint checking on every tool call
|
|
189
209
|
api.on('before_tool_call', async (event: PluginEvent, ctx: PluginContext) => {
|
|
190
|
-
|
|
210
|
+
const cfg = getConfig();
|
|
211
|
+
if (!handshakeCompleted && cfg.failMode === 'closed' && cfg.enforcement === 'enforce') {
|
|
191
212
|
return { block: true, blockReason: 'SafeClaw handshake not completed (fail-closed)' };
|
|
192
213
|
}
|
|
193
214
|
|
|
@@ -199,19 +220,20 @@ export default {
|
|
|
199
220
|
sessionHistory: event.sessionHistory ?? [],
|
|
200
221
|
});
|
|
201
222
|
|
|
202
|
-
if (r === null &&
|
|
203
|
-
return { block: true, blockReason: `SafeClaw service unavailable at ${
|
|
204
|
-
} else if (r === null &&
|
|
205
|
-
console.warn(`[SafeClaw] Service unavailable at ${
|
|
206
|
-
} else if (r === null &&
|
|
207
|
-
console.warn(`[SafeClaw] Service unavailable at ${
|
|
223
|
+
if (r === null && cfg.failMode === 'closed' && cfg.enforcement === 'enforce') {
|
|
224
|
+
return { block: true, blockReason: `SafeClaw service unavailable at ${cfg.serviceUrl} (fail-closed)` };
|
|
225
|
+
} else if (r === null && cfg.failMode === 'closed' && cfg.enforcement === 'warn-only') {
|
|
226
|
+
console.warn(`[SafeClaw] Service unavailable at ${cfg.serviceUrl} (fail-closed mode, warn-only)`);
|
|
227
|
+
} else if (r === null && cfg.failMode === 'closed' && cfg.enforcement === 'audit-only') {
|
|
228
|
+
console.warn(`[SafeClaw] Service unavailable at ${cfg.serviceUrl} (fail-closed mode, audit-only)`);
|
|
208
229
|
}
|
|
209
230
|
if (r?.block) {
|
|
210
|
-
|
|
211
|
-
|
|
231
|
+
const blockReason = (r.reason as string) || 'Blocked by SafeClaw (no reason provided)';
|
|
232
|
+
if (cfg.enforcement === 'enforce') {
|
|
233
|
+
return { block: true, blockReason };
|
|
212
234
|
}
|
|
213
|
-
if (
|
|
214
|
-
console.warn(`[SafeClaw] Warning: ${
|
|
235
|
+
if (cfg.enforcement === 'warn-only') {
|
|
236
|
+
console.warn(`[SafeClaw] Warning: ${blockReason}`);
|
|
215
237
|
}
|
|
216
238
|
// audit-only: logged server-side, no action here
|
|
217
239
|
}
|
|
@@ -231,6 +253,7 @@ export default {
|
|
|
231
253
|
|
|
232
254
|
// Message governance — check outbound messages
|
|
233
255
|
api.on('message_sending', async (event: PluginEvent, ctx: PluginContext) => {
|
|
256
|
+
const cfg = getConfig();
|
|
234
257
|
const r = await post('/evaluate/message', {
|
|
235
258
|
sessionId: ctx.sessionId ?? event.sessionId,
|
|
236
259
|
userId: ctx.userId ?? event.userId,
|
|
@@ -238,17 +261,20 @@ export default {
|
|
|
238
261
|
content: event.content,
|
|
239
262
|
});
|
|
240
263
|
|
|
241
|
-
if (r === null &&
|
|
242
|
-
return { cancel: true };
|
|
243
|
-
} else if (r === null &&
|
|
264
|
+
if (r === null && cfg.failMode === 'closed' && cfg.enforcement === 'enforce') {
|
|
265
|
+
return { cancel: true, cancelReason: 'SafeClaw service unavailable (fail-closed mode)' };
|
|
266
|
+
} else if (r === null && cfg.failMode === 'closed' && cfg.enforcement === 'warn-only') {
|
|
244
267
|
console.warn('[SafeClaw] Service unavailable (fail-closed mode, warn-only)');
|
|
268
|
+
} else if (r === null && cfg.failMode === 'closed' && cfg.enforcement === 'audit-only') {
|
|
269
|
+
console.info('[SafeClaw] audit-only: service unreachable, allowing message (fail-closed)');
|
|
245
270
|
}
|
|
246
271
|
if (r?.block) {
|
|
247
|
-
|
|
248
|
-
|
|
272
|
+
const blockReason = (r.reason as string) || 'Blocked by SafeClaw (no reason provided)';
|
|
273
|
+
if (cfg.enforcement === 'enforce') {
|
|
274
|
+
return { cancel: true, cancelReason: blockReason };
|
|
249
275
|
}
|
|
250
|
-
if (
|
|
251
|
-
console.warn(`[SafeClaw] Warning: ${
|
|
276
|
+
if (cfg.enforcement === 'warn-only') {
|
|
277
|
+
console.warn(`[SafeClaw] Warning: ${blockReason}`);
|
|
252
278
|
}
|
|
253
279
|
// audit-only: logged server-side, no action here
|
|
254
280
|
}
|
package/package.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "openclaw-safeclaw-plugin",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "1.1.0",
|
|
4
4
|
"description": "SafeClaw Neurosymbolic Governance plugin for OpenClaw — validates AI agent actions against OWL ontologies and SHACL constraints",
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"types": "dist/index.d.ts",
|
|
7
7
|
"type": "module",
|
|
8
8
|
"bin": {
|
|
9
|
-
"safeclaw": "dist/cli.js"
|
|
9
|
+
"safeclaw-plugin": "dist/cli.js"
|
|
10
10
|
},
|
|
11
11
|
"scripts": {
|
|
12
12
|
"build": "tsc",
|
package/tui/About.tsx
CHANGED
|
@@ -5,11 +5,28 @@ export default function About() {
|
|
|
5
5
|
return (
|
|
6
6
|
<Box flexDirection="column" paddingX={1}>
|
|
7
7
|
<Box marginBottom={1}>
|
|
8
|
-
<Text bold>About</Text>
|
|
8
|
+
<Text bold>About SafeClaw</Text>
|
|
9
9
|
</Box>
|
|
10
|
-
<Text>
|
|
11
|
-
<Text dimColor> Validates
|
|
12
|
-
<Text dimColor> ontologies and SHACL constraints.</Text>
|
|
10
|
+
<Text> Neurosymbolic governance for AI agents.</Text>
|
|
11
|
+
<Text dimColor> Validates tool calls, messages, and actions against</Text>
|
|
12
|
+
<Text dimColor> OWL ontologies and SHACL constraints before execution.</Text>
|
|
13
|
+
|
|
14
|
+
<Box marginTop={1} marginBottom={1}>
|
|
15
|
+
<Text bold> Features</Text>
|
|
16
|
+
</Box>
|
|
17
|
+
<Text dimColor> - 9-step constraint pipeline (SHACL, policies, preferences, dependencies)</Text>
|
|
18
|
+
<Text dimColor> - Role-based access control with per-user preferences</Text>
|
|
19
|
+
<Text dimColor> - Multi-agent governance with delegation detection</Text>
|
|
20
|
+
<Text dimColor> - Append-only audit trail with compliance reporting</Text>
|
|
21
|
+
<Text dimColor> - Passive LLM security reviewer and classification observer</Text>
|
|
22
|
+
<Text dimColor> - Natural language policy compilation</Text>
|
|
23
|
+
|
|
24
|
+
<Box marginTop={1} marginBottom={1}>
|
|
25
|
+
<Text bold> CLI commands</Text>
|
|
26
|
+
</Box>
|
|
27
|
+
<Text dimColor> safeclaw-plugin This plugin CLI (connect, status, config, tui)</Text>
|
|
28
|
+
<Text dimColor> safeclaw Service CLI (serve, audit, policy, pref, status)</Text>
|
|
29
|
+
|
|
13
30
|
<Box marginTop={1}>
|
|
14
31
|
<Text> Web: </Text>
|
|
15
32
|
<Text color="cyan">https://safeclaw.eu</Text>
|
package/tui/App.tsx
CHANGED
package/tui/Settings.tsx
CHANGED
|
@@ -22,6 +22,7 @@ const SETTINGS: SettingItem[] = [
|
|
|
22
22
|
{ key: 'enforcement', label: 'Enforcement', type: 'cycle', values: ENFORCEMENT_MODES },
|
|
23
23
|
{ key: 'failMode', label: 'Fail Mode', type: 'cycle', values: FAIL_MODES },
|
|
24
24
|
{ key: 'serviceUrl', label: 'Service URL', type: 'text' },
|
|
25
|
+
{ key: 'apiKey', label: 'API Key', type: 'text' },
|
|
25
26
|
];
|
|
26
27
|
|
|
27
28
|
export default function Settings({ config, onConfigChange }: SettingsProps) {
|
|
@@ -38,7 +39,17 @@ export default function Settings({ config, onConfigChange }: SettingsProps) {
|
|
|
38
39
|
useInput((input, key) => {
|
|
39
40
|
if (editing) {
|
|
40
41
|
if (key.return) {
|
|
41
|
-
|
|
42
|
+
const editingKey = SETTINGS[selected].key;
|
|
43
|
+
if (editingKey === 'apiKey' && editBuffer && !editBuffer.startsWith('sc_')) {
|
|
44
|
+
// Reject invalid API keys — must start with sc_
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
try {
|
|
48
|
+
updateConfig({ [editingKey]: editBuffer } as Partial<SafeClawConfig>);
|
|
49
|
+
} catch {
|
|
50
|
+
// saveConfig may throw on invalid URL — stay in edit mode
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
42
53
|
setEditing(false);
|
|
43
54
|
} else if (key.escape) {
|
|
44
55
|
setEditing(false);
|
|
@@ -67,7 +78,7 @@ export default function Settings({ config, onConfigChange }: SettingsProps) {
|
|
|
67
78
|
updateConfig({ [currentKey]: next });
|
|
68
79
|
} else if (setting.type === 'text' && key.return) {
|
|
69
80
|
setEditing(true);
|
|
70
|
-
setEditBuffer(config.
|
|
81
|
+
setEditBuffer(String(config[setting.key as keyof SafeClawConfig] ?? ''));
|
|
71
82
|
}
|
|
72
83
|
}
|
|
73
84
|
});
|
|
@@ -85,8 +96,12 @@ export default function Settings({ config, onConfigChange }: SettingsProps) {
|
|
|
85
96
|
|
|
86
97
|
if (setting.key === 'enabled') {
|
|
87
98
|
value = config.enabled ? 'ON' : 'OFF';
|
|
88
|
-
} else if (setting.
|
|
89
|
-
value = editBuffer + '█';
|
|
99
|
+
} else if (setting.type === 'text' && editing && isSelected) {
|
|
100
|
+
value = (setting.key === 'apiKey' ? '*'.repeat(editBuffer.length) : editBuffer) + '█';
|
|
101
|
+
} else if (setting.key === 'apiKey') {
|
|
102
|
+
value = config.apiKey
|
|
103
|
+
? `${config.apiKey.slice(0, 6)}..${config.apiKey.slice(-4)}`
|
|
104
|
+
: '(not set)';
|
|
90
105
|
} else {
|
|
91
106
|
value = String(config[setting.key as keyof SafeClawConfig]);
|
|
92
107
|
}
|
|
@@ -117,13 +132,22 @@ export default function Settings({ config, onConfigChange }: SettingsProps) {
|
|
|
117
132
|
<Box marginTop={1}>
|
|
118
133
|
<Text dimColor>
|
|
119
134
|
{editing
|
|
120
|
-
?
|
|
121
|
-
|
|
135
|
+
? SETTINGS[selected].key === 'apiKey' && editBuffer && !editBuffer.startsWith('sc_')
|
|
136
|
+
? ' API key must start with sc_ · esc to cancel'
|
|
137
|
+
: ' type to edit · enter to save · esc to cancel'
|
|
138
|
+
: ' ↑↓ navigate · ←→/enter cycle/toggle · enter to edit text fields · q quit'}
|
|
122
139
|
</Text>
|
|
123
140
|
</Box>
|
|
124
141
|
|
|
142
|
+
<Box marginTop={1}>
|
|
143
|
+
<Text dimColor>{' Enforcement: enforce=block violations, warn-only=log only, audit-only=record only, disabled=off'}</Text>
|
|
144
|
+
</Box>
|
|
125
145
|
<Box>
|
|
126
|
-
<Text dimColor>{'
|
|
146
|
+
<Text dimColor>{' Fail Mode: open=allow if service unreachable, closed=block if service unreachable'}</Text>
|
|
147
|
+
</Box>
|
|
148
|
+
|
|
149
|
+
<Box marginTop={1}>
|
|
150
|
+
<Text dimColor>{' All changes save to ~/.safeclaw/config.json immediately.'}</Text>
|
|
127
151
|
</Box>
|
|
128
152
|
</Box>
|
|
129
153
|
);
|
package/tui/Status.tsx
CHANGED
|
@@ -1,8 +1,15 @@
|
|
|
1
1
|
import React, { useState, useEffect } from 'react';
|
|
2
2
|
import { Text, Box, useInput } from 'ink';
|
|
3
3
|
import { exec } from 'child_process';
|
|
4
|
+
import { existsSync, readFileSync } from 'fs';
|
|
5
|
+
import { join, dirname } from 'path';
|
|
6
|
+
import { homedir } from 'os';
|
|
7
|
+
import { fileURLToPath } from 'url';
|
|
4
8
|
import { type SafeClawConfig } from './config.js';
|
|
5
9
|
|
|
10
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
11
|
+
const PKG_VERSION = JSON.parse(readFileSync(join(__dirname, '..', '..', 'package.json'), 'utf-8')).version as string;
|
|
12
|
+
|
|
6
13
|
interface StatusProps {
|
|
7
14
|
config: SafeClawConfig;
|
|
8
15
|
}
|
|
@@ -13,6 +20,7 @@ interface HealthData {
|
|
|
13
20
|
engine_ready?: boolean;
|
|
14
21
|
}
|
|
15
22
|
|
|
23
|
+
type CheckStatus = 'checking' | 'ok' | 'fail';
|
|
16
24
|
type OpenClawStatus = 'checking' | 'running' | 'not running' | 'error';
|
|
17
25
|
|
|
18
26
|
export default function Status({ config }: StatusProps) {
|
|
@@ -21,6 +29,9 @@ export default function Status({ config }: StatusProps) {
|
|
|
21
29
|
const [lastCheck, setLastCheck] = useState<Date | null>(null);
|
|
22
30
|
const [openclawStatus, setOpenclawStatus] = useState<OpenClawStatus>('checking');
|
|
23
31
|
const [restartMsg, setRestartMsg] = useState<string | null>(null);
|
|
32
|
+
const [evaluateStatus, setEvaluateStatus] = useState<CheckStatus>('checking');
|
|
33
|
+
const [handshakeStatus, setHandshakeStatus] = useState<CheckStatus>('checking');
|
|
34
|
+
const [handshakeDetail, setHandshakeDetail] = useState('');
|
|
24
35
|
|
|
25
36
|
const checkHealth = async () => {
|
|
26
37
|
try {
|
|
@@ -31,17 +42,73 @@ export default function Status({ config }: StatusProps) {
|
|
|
31
42
|
const data = await res.json() as HealthData;
|
|
32
43
|
setHealth(data);
|
|
33
44
|
setError(null);
|
|
45
|
+
// If healthy, run deeper checks
|
|
46
|
+
checkEvaluate();
|
|
47
|
+
if (config.apiKey) checkHandshake();
|
|
48
|
+
else {
|
|
49
|
+
setHandshakeStatus('fail');
|
|
50
|
+
setHandshakeDetail('No API key');
|
|
51
|
+
}
|
|
34
52
|
} else {
|
|
35
53
|
setHealth(null);
|
|
36
54
|
setError(`HTTP ${res.status}`);
|
|
55
|
+
setEvaluateStatus('fail');
|
|
56
|
+
setHandshakeStatus('fail');
|
|
37
57
|
}
|
|
38
58
|
} catch {
|
|
39
59
|
setHealth(null);
|
|
40
60
|
setError('Cannot connect');
|
|
61
|
+
setEvaluateStatus('fail');
|
|
62
|
+
setHandshakeStatus('fail');
|
|
41
63
|
}
|
|
42
64
|
setLastCheck(new Date());
|
|
43
65
|
};
|
|
44
66
|
|
|
67
|
+
const checkEvaluate = async () => {
|
|
68
|
+
try {
|
|
69
|
+
const res = await fetch(`${config.serviceUrl}/evaluate/tool-call`, {
|
|
70
|
+
method: 'POST',
|
|
71
|
+
headers: {
|
|
72
|
+
'Content-Type': 'application/json',
|
|
73
|
+
...(config.apiKey ? { 'Authorization': `Bearer ${config.apiKey}` } : {}),
|
|
74
|
+
},
|
|
75
|
+
body: JSON.stringify({
|
|
76
|
+
sessionId: 'tui-check', userId: 'tui-check',
|
|
77
|
+
toolName: 'echo', params: { message: 'tui-check' },
|
|
78
|
+
}),
|
|
79
|
+
signal: AbortSignal.timeout(config.timeoutMs),
|
|
80
|
+
});
|
|
81
|
+
setEvaluateStatus(res.ok || res.status === 422 || res.status === 401 || res.status === 403 ? 'ok' : 'fail');
|
|
82
|
+
} catch {
|
|
83
|
+
setEvaluateStatus('fail');
|
|
84
|
+
}
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
const checkHandshake = async () => {
|
|
88
|
+
try {
|
|
89
|
+
const res = await fetch(`${config.serviceUrl}/handshake`, {
|
|
90
|
+
method: 'POST',
|
|
91
|
+
headers: {
|
|
92
|
+
'Content-Type': 'application/json',
|
|
93
|
+
'Authorization': `Bearer ${config.apiKey}`,
|
|
94
|
+
},
|
|
95
|
+
body: JSON.stringify({ pluginVersion: PKG_VERSION, configHash: '' }),
|
|
96
|
+
signal: AbortSignal.timeout(config.timeoutMs),
|
|
97
|
+
});
|
|
98
|
+
if (res.ok) {
|
|
99
|
+
const data = await res.json() as Record<string, unknown>;
|
|
100
|
+
setHandshakeStatus('ok');
|
|
101
|
+
setHandshakeDetail(`org=${data.orgId ?? '?'}`);
|
|
102
|
+
} else {
|
|
103
|
+
setHandshakeStatus('fail');
|
|
104
|
+
setHandshakeDetail(`HTTP ${res.status}`);
|
|
105
|
+
}
|
|
106
|
+
} catch {
|
|
107
|
+
setHandshakeStatus('fail');
|
|
108
|
+
setHandshakeDetail('timeout');
|
|
109
|
+
}
|
|
110
|
+
};
|
|
111
|
+
|
|
45
112
|
const checkOpenClaw = () => {
|
|
46
113
|
exec('openclaw daemon status', { timeout: 10000 }, (err, stdout) => {
|
|
47
114
|
if (err) {
|
|
@@ -82,6 +149,7 @@ export default function Status({ config }: StatusProps) {
|
|
|
82
149
|
}
|
|
83
150
|
});
|
|
84
151
|
|
|
152
|
+
// Derived display values
|
|
85
153
|
const connected = health !== null;
|
|
86
154
|
const dot = '●';
|
|
87
155
|
const serviceDotColor = connected ? 'green' : 'red';
|
|
@@ -94,6 +162,31 @@ export default function Status({ config }: StatusProps) {
|
|
|
94
162
|
: openclawStatus === 'running' ? 'Running'
|
|
95
163
|
: 'Not running';
|
|
96
164
|
|
|
165
|
+
// Plugin file checks
|
|
166
|
+
const extensionDir = join(homedir(), '.openclaw', 'extensions', 'safeclaw');
|
|
167
|
+
const pluginInstalled = existsSync(join(extensionDir, 'openclaw.plugin.json'))
|
|
168
|
+
&& existsSync(join(extensionDir, 'index.js'));
|
|
169
|
+
|
|
170
|
+
// Plugin enabled in OpenClaw config
|
|
171
|
+
let pluginEnabled = false;
|
|
172
|
+
const ocConfigPath = join(homedir(), '.openclaw', 'openclaw.json');
|
|
173
|
+
if (existsSync(ocConfigPath)) {
|
|
174
|
+
try {
|
|
175
|
+
const ocConfig = JSON.parse(readFileSync(ocConfigPath, 'utf-8'));
|
|
176
|
+
pluginEnabled = !!ocConfig?.plugins?.entries?.safeclaw?.enabled;
|
|
177
|
+
} catch { /* ignore */ }
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
const apiKeyMasked = config.apiKey
|
|
181
|
+
? `${config.apiKey.slice(0, 6)}..${config.apiKey.slice(-4)}`
|
|
182
|
+
: '(not set)';
|
|
183
|
+
|
|
184
|
+
const statusDot = (status: CheckStatus | boolean) => {
|
|
185
|
+
if (status === 'checking') return <Text color="yellow">{dot} </Text>;
|
|
186
|
+
if (status === 'ok' || status === true) return <Text color="green">{dot} </Text>;
|
|
187
|
+
return <Text color="red">{dot} </Text>;
|
|
188
|
+
};
|
|
189
|
+
|
|
97
190
|
return (
|
|
98
191
|
<Box flexDirection="column" paddingX={1}>
|
|
99
192
|
<Box marginBottom={1}>
|
|
@@ -101,18 +194,51 @@ export default function Status({ config }: StatusProps) {
|
|
|
101
194
|
</Box>
|
|
102
195
|
|
|
103
196
|
<Box>
|
|
104
|
-
<Text dimColor>{' Service
|
|
197
|
+
<Text dimColor>{' Service '}</Text>
|
|
105
198
|
<Text color={serviceDotColor}>{dot} </Text>
|
|
106
199
|
<Text>{serviceText}</Text>
|
|
107
200
|
</Box>
|
|
108
201
|
|
|
202
|
+
{connected && (
|
|
203
|
+
<>
|
|
204
|
+
<Box>
|
|
205
|
+
<Text dimColor>{' Evaluate '}</Text>
|
|
206
|
+
{statusDot(evaluateStatus)}
|
|
207
|
+
<Text>{evaluateStatus === 'ok' ? 'Responding' : evaluateStatus === 'checking' ? 'Checking...' : 'Not responding'}</Text>
|
|
208
|
+
</Box>
|
|
209
|
+
<Box>
|
|
210
|
+
<Text dimColor>{' Handshake '}</Text>
|
|
211
|
+
{statusDot(handshakeStatus)}
|
|
212
|
+
<Text>{handshakeStatus === 'ok' ? handshakeDetail : handshakeStatus === 'checking' ? 'Checking...' : handshakeDetail}</Text>
|
|
213
|
+
</Box>
|
|
214
|
+
</>
|
|
215
|
+
)}
|
|
216
|
+
|
|
109
217
|
<Box>
|
|
110
|
-
<Text dimColor>{' OpenClaw
|
|
218
|
+
<Text dimColor>{' OpenClaw '}</Text>
|
|
111
219
|
<Text color={openclawDotColor}>{dot} </Text>
|
|
112
220
|
<Text>{openclawText}</Text>
|
|
113
221
|
{restartMsg && <Text dimColor>{` (${restartMsg})`}</Text>}
|
|
114
222
|
</Box>
|
|
115
223
|
|
|
224
|
+
<Box>
|
|
225
|
+
<Text dimColor>{' Plugin '}</Text>
|
|
226
|
+
{statusDot(pluginInstalled)}
|
|
227
|
+
<Text>{pluginInstalled ? 'Installed' : 'Not installed'}</Text>
|
|
228
|
+
</Box>
|
|
229
|
+
|
|
230
|
+
<Box>
|
|
231
|
+
<Text dimColor>{' Config '}</Text>
|
|
232
|
+
{statusDot(pluginEnabled)}
|
|
233
|
+
<Text>{pluginEnabled ? 'Enabled' : 'Not enabled'}</Text>
|
|
234
|
+
</Box>
|
|
235
|
+
|
|
236
|
+
<Box>
|
|
237
|
+
<Text dimColor>{' API Key '}</Text>
|
|
238
|
+
{statusDot(!!config.apiKey)}
|
|
239
|
+
<Text>{apiKeyMasked}</Text>
|
|
240
|
+
</Box>
|
|
241
|
+
|
|
116
242
|
<Box>
|
|
117
243
|
<Text dimColor>{' Enforcement '}</Text>
|
|
118
244
|
<Text>{config.enforcement}</Text>
|