openclaw-safeclaw-plugin 0.1.2 → 0.3.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 +2 -2
- package/cli.tsx +29 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +30 -0
- package/dist/index.js +52 -59
- package/dist/tui/About.d.ts +1 -0
- package/dist/tui/About.js +5 -0
- package/dist/tui/App.d.ts +1 -0
- package/dist/tui/App.js +32 -0
- package/dist/tui/Settings.d.ts +7 -0
- package/dist/tui/Settings.js +84 -0
- package/dist/tui/Status.d.ts +6 -0
- package/dist/tui/Status.js +81 -0
- package/dist/tui/config.d.ts +30 -0
- package/dist/tui/config.js +123 -0
- package/index.ts +51 -67
- package/package.json +15 -3
- package/tui/About.tsx +30 -0
- package/tui/App.tsx +65 -0
- package/tui/Settings.tsx +130 -0
- package/tui/Status.tsx +156 -0
- package/tui/config.ts +136 -0
package/index.ts
CHANGED
|
@@ -7,70 +7,10 @@
|
|
|
7
7
|
* to the SafeClaw service and acts on the responses.
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
|
-
import {
|
|
11
|
-
import { join } from 'path';
|
|
12
|
-
import { homedir } from 'os';
|
|
10
|
+
import { loadConfig, configHash } from './tui/config.js';
|
|
13
11
|
|
|
14
12
|
// --- Configuration ---
|
|
15
13
|
|
|
16
|
-
interface SafeClawPluginConfig {
|
|
17
|
-
serviceUrl: string;
|
|
18
|
-
apiKey: string;
|
|
19
|
-
timeoutMs: number;
|
|
20
|
-
enabled: boolean;
|
|
21
|
-
enforcement: 'enforce' | 'warn-only' | 'audit-only' | 'disabled';
|
|
22
|
-
failMode: 'open' | 'closed';
|
|
23
|
-
agentId: string;
|
|
24
|
-
agentToken: string;
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
function loadConfig(): SafeClawPluginConfig {
|
|
28
|
-
const defaults: SafeClawPluginConfig = {
|
|
29
|
-
serviceUrl: process.env.SAFECLAW_URL ?? 'https://api.safeclaw.eu/api/v1',
|
|
30
|
-
apiKey: process.env.SAFECLAW_API_KEY ?? '',
|
|
31
|
-
timeoutMs: parseInt(process.env.SAFECLAW_TIMEOUT_MS ?? '500', 10),
|
|
32
|
-
enabled: process.env.SAFECLAW_ENABLED !== 'false',
|
|
33
|
-
enforcement: (process.env.SAFECLAW_ENFORCEMENT as SafeClawPluginConfig['enforcement']) ?? 'enforce',
|
|
34
|
-
failMode: (process.env.SAFECLAW_FAIL_MODE as SafeClawPluginConfig['failMode']) ?? 'closed',
|
|
35
|
-
agentId: process.env.SAFECLAW_AGENT_ID ?? '',
|
|
36
|
-
agentToken: process.env.SAFECLAW_AGENT_TOKEN ?? '',
|
|
37
|
-
};
|
|
38
|
-
|
|
39
|
-
// Try loading from config file
|
|
40
|
-
const configPath = join(homedir(), '.safeclaw', 'config.json');
|
|
41
|
-
if (existsSync(configPath)) {
|
|
42
|
-
try {
|
|
43
|
-
const raw = JSON.parse(readFileSync(configPath, 'utf-8'));
|
|
44
|
-
if (raw.enabled === false) defaults.enabled = false;
|
|
45
|
-
if (raw.remote?.serviceUrl) defaults.serviceUrl = raw.remote.serviceUrl;
|
|
46
|
-
if (raw.remote?.apiKey) defaults.apiKey = raw.remote.apiKey;
|
|
47
|
-
if (raw.remote?.timeoutMs) defaults.timeoutMs = raw.remote.timeoutMs;
|
|
48
|
-
if (raw.enforcement?.mode) defaults.enforcement = raw.enforcement.mode;
|
|
49
|
-
if (raw.enforcement?.failMode) defaults.failMode = raw.enforcement.failMode;
|
|
50
|
-
if (raw.agentId) defaults.agentId = raw.agentId;
|
|
51
|
-
if (raw.agentToken) defaults.agentToken = raw.agentToken;
|
|
52
|
-
} catch {
|
|
53
|
-
// Config file unreadable — use defaults
|
|
54
|
-
}
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
defaults.serviceUrl = defaults.serviceUrl.replace(/\/+$/, '');
|
|
58
|
-
|
|
59
|
-
const validModes = ['enforce', 'warn-only', 'audit-only', 'disabled'] as const;
|
|
60
|
-
if (!validModes.includes(defaults.enforcement as any)) {
|
|
61
|
-
console.warn(`[SafeClaw] Invalid enforcement mode "${defaults.enforcement}", defaulting to "enforce"`);
|
|
62
|
-
defaults.enforcement = 'enforce';
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
const validFailModes = ['open', 'closed'] as const;
|
|
66
|
-
if (!validFailModes.includes(defaults.failMode as any)) {
|
|
67
|
-
console.warn(`[SafeClaw] Invalid fail mode "${defaults.failMode}", defaulting to "closed"`);
|
|
68
|
-
defaults.failMode = 'closed';
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
return defaults;
|
|
72
|
-
}
|
|
73
|
-
|
|
74
14
|
const config = loadConfig();
|
|
75
15
|
|
|
76
16
|
// --- HTTP Client ---
|
|
@@ -175,8 +115,44 @@ export default {
|
|
|
175
115
|
return;
|
|
176
116
|
}
|
|
177
117
|
|
|
178
|
-
//
|
|
179
|
-
|
|
118
|
+
// Heartbeat watchdog — send config hash to service every 30s
|
|
119
|
+
const sendHeartbeat = async () => {
|
|
120
|
+
try {
|
|
121
|
+
await fetch(`${config.serviceUrl}/heartbeat`, {
|
|
122
|
+
method: 'POST',
|
|
123
|
+
headers: { 'Content-Type': 'application/json' },
|
|
124
|
+
body: JSON.stringify({
|
|
125
|
+
agentId: config.agentId || 'default',
|
|
126
|
+
configHash: configHash(config),
|
|
127
|
+
status: 'alive',
|
|
128
|
+
}),
|
|
129
|
+
signal: AbortSignal.timeout(config.timeoutMs),
|
|
130
|
+
});
|
|
131
|
+
} catch {
|
|
132
|
+
// Heartbeat failure is non-fatal
|
|
133
|
+
}
|
|
134
|
+
};
|
|
135
|
+
|
|
136
|
+
// Start heartbeat after connection check
|
|
137
|
+
checkConnection().then(() => sendHeartbeat()).catch(() => {});
|
|
138
|
+
const heartbeatInterval = setInterval(sendHeartbeat, 30000);
|
|
139
|
+
|
|
140
|
+
// Clean shutdown: send shutdown heartbeat and clear interval
|
|
141
|
+
const shutdown = () => {
|
|
142
|
+
clearInterval(heartbeatInterval);
|
|
143
|
+
fetch(`${config.serviceUrl}/heartbeat`, {
|
|
144
|
+
method: 'POST',
|
|
145
|
+
headers: { 'Content-Type': 'application/json' },
|
|
146
|
+
body: JSON.stringify({
|
|
147
|
+
agentId: config.agentId || 'default',
|
|
148
|
+
configHash: configHash(config),
|
|
149
|
+
status: 'shutdown',
|
|
150
|
+
}),
|
|
151
|
+
}).catch(() => {});
|
|
152
|
+
};
|
|
153
|
+
process.on('exit', shutdown);
|
|
154
|
+
process.on('SIGINT', () => { shutdown(); process.exit(0); });
|
|
155
|
+
process.on('SIGTERM', () => { shutdown(); process.exit(0); });
|
|
180
156
|
|
|
181
157
|
// THE GATE — constraint checking on every tool call
|
|
182
158
|
api.on('before_tool_call', async (event: PluginEvent, ctx: PluginContext) => {
|
|
@@ -192,6 +168,8 @@ export default {
|
|
|
192
168
|
return { block: true, blockReason: `SafeClaw service unavailable at ${config.serviceUrl} (fail-closed)` };
|
|
193
169
|
} else if (r === null && config.failMode === 'closed' && config.enforcement === 'warn-only') {
|
|
194
170
|
console.warn(`[SafeClaw] Service unavailable at ${config.serviceUrl} (fail-closed mode, warn-only)`);
|
|
171
|
+
} else if (r === null && config.failMode === 'closed' && config.enforcement === 'audit-only') {
|
|
172
|
+
console.warn(`[SafeClaw] Service unavailable at ${config.serviceUrl} (fail-closed mode, audit-only)`);
|
|
195
173
|
}
|
|
196
174
|
if (r?.block) {
|
|
197
175
|
if (config.enforcement === 'enforce') {
|
|
@@ -230,8 +208,14 @@ export default {
|
|
|
230
208
|
} else if (r === null && config.failMode === 'closed' && config.enforcement === 'warn-only') {
|
|
231
209
|
console.warn('[SafeClaw] Service unavailable (fail-closed mode, warn-only)');
|
|
232
210
|
}
|
|
233
|
-
if (r?.block
|
|
234
|
-
|
|
211
|
+
if (r?.block) {
|
|
212
|
+
if (config.enforcement === 'enforce') {
|
|
213
|
+
return { cancel: true };
|
|
214
|
+
}
|
|
215
|
+
if (config.enforcement === 'warn-only') {
|
|
216
|
+
console.warn(`[SafeClaw] Warning: ${r.reason}`);
|
|
217
|
+
}
|
|
218
|
+
// audit-only: logged server-side, no action here
|
|
235
219
|
}
|
|
236
220
|
}, { priority: 100 });
|
|
237
221
|
|
|
@@ -256,8 +240,8 @@ export default {
|
|
|
256
240
|
toolName: event.toolName ?? event.tool_name,
|
|
257
241
|
params: event.params ?? {},
|
|
258
242
|
result: event.result ?? '',
|
|
259
|
-
success: event.success ??
|
|
260
|
-
}).catch(() =>
|
|
243
|
+
success: event.success ?? false,
|
|
244
|
+
}).catch((e) => console.warn('[SafeClaw] Failed to record tool result:', e));
|
|
261
245
|
});
|
|
262
246
|
},
|
|
263
247
|
};
|
package/package.json
CHANGED
|
@@ -1,10 +1,13 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "openclaw-safeclaw-plugin",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.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
|
+
"bin": {
|
|
9
|
+
"safeclaw": "dist/cli.js"
|
|
10
|
+
},
|
|
8
11
|
"scripts": {
|
|
9
12
|
"build": "tsc",
|
|
10
13
|
"typecheck": "tsc --noEmit",
|
|
@@ -13,6 +16,8 @@
|
|
|
13
16
|
"files": [
|
|
14
17
|
"dist/",
|
|
15
18
|
"index.ts",
|
|
19
|
+
"cli.tsx",
|
|
20
|
+
"tui/",
|
|
16
21
|
"SKILL.md",
|
|
17
22
|
"README.md"
|
|
18
23
|
],
|
|
@@ -40,10 +45,17 @@
|
|
|
40
45
|
},
|
|
41
46
|
"author": "Tendly EU",
|
|
42
47
|
"devDependencies": {
|
|
43
|
-
"
|
|
44
|
-
"@types/
|
|
48
|
+
"@types/node": "^20.0.0",
|
|
49
|
+
"@types/react": "^19.2.14",
|
|
50
|
+
"typescript": "^5.4.0"
|
|
45
51
|
},
|
|
46
52
|
"engines": {
|
|
47
53
|
"node": ">=18.0.0"
|
|
54
|
+
},
|
|
55
|
+
"dependencies": {
|
|
56
|
+
"ink": "^6.8.0",
|
|
57
|
+
"ink-select-input": "^6.2.0",
|
|
58
|
+
"ink-text-input": "^6.0.0",
|
|
59
|
+
"react": "^19.2.4"
|
|
48
60
|
}
|
|
49
61
|
}
|
package/tui/About.tsx
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { Text, Box } from 'ink';
|
|
3
|
+
|
|
4
|
+
export default function About() {
|
|
5
|
+
return (
|
|
6
|
+
<Box flexDirection="column" paddingX={1}>
|
|
7
|
+
<Box marginBottom={1}>
|
|
8
|
+
<Text bold>About</Text>
|
|
9
|
+
</Box>
|
|
10
|
+
<Text> SafeClaw Neurosymbolic Governance</Text>
|
|
11
|
+
<Text dimColor> Validates AI agent actions against OWL</Text>
|
|
12
|
+
<Text dimColor> ontologies and SHACL constraints.</Text>
|
|
13
|
+
<Box marginTop={1}>
|
|
14
|
+
<Text> Web: </Text>
|
|
15
|
+
<Text color="cyan">https://safeclaw.eu</Text>
|
|
16
|
+
</Box>
|
|
17
|
+
<Box>
|
|
18
|
+
<Text> Docs: </Text>
|
|
19
|
+
<Text color="cyan">https://safeclaw.eu/docs</Text>
|
|
20
|
+
</Box>
|
|
21
|
+
<Box>
|
|
22
|
+
<Text> Repo: </Text>
|
|
23
|
+
<Text color="cyan">https://github.com/tendlyeu/SafeClaw</Text>
|
|
24
|
+
</Box>
|
|
25
|
+
<Box marginTop={1}>
|
|
26
|
+
<Text dimColor> q to quit</Text>
|
|
27
|
+
</Box>
|
|
28
|
+
</Box>
|
|
29
|
+
);
|
|
30
|
+
}
|
package/tui/App.tsx
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import React, { useState } from 'react';
|
|
2
|
+
import { Text, Box, useInput, useApp } from 'ink';
|
|
3
|
+
import { loadConfig, type SafeClawConfig } from './config.js';
|
|
4
|
+
import Status from './Status.js';
|
|
5
|
+
import Settings from './Settings.js';
|
|
6
|
+
import About from './About.js';
|
|
7
|
+
|
|
8
|
+
const TABS = ['Status', 'Settings', 'About'] as const;
|
|
9
|
+
type Tab = typeof TABS[number];
|
|
10
|
+
|
|
11
|
+
export default function App() {
|
|
12
|
+
const { exit } = useApp();
|
|
13
|
+
const [tab, setTab] = useState<Tab>('Status');
|
|
14
|
+
const [config, setConfig] = useState<SafeClawConfig>(loadConfig());
|
|
15
|
+
|
|
16
|
+
useInput((input, key) => {
|
|
17
|
+
if (input === 'q' && tab !== 'Settings') {
|
|
18
|
+
exit();
|
|
19
|
+
return;
|
|
20
|
+
}
|
|
21
|
+
if (key.tab || (input === '1' || input === '2' || input === '3')) {
|
|
22
|
+
if (input === '1') setTab('Status');
|
|
23
|
+
else if (input === '2') setTab('Settings');
|
|
24
|
+
else if (input === '3') setTab('About');
|
|
25
|
+
else {
|
|
26
|
+
const idx = TABS.indexOf(tab);
|
|
27
|
+
setTab(TABS[(idx + 1) % TABS.length]);
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
return (
|
|
33
|
+
<Box flexDirection="column">
|
|
34
|
+
{/* Header */}
|
|
35
|
+
<Box borderStyle="single" borderColor="green" paddingX={1}>
|
|
36
|
+
<Text bold color="green">SafeClaw </Text>
|
|
37
|
+
<Text dimColor>v0.2.0</Text>
|
|
38
|
+
</Box>
|
|
39
|
+
|
|
40
|
+
{/* Tab bar */}
|
|
41
|
+
<Box paddingX={1} gap={2}>
|
|
42
|
+
{TABS.map((t, i) => (
|
|
43
|
+
<Text
|
|
44
|
+
key={t}
|
|
45
|
+
bold={tab === t}
|
|
46
|
+
color={tab === t ? 'cyan' : 'white'}
|
|
47
|
+
dimColor={tab !== t}
|
|
48
|
+
>
|
|
49
|
+
{`${i + 1}:${t}`}
|
|
50
|
+
</Text>
|
|
51
|
+
))}
|
|
52
|
+
<Text dimColor> tab/1-3 to switch</Text>
|
|
53
|
+
</Box>
|
|
54
|
+
|
|
55
|
+
{/* Content */}
|
|
56
|
+
<Box marginTop={1}>
|
|
57
|
+
{tab === 'Status' && <Status config={config} />}
|
|
58
|
+
{tab === 'Settings' && (
|
|
59
|
+
<Settings config={config} onConfigChange={setConfig} />
|
|
60
|
+
)}
|
|
61
|
+
{tab === 'About' && <About />}
|
|
62
|
+
</Box>
|
|
63
|
+
</Box>
|
|
64
|
+
);
|
|
65
|
+
}
|
package/tui/Settings.tsx
ADDED
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import React, { useState } from 'react';
|
|
2
|
+
import { Text, Box, useInput } from 'ink';
|
|
3
|
+
import { type SafeClawConfig, saveConfig } from './config.js';
|
|
4
|
+
|
|
5
|
+
interface SettingsProps {
|
|
6
|
+
config: SafeClawConfig;
|
|
7
|
+
onConfigChange: (config: SafeClawConfig) => void;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
const ENFORCEMENT_MODES = ['enforce', 'warn-only', 'audit-only', 'disabled'] as const;
|
|
11
|
+
const FAIL_MODES = ['closed', 'open'] as const;
|
|
12
|
+
|
|
13
|
+
interface SettingItem {
|
|
14
|
+
key: string;
|
|
15
|
+
label: string;
|
|
16
|
+
type: 'toggle' | 'cycle' | 'text';
|
|
17
|
+
values?: readonly string[];
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const SETTINGS: SettingItem[] = [
|
|
21
|
+
{ key: 'enabled', label: 'Enabled', type: 'toggle' },
|
|
22
|
+
{ key: 'enforcement', label: 'Enforcement', type: 'cycle', values: ENFORCEMENT_MODES },
|
|
23
|
+
{ key: 'failMode', label: 'Fail Mode', type: 'cycle', values: FAIL_MODES },
|
|
24
|
+
{ key: 'serviceUrl', label: 'Service URL', type: 'text' },
|
|
25
|
+
];
|
|
26
|
+
|
|
27
|
+
export default function Settings({ config, onConfigChange }: SettingsProps) {
|
|
28
|
+
const [selected, setSelected] = useState(0);
|
|
29
|
+
const [editing, setEditing] = useState(false);
|
|
30
|
+
const [editBuffer, setEditBuffer] = useState('');
|
|
31
|
+
|
|
32
|
+
const updateConfig = (patch: Partial<SafeClawConfig>) => {
|
|
33
|
+
const updated = { ...config, ...patch };
|
|
34
|
+
saveConfig(updated);
|
|
35
|
+
onConfigChange(updated);
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
useInput((input, key) => {
|
|
39
|
+
if (editing) {
|
|
40
|
+
if (key.return) {
|
|
41
|
+
updateConfig({ serviceUrl: editBuffer });
|
|
42
|
+
setEditing(false);
|
|
43
|
+
} else if (key.escape) {
|
|
44
|
+
setEditing(false);
|
|
45
|
+
} else if (key.backspace || key.delete) {
|
|
46
|
+
setEditBuffer(prev => prev.slice(0, -1));
|
|
47
|
+
} else if (input && !key.ctrl && !key.meta) {
|
|
48
|
+
setEditBuffer(prev => prev + input);
|
|
49
|
+
}
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
if (key.upArrow) {
|
|
54
|
+
setSelected(prev => Math.max(0, prev - 1));
|
|
55
|
+
} else if (key.downArrow) {
|
|
56
|
+
setSelected(prev => Math.min(SETTINGS.length - 1, prev + 1));
|
|
57
|
+
} else if (key.return || key.rightArrow || key.leftArrow) {
|
|
58
|
+
const setting = SETTINGS[selected];
|
|
59
|
+
if (setting.type === 'toggle') {
|
|
60
|
+
updateConfig({ enabled: !config.enabled });
|
|
61
|
+
} else if (setting.type === 'cycle' && setting.values) {
|
|
62
|
+
const currentKey = setting.key as 'enforcement' | 'failMode';
|
|
63
|
+
const current = config[currentKey];
|
|
64
|
+
const idx = setting.values.indexOf(current);
|
|
65
|
+
const dir = key.leftArrow ? -1 : 1;
|
|
66
|
+
const next = setting.values[(idx + dir + setting.values.length) % setting.values.length];
|
|
67
|
+
updateConfig({ [currentKey]: next });
|
|
68
|
+
} else if (setting.type === 'text' && key.return) {
|
|
69
|
+
setEditing(true);
|
|
70
|
+
setEditBuffer(config.serviceUrl);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
return (
|
|
76
|
+
<Box flexDirection="column" paddingX={1}>
|
|
77
|
+
<Box marginBottom={1}>
|
|
78
|
+
<Text bold>Settings</Text>
|
|
79
|
+
</Box>
|
|
80
|
+
|
|
81
|
+
{SETTINGS.map((setting, i) => {
|
|
82
|
+
const isSelected = i === selected;
|
|
83
|
+
const prefix = isSelected ? '▸ ' : ' ';
|
|
84
|
+
let value: string;
|
|
85
|
+
|
|
86
|
+
if (setting.key === 'enabled') {
|
|
87
|
+
value = config.enabled ? 'ON' : 'OFF';
|
|
88
|
+
} else if (setting.key === 'serviceUrl' && editing && isSelected) {
|
|
89
|
+
value = editBuffer + '█';
|
|
90
|
+
} else {
|
|
91
|
+
value = String(config[setting.key as keyof SafeClawConfig]);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const showArrows = isSelected && setting.type === 'cycle';
|
|
95
|
+
|
|
96
|
+
return (
|
|
97
|
+
<Box key={setting.key}>
|
|
98
|
+
<Text color={isSelected ? 'cyan' : undefined} bold={isSelected}>
|
|
99
|
+
{prefix}
|
|
100
|
+
{setting.label.padEnd(16)}
|
|
101
|
+
</Text>
|
|
102
|
+
{showArrows && <Text dimColor>{'◀ '}</Text>}
|
|
103
|
+
<Text
|
|
104
|
+
color={
|
|
105
|
+
setting.key === 'enabled'
|
|
106
|
+
? config.enabled ? 'green' : 'red'
|
|
107
|
+
: undefined
|
|
108
|
+
}
|
|
109
|
+
>
|
|
110
|
+
{value}
|
|
111
|
+
</Text>
|
|
112
|
+
{showArrows && <Text dimColor>{' ▶'}</Text>}
|
|
113
|
+
</Box>
|
|
114
|
+
);
|
|
115
|
+
})}
|
|
116
|
+
|
|
117
|
+
<Box marginTop={1}>
|
|
118
|
+
<Text dimColor>
|
|
119
|
+
{editing
|
|
120
|
+
? ' type to edit · enter to save · esc to cancel'
|
|
121
|
+
: ' ↑↓ navigate · ←→ change · enter to edit URL · q quit'}
|
|
122
|
+
</Text>
|
|
123
|
+
</Box>
|
|
124
|
+
|
|
125
|
+
<Box>
|
|
126
|
+
<Text dimColor>{' Saves to ~/.safeclaw/config.json'}</Text>
|
|
127
|
+
</Box>
|
|
128
|
+
</Box>
|
|
129
|
+
);
|
|
130
|
+
}
|
package/tui/Status.tsx
ADDED
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
import React, { useState, useEffect } from 'react';
|
|
2
|
+
import { Text, Box, useInput } from 'ink';
|
|
3
|
+
import { exec } from 'child_process';
|
|
4
|
+
import { type SafeClawConfig } from './config.js';
|
|
5
|
+
|
|
6
|
+
interface StatusProps {
|
|
7
|
+
config: SafeClawConfig;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
interface HealthData {
|
|
11
|
+
status: string;
|
|
12
|
+
version?: string;
|
|
13
|
+
engine_ready?: boolean;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
type OpenClawStatus = 'checking' | 'running' | 'not running' | 'error';
|
|
17
|
+
|
|
18
|
+
export default function Status({ config }: StatusProps) {
|
|
19
|
+
const [health, setHealth] = useState<HealthData | null>(null);
|
|
20
|
+
const [error, setError] = useState<string | null>(null);
|
|
21
|
+
const [lastCheck, setLastCheck] = useState<Date | null>(null);
|
|
22
|
+
const [openclawStatus, setOpenclawStatus] = useState<OpenClawStatus>('checking');
|
|
23
|
+
const [restartMsg, setRestartMsg] = useState<string | null>(null);
|
|
24
|
+
|
|
25
|
+
const checkHealth = async () => {
|
|
26
|
+
try {
|
|
27
|
+
const res = await fetch(`${config.serviceUrl}/health`, {
|
|
28
|
+
signal: AbortSignal.timeout(config.timeoutMs * 2),
|
|
29
|
+
});
|
|
30
|
+
if (res.ok) {
|
|
31
|
+
const data = await res.json() as HealthData;
|
|
32
|
+
setHealth(data);
|
|
33
|
+
setError(null);
|
|
34
|
+
} else {
|
|
35
|
+
setHealth(null);
|
|
36
|
+
setError(`HTTP ${res.status}`);
|
|
37
|
+
}
|
|
38
|
+
} catch {
|
|
39
|
+
setHealth(null);
|
|
40
|
+
setError('Cannot connect');
|
|
41
|
+
}
|
|
42
|
+
setLastCheck(new Date());
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
const checkOpenClaw = () => {
|
|
46
|
+
exec('openclaw daemon status', { timeout: 10000 }, (err, stdout) => {
|
|
47
|
+
if (err) {
|
|
48
|
+
setOpenclawStatus('not running');
|
|
49
|
+
} else {
|
|
50
|
+
const output = stdout.toLowerCase();
|
|
51
|
+
setOpenclawStatus(output.includes('running') ? 'running' : 'not running');
|
|
52
|
+
}
|
|
53
|
+
});
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
const restartOpenClaw = () => {
|
|
57
|
+
setRestartMsg('Restarting...');
|
|
58
|
+
exec('openclaw daemon restart', { timeout: 15000 }, (err) => {
|
|
59
|
+
if (err) {
|
|
60
|
+
setRestartMsg('Restart failed');
|
|
61
|
+
} else {
|
|
62
|
+
setRestartMsg('Restarted');
|
|
63
|
+
checkOpenClaw();
|
|
64
|
+
}
|
|
65
|
+
setTimeout(() => setRestartMsg(null), 3000);
|
|
66
|
+
});
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
useEffect(() => {
|
|
70
|
+
checkHealth();
|
|
71
|
+
checkOpenClaw();
|
|
72
|
+
const interval = setInterval(() => {
|
|
73
|
+
checkHealth();
|
|
74
|
+
checkOpenClaw();
|
|
75
|
+
}, 10000);
|
|
76
|
+
return () => clearInterval(interval);
|
|
77
|
+
}, []);
|
|
78
|
+
|
|
79
|
+
useInput((input) => {
|
|
80
|
+
if (input === 'r') {
|
|
81
|
+
restartOpenClaw();
|
|
82
|
+
}
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
const connected = health !== null;
|
|
86
|
+
const dot = '●';
|
|
87
|
+
const serviceDotColor = connected ? 'green' : 'red';
|
|
88
|
+
const serviceText = connected
|
|
89
|
+
? `Connected (${config.serviceUrl.replace(/^https?:\/\//, '').replace(/\/api\/v1$/, '')})`
|
|
90
|
+
: error ?? 'Disconnected';
|
|
91
|
+
|
|
92
|
+
const openclawDotColor = openclawStatus === 'running' ? 'green' : openclawStatus === 'checking' ? 'yellow' : 'red';
|
|
93
|
+
const openclawText = openclawStatus === 'checking' ? 'Checking...'
|
|
94
|
+
: openclawStatus === 'running' ? 'Running'
|
|
95
|
+
: 'Not running';
|
|
96
|
+
|
|
97
|
+
return (
|
|
98
|
+
<Box flexDirection="column" paddingX={1}>
|
|
99
|
+
<Box marginBottom={1}>
|
|
100
|
+
<Text bold>Status</Text>
|
|
101
|
+
</Box>
|
|
102
|
+
|
|
103
|
+
<Box>
|
|
104
|
+
<Text dimColor>{' Service '}</Text>
|
|
105
|
+
<Text color={serviceDotColor}>{dot} </Text>
|
|
106
|
+
<Text>{serviceText}</Text>
|
|
107
|
+
</Box>
|
|
108
|
+
|
|
109
|
+
<Box>
|
|
110
|
+
<Text dimColor>{' OpenClaw '}</Text>
|
|
111
|
+
<Text color={openclawDotColor}>{dot} </Text>
|
|
112
|
+
<Text>{openclawText}</Text>
|
|
113
|
+
{restartMsg && <Text dimColor>{` (${restartMsg})`}</Text>}
|
|
114
|
+
</Box>
|
|
115
|
+
|
|
116
|
+
<Box>
|
|
117
|
+
<Text dimColor>{' Enforcement '}</Text>
|
|
118
|
+
<Text>{config.enforcement}</Text>
|
|
119
|
+
</Box>
|
|
120
|
+
|
|
121
|
+
<Box>
|
|
122
|
+
<Text dimColor>{' Fail Mode '}</Text>
|
|
123
|
+
<Text>{config.failMode}</Text>
|
|
124
|
+
</Box>
|
|
125
|
+
|
|
126
|
+
<Box>
|
|
127
|
+
<Text dimColor>{' Enabled '}</Text>
|
|
128
|
+
<Text color={config.enabled ? 'green' : 'red'}>
|
|
129
|
+
{config.enabled ? 'ON' : 'OFF'}
|
|
130
|
+
</Text>
|
|
131
|
+
</Box>
|
|
132
|
+
|
|
133
|
+
{health?.version && (
|
|
134
|
+
<Box marginTop={1}>
|
|
135
|
+
<Text dimColor>{' Service v'}</Text>
|
|
136
|
+
<Text>{health.version}</Text>
|
|
137
|
+
</Box>
|
|
138
|
+
)}
|
|
139
|
+
|
|
140
|
+
{lastCheck && (
|
|
141
|
+
<Box marginTop={1}>
|
|
142
|
+
<Text dimColor>
|
|
143
|
+
{' Last check: '}
|
|
144
|
+
{lastCheck.toLocaleTimeString()}
|
|
145
|
+
</Text>
|
|
146
|
+
</Box>
|
|
147
|
+
)}
|
|
148
|
+
|
|
149
|
+
<Box marginTop={1}>
|
|
150
|
+
<Text dimColor>{' Press '}</Text>
|
|
151
|
+
<Text bold>r</Text>
|
|
152
|
+
<Text dimColor>{' to restart OpenClaw daemon'}</Text>
|
|
153
|
+
</Box>
|
|
154
|
+
</Box>
|
|
155
|
+
);
|
|
156
|
+
}
|