openclaw-safeclaw-plugin 1.0.0 → 1.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +9 -7
- package/cli.tsx +116 -26
- package/dist/cli.js +120 -26
- package/dist/index.js +88 -55
- 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 +42 -3
- package/index.ts +89 -55
- 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 +42 -2
package/index.ts
CHANGED
|
@@ -8,29 +8,47 @@
|
|
|
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
|
|
@@ -48,11 +66,11 @@ async function post(path: string, body: Record<string, unknown>): Promise<Record
|
|
|
48
66
|
return await res.json() as Record<string, unknown>;
|
|
49
67
|
} catch (e) {
|
|
50
68
|
if (e instanceof DOMException && e.name === 'TimeoutError') {
|
|
51
|
-
console.warn(`[SafeClaw] Timeout after ${
|
|
69
|
+
console.warn(`[SafeClaw] Timeout after ${cfg.timeoutMs}ms on ${path} (${cfg.serviceUrl})`);
|
|
52
70
|
} else if (e instanceof TypeError && (e.message.includes('fetch') || e.message.includes('ECONNREFUSED'))) {
|
|
53
|
-
console.warn(`[SafeClaw] Connection refused: ${
|
|
71
|
+
console.warn(`[SafeClaw] Connection refused: ${cfg.serviceUrl}${path} — is the service running?`);
|
|
54
72
|
} else {
|
|
55
|
-
console.warn(`[SafeClaw] Service unavailable: ${
|
|
73
|
+
console.warn(`[SafeClaw] Service unavailable: ${cfg.serviceUrl}${path}`);
|
|
56
74
|
}
|
|
57
75
|
return null; // Caller checks failMode
|
|
58
76
|
}
|
|
@@ -83,14 +101,15 @@ interface PluginApi {
|
|
|
83
101
|
let handshakeCompleted = false;
|
|
84
102
|
|
|
85
103
|
async function performHandshake(): Promise<boolean> {
|
|
86
|
-
|
|
104
|
+
const cfg = getConfig();
|
|
105
|
+
if (!cfg.apiKey) {
|
|
87
106
|
console.warn('[SafeClaw] No API key configured — skipping handshake');
|
|
88
107
|
return false;
|
|
89
108
|
}
|
|
90
109
|
|
|
91
110
|
const r = await post('/handshake', {
|
|
92
|
-
pluginVersion:
|
|
93
|
-
configHash: configHash(
|
|
111
|
+
pluginVersion: PLUGIN_VERSION,
|
|
112
|
+
configHash: configHash(cfg),
|
|
94
113
|
});
|
|
95
114
|
|
|
96
115
|
if (r === null) {
|
|
@@ -104,13 +123,14 @@ async function performHandshake(): Promise<boolean> {
|
|
|
104
123
|
}
|
|
105
124
|
|
|
106
125
|
async function checkConnection(): Promise<void> {
|
|
126
|
+
const cfg = getConfig();
|
|
107
127
|
const label = `[SafeClaw]`;
|
|
108
|
-
console.log(`${label} Connecting to ${
|
|
109
|
-
console.log(`${label} Mode: enforcement=${
|
|
128
|
+
console.log(`${label} Connecting to ${cfg.serviceUrl} ...`);
|
|
129
|
+
console.log(`${label} Mode: enforcement=${cfg.enforcement}, failMode=${cfg.failMode}`);
|
|
110
130
|
|
|
111
131
|
try {
|
|
112
|
-
const res = await fetch(`${
|
|
113
|
-
signal: AbortSignal.timeout(
|
|
132
|
+
const res = await fetch(`${cfg.serviceUrl}/health`, {
|
|
133
|
+
signal: AbortSignal.timeout(cfg.timeoutMs * 2),
|
|
114
134
|
});
|
|
115
135
|
if (res.ok) {
|
|
116
136
|
const data = await res.json() as Record<string, unknown>;
|
|
@@ -119,8 +139,8 @@ async function checkConnection(): Promise<void> {
|
|
|
119
139
|
console.warn(`${label} ✗ Service responded with HTTP ${res.status}`);
|
|
120
140
|
}
|
|
121
141
|
} catch {
|
|
122
|
-
console.warn(`${label} ✗ Cannot reach service at ${
|
|
123
|
-
if (
|
|
142
|
+
console.warn(`${label} ✗ Cannot reach service at ${cfg.serviceUrl}`);
|
|
143
|
+
if (cfg.failMode === 'closed') {
|
|
124
144
|
console.warn(`${label} fail-mode=closed → tool calls will be BLOCKED until service is reachable`);
|
|
125
145
|
} else {
|
|
126
146
|
console.warn(`${label} fail-mode=open → tool calls will be ALLOWED despite no connection`);
|
|
@@ -131,20 +151,23 @@ async function checkConnection(): Promise<void> {
|
|
|
131
151
|
export default {
|
|
132
152
|
id: 'safeclaw',
|
|
133
153
|
name: 'SafeClaw Neurosymbolic Governance',
|
|
134
|
-
version:
|
|
154
|
+
version: PLUGIN_VERSION,
|
|
135
155
|
|
|
136
156
|
register(api: PluginApi) {
|
|
137
|
-
if (!
|
|
157
|
+
if (!getConfig().enabled) {
|
|
138
158
|
console.log('[SafeClaw] Plugin disabled');
|
|
139
159
|
return;
|
|
140
160
|
}
|
|
141
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
|
+
|
|
142
165
|
// Heartbeat watchdog — send config hash to service every 30s
|
|
143
166
|
const sendHeartbeat = async () => {
|
|
144
167
|
try {
|
|
145
168
|
await post('/heartbeat', {
|
|
146
|
-
agentId:
|
|
147
|
-
configHash: configHash(
|
|
169
|
+
agentId: instanceId,
|
|
170
|
+
configHash: configHash(getConfig()),
|
|
148
171
|
status: 'alive',
|
|
149
172
|
});
|
|
150
173
|
} catch {
|
|
@@ -152,34 +175,40 @@ export default {
|
|
|
152
175
|
}
|
|
153
176
|
};
|
|
154
177
|
|
|
155
|
-
// Start heartbeat after connection check + handshake
|
|
178
|
+
// Start heartbeat only after connection check + handshake completes (#84)
|
|
179
|
+
let heartbeatInterval: ReturnType<typeof setInterval> | undefined;
|
|
156
180
|
checkConnection()
|
|
157
181
|
.then(() => performHandshake())
|
|
158
182
|
.then((ok) => {
|
|
159
|
-
if (!ok &&
|
|
160
|
-
console.warn('[SafeClaw]
|
|
183
|
+
if (!ok && getConfig().failMode === 'closed') {
|
|
184
|
+
console.warn('[SafeClaw] Handshake failed with fail-mode=closed — tool calls will be BLOCKED');
|
|
161
185
|
}
|
|
186
|
+
heartbeatInterval = setInterval(sendHeartbeat, 30000);
|
|
162
187
|
return sendHeartbeat();
|
|
163
188
|
})
|
|
164
189
|
.catch(() => {});
|
|
165
|
-
const heartbeatInterval = setInterval(sendHeartbeat, 30000);
|
|
166
190
|
|
|
167
191
|
// Clean shutdown: send shutdown heartbeat and clear interval
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
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()),
|
|
199
|
+
status: 'shutdown',
|
|
200
|
+
});
|
|
201
|
+
} catch {
|
|
202
|
+
// Best-effort shutdown notification
|
|
203
|
+
}
|
|
175
204
|
};
|
|
176
|
-
process.on('
|
|
177
|
-
process.on('
|
|
178
|
-
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); });
|
|
179
207
|
|
|
180
208
|
// THE GATE — constraint checking on every tool call
|
|
181
209
|
api.on('before_tool_call', async (event: PluginEvent, ctx: PluginContext) => {
|
|
182
|
-
|
|
210
|
+
const cfg = getConfig();
|
|
211
|
+
if (!handshakeCompleted && cfg.failMode === 'closed' && cfg.enforcement === 'enforce') {
|
|
183
212
|
return { block: true, blockReason: 'SafeClaw handshake not completed (fail-closed)' };
|
|
184
213
|
}
|
|
185
214
|
|
|
@@ -191,19 +220,20 @@ export default {
|
|
|
191
220
|
sessionHistory: event.sessionHistory ?? [],
|
|
192
221
|
});
|
|
193
222
|
|
|
194
|
-
if (r === null &&
|
|
195
|
-
return { block: true, blockReason: `SafeClaw service unavailable at ${
|
|
196
|
-
} else if (r === null &&
|
|
197
|
-
console.warn(`[SafeClaw] Service unavailable at ${
|
|
198
|
-
} else if (r === null &&
|
|
199
|
-
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)`);
|
|
200
229
|
}
|
|
201
230
|
if (r?.block) {
|
|
202
|
-
|
|
203
|
-
|
|
231
|
+
const blockReason = (r.reason as string) || 'Blocked by SafeClaw (no reason provided)';
|
|
232
|
+
if (cfg.enforcement === 'enforce') {
|
|
233
|
+
return { block: true, blockReason };
|
|
204
234
|
}
|
|
205
|
-
if (
|
|
206
|
-
console.warn(`[SafeClaw] Warning: ${
|
|
235
|
+
if (cfg.enforcement === 'warn-only') {
|
|
236
|
+
console.warn(`[SafeClaw] Warning: ${blockReason}`);
|
|
207
237
|
}
|
|
208
238
|
// audit-only: logged server-side, no action here
|
|
209
239
|
}
|
|
@@ -223,6 +253,7 @@ export default {
|
|
|
223
253
|
|
|
224
254
|
// Message governance — check outbound messages
|
|
225
255
|
api.on('message_sending', async (event: PluginEvent, ctx: PluginContext) => {
|
|
256
|
+
const cfg = getConfig();
|
|
226
257
|
const r = await post('/evaluate/message', {
|
|
227
258
|
sessionId: ctx.sessionId ?? event.sessionId,
|
|
228
259
|
userId: ctx.userId ?? event.userId,
|
|
@@ -230,17 +261,20 @@ export default {
|
|
|
230
261
|
content: event.content,
|
|
231
262
|
});
|
|
232
263
|
|
|
233
|
-
if (r === null &&
|
|
234
|
-
return { cancel: true };
|
|
235
|
-
} 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') {
|
|
236
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)');
|
|
237
270
|
}
|
|
238
271
|
if (r?.block) {
|
|
239
|
-
|
|
240
|
-
|
|
272
|
+
const blockReason = (r.reason as string) || 'Blocked by SafeClaw (no reason provided)';
|
|
273
|
+
if (cfg.enforcement === 'enforce') {
|
|
274
|
+
return { cancel: true, cancelReason: blockReason };
|
|
241
275
|
}
|
|
242
|
-
if (
|
|
243
|
-
console.warn(`[SafeClaw] Warning: ${
|
|
276
|
+
if (cfg.enforcement === 'warn-only') {
|
|
277
|
+
console.warn(`[SafeClaw] Warning: ${blockReason}`);
|
|
244
278
|
}
|
|
245
279
|
// audit-only: logged server-side, no action here
|
|
246
280
|
}
|
package/package.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "openclaw-safeclaw-plugin",
|
|
3
|
-
"version": "1.
|
|
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>
|