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/README.md
CHANGED
|
@@ -5,7 +5,7 @@ Neurosymbolic governance plugin for OpenClaw AI agents. Validates every tool cal
|
|
|
5
5
|
## Install
|
|
6
6
|
|
|
7
7
|
```bash
|
|
8
|
-
|
|
8
|
+
openclaw plugins install openclaw-safeclaw-plugin
|
|
9
9
|
```
|
|
10
10
|
|
|
11
11
|
## Quick Start
|
|
@@ -13,7 +13,7 @@ npm install openclaw-safeclaw-plugin
|
|
|
13
13
|
Install and go — the plugin connects to SafeClaw's hosted service by default:
|
|
14
14
|
|
|
15
15
|
```bash
|
|
16
|
-
|
|
16
|
+
openclaw plugins install openclaw-safeclaw-plugin
|
|
17
17
|
```
|
|
18
18
|
|
|
19
19
|
No configuration needed. The default service URL is `https://api.safeclaw.eu/api/v1`.
|
package/cli.tsx
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import React from 'react';
|
|
3
|
+
import { render } from 'ink';
|
|
4
|
+
import { execSync } from 'child_process';
|
|
5
|
+
import App from './tui/App.js';
|
|
6
|
+
|
|
7
|
+
const args = process.argv.slice(2);
|
|
8
|
+
const command = args[0];
|
|
9
|
+
|
|
10
|
+
if (command === 'tui') {
|
|
11
|
+
render(React.createElement(App));
|
|
12
|
+
} else if (command === 'restart-openclaw') {
|
|
13
|
+
try {
|
|
14
|
+
const output = execSync('openclaw daemon restart', { encoding: 'utf-8', timeout: 15000 });
|
|
15
|
+
console.log(output.trim());
|
|
16
|
+
console.log('OpenClaw daemon restarted successfully.');
|
|
17
|
+
} catch (err: unknown) {
|
|
18
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
19
|
+
console.error('Failed to restart OpenClaw daemon:', message);
|
|
20
|
+
process.exit(1);
|
|
21
|
+
}
|
|
22
|
+
} else {
|
|
23
|
+
console.log('Usage: safeclaw <command>');
|
|
24
|
+
console.log('');
|
|
25
|
+
console.log('Commands:');
|
|
26
|
+
console.log(' tui Open the interactive SafeClaw settings TUI');
|
|
27
|
+
console.log(' restart-openclaw Restart the OpenClaw daemon');
|
|
28
|
+
process.exit(0);
|
|
29
|
+
}
|
package/dist/cli.d.ts
ADDED
package/dist/cli.js
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import React from 'react';
|
|
3
|
+
import { render } from 'ink';
|
|
4
|
+
import { execSync } from 'child_process';
|
|
5
|
+
import App from './tui/App.js';
|
|
6
|
+
const args = process.argv.slice(2);
|
|
7
|
+
const command = args[0];
|
|
8
|
+
if (command === 'tui') {
|
|
9
|
+
render(React.createElement(App));
|
|
10
|
+
}
|
|
11
|
+
else if (command === 'restart-openclaw') {
|
|
12
|
+
try {
|
|
13
|
+
const output = execSync('openclaw daemon restart', { encoding: 'utf-8', timeout: 15000 });
|
|
14
|
+
console.log(output.trim());
|
|
15
|
+
console.log('OpenClaw daemon restarted successfully.');
|
|
16
|
+
}
|
|
17
|
+
catch (err) {
|
|
18
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
19
|
+
console.error('Failed to restart OpenClaw daemon:', message);
|
|
20
|
+
process.exit(1);
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
else {
|
|
24
|
+
console.log('Usage: safeclaw <command>');
|
|
25
|
+
console.log('');
|
|
26
|
+
console.log('Commands:');
|
|
27
|
+
console.log(' tui Open the interactive SafeClaw settings TUI');
|
|
28
|
+
console.log(' restart-openclaw Restart the OpenClaw daemon');
|
|
29
|
+
process.exit(0);
|
|
30
|
+
}
|
package/dist/index.js
CHANGED
|
@@ -6,59 +6,8 @@
|
|
|
6
6
|
* This plugin is a thin HTTP bridge that forwards OpenClaw events
|
|
7
7
|
* to the SafeClaw service and acts on the responses.
|
|
8
8
|
*/
|
|
9
|
-
import {
|
|
10
|
-
|
|
11
|
-
import { homedir } from 'os';
|
|
12
|
-
function loadConfig() {
|
|
13
|
-
const defaults = {
|
|
14
|
-
serviceUrl: process.env.SAFECLAW_URL ?? 'https://api.safeclaw.eu/api/v1',
|
|
15
|
-
apiKey: process.env.SAFECLAW_API_KEY ?? '',
|
|
16
|
-
timeoutMs: parseInt(process.env.SAFECLAW_TIMEOUT_MS ?? '500', 10),
|
|
17
|
-
enabled: process.env.SAFECLAW_ENABLED !== 'false',
|
|
18
|
-
enforcement: process.env.SAFECLAW_ENFORCEMENT ?? 'enforce',
|
|
19
|
-
failMode: process.env.SAFECLAW_FAIL_MODE ?? 'closed',
|
|
20
|
-
agentId: process.env.SAFECLAW_AGENT_ID ?? '',
|
|
21
|
-
agentToken: process.env.SAFECLAW_AGENT_TOKEN ?? '',
|
|
22
|
-
};
|
|
23
|
-
// Try loading from config file
|
|
24
|
-
const configPath = join(homedir(), '.safeclaw', 'config.json');
|
|
25
|
-
if (existsSync(configPath)) {
|
|
26
|
-
try {
|
|
27
|
-
const raw = JSON.parse(readFileSync(configPath, 'utf-8'));
|
|
28
|
-
if (raw.enabled === false)
|
|
29
|
-
defaults.enabled = false;
|
|
30
|
-
if (raw.remote?.serviceUrl)
|
|
31
|
-
defaults.serviceUrl = raw.remote.serviceUrl;
|
|
32
|
-
if (raw.remote?.apiKey)
|
|
33
|
-
defaults.apiKey = raw.remote.apiKey;
|
|
34
|
-
if (raw.remote?.timeoutMs)
|
|
35
|
-
defaults.timeoutMs = raw.remote.timeoutMs;
|
|
36
|
-
if (raw.enforcement?.mode)
|
|
37
|
-
defaults.enforcement = raw.enforcement.mode;
|
|
38
|
-
if (raw.enforcement?.failMode)
|
|
39
|
-
defaults.failMode = raw.enforcement.failMode;
|
|
40
|
-
if (raw.agentId)
|
|
41
|
-
defaults.agentId = raw.agentId;
|
|
42
|
-
if (raw.agentToken)
|
|
43
|
-
defaults.agentToken = raw.agentToken;
|
|
44
|
-
}
|
|
45
|
-
catch {
|
|
46
|
-
// Config file unreadable — use defaults
|
|
47
|
-
}
|
|
48
|
-
}
|
|
49
|
-
defaults.serviceUrl = defaults.serviceUrl.replace(/\/+$/, '');
|
|
50
|
-
const validModes = ['enforce', 'warn-only', 'audit-only', 'disabled'];
|
|
51
|
-
if (!validModes.includes(defaults.enforcement)) {
|
|
52
|
-
console.warn(`[SafeClaw] Invalid enforcement mode "${defaults.enforcement}", defaulting to "enforce"`);
|
|
53
|
-
defaults.enforcement = 'enforce';
|
|
54
|
-
}
|
|
55
|
-
const validFailModes = ['open', 'closed'];
|
|
56
|
-
if (!validFailModes.includes(defaults.failMode)) {
|
|
57
|
-
console.warn(`[SafeClaw] Invalid fail mode "${defaults.failMode}", defaulting to "closed"`);
|
|
58
|
-
defaults.failMode = 'closed';
|
|
59
|
-
}
|
|
60
|
-
return defaults;
|
|
61
|
-
}
|
|
9
|
+
import { loadConfig, configHash } from './tui/config.js';
|
|
10
|
+
// --- Configuration ---
|
|
62
11
|
const config = loadConfig();
|
|
63
12
|
// --- HTTP Client ---
|
|
64
13
|
async function post(path, body) {
|
|
@@ -139,8 +88,43 @@ export default {
|
|
|
139
88
|
console.log('[SafeClaw] Plugin disabled');
|
|
140
89
|
return;
|
|
141
90
|
}
|
|
142
|
-
//
|
|
143
|
-
|
|
91
|
+
// Heartbeat watchdog — send config hash to service every 30s
|
|
92
|
+
const sendHeartbeat = async () => {
|
|
93
|
+
try {
|
|
94
|
+
await fetch(`${config.serviceUrl}/heartbeat`, {
|
|
95
|
+
method: 'POST',
|
|
96
|
+
headers: { 'Content-Type': 'application/json' },
|
|
97
|
+
body: JSON.stringify({
|
|
98
|
+
agentId: config.agentId || 'default',
|
|
99
|
+
configHash: configHash(config),
|
|
100
|
+
status: 'alive',
|
|
101
|
+
}),
|
|
102
|
+
signal: AbortSignal.timeout(config.timeoutMs),
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
catch {
|
|
106
|
+
// Heartbeat failure is non-fatal
|
|
107
|
+
}
|
|
108
|
+
};
|
|
109
|
+
// Start heartbeat after connection check
|
|
110
|
+
checkConnection().then(() => sendHeartbeat()).catch(() => { });
|
|
111
|
+
const heartbeatInterval = setInterval(sendHeartbeat, 30000);
|
|
112
|
+
// Clean shutdown: send shutdown heartbeat and clear interval
|
|
113
|
+
const shutdown = () => {
|
|
114
|
+
clearInterval(heartbeatInterval);
|
|
115
|
+
fetch(`${config.serviceUrl}/heartbeat`, {
|
|
116
|
+
method: 'POST',
|
|
117
|
+
headers: { 'Content-Type': 'application/json' },
|
|
118
|
+
body: JSON.stringify({
|
|
119
|
+
agentId: config.agentId || 'default',
|
|
120
|
+
configHash: configHash(config),
|
|
121
|
+
status: 'shutdown',
|
|
122
|
+
}),
|
|
123
|
+
}).catch(() => { });
|
|
124
|
+
};
|
|
125
|
+
process.on('exit', shutdown);
|
|
126
|
+
process.on('SIGINT', () => { shutdown(); process.exit(0); });
|
|
127
|
+
process.on('SIGTERM', () => { shutdown(); process.exit(0); });
|
|
144
128
|
// THE GATE — constraint checking on every tool call
|
|
145
129
|
api.on('before_tool_call', async (event, ctx) => {
|
|
146
130
|
const r = await post('/evaluate/tool-call', {
|
|
@@ -156,6 +140,9 @@ export default {
|
|
|
156
140
|
else if (r === null && config.failMode === 'closed' && config.enforcement === 'warn-only') {
|
|
157
141
|
console.warn(`[SafeClaw] Service unavailable at ${config.serviceUrl} (fail-closed mode, warn-only)`);
|
|
158
142
|
}
|
|
143
|
+
else if (r === null && config.failMode === 'closed' && config.enforcement === 'audit-only') {
|
|
144
|
+
console.warn(`[SafeClaw] Service unavailable at ${config.serviceUrl} (fail-closed mode, audit-only)`);
|
|
145
|
+
}
|
|
159
146
|
if (r?.block) {
|
|
160
147
|
if (config.enforcement === 'enforce') {
|
|
161
148
|
return { block: true, blockReason: r.reason };
|
|
@@ -190,8 +177,14 @@ export default {
|
|
|
190
177
|
else if (r === null && config.failMode === 'closed' && config.enforcement === 'warn-only') {
|
|
191
178
|
console.warn('[SafeClaw] Service unavailable (fail-closed mode, warn-only)');
|
|
192
179
|
}
|
|
193
|
-
if (r?.block
|
|
194
|
-
|
|
180
|
+
if (r?.block) {
|
|
181
|
+
if (config.enforcement === 'enforce') {
|
|
182
|
+
return { cancel: true };
|
|
183
|
+
}
|
|
184
|
+
if (config.enforcement === 'warn-only') {
|
|
185
|
+
console.warn(`[SafeClaw] Warning: ${r.reason}`);
|
|
186
|
+
}
|
|
187
|
+
// audit-only: logged server-side, no action here
|
|
195
188
|
}
|
|
196
189
|
}, { priority: 100 });
|
|
197
190
|
// Async logging — fire-and-forget, no return value needed
|
|
@@ -213,8 +206,8 @@ export default {
|
|
|
213
206
|
toolName: event.toolName ?? event.tool_name,
|
|
214
207
|
params: event.params ?? {},
|
|
215
208
|
result: event.result ?? '',
|
|
216
|
-
success: event.success ??
|
|
217
|
-
}).catch(() =>
|
|
209
|
+
success: event.success ?? false,
|
|
210
|
+
}).catch((e) => console.warn('[SafeClaw] Failed to record tool result:', e));
|
|
218
211
|
});
|
|
219
212
|
},
|
|
220
213
|
};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export default function About(): import("react/jsx-runtime").JSX.Element;
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { Text, Box } from 'ink';
|
|
3
|
+
export default function About() {
|
|
4
|
+
return (_jsxs(Box, { flexDirection: "column", paddingX: 1, children: [_jsx(Box, { marginBottom: 1, children: _jsx(Text, { bold: true, children: "About" }) }), _jsx(Text, { children: " SafeClaw Neurosymbolic Governance" }), _jsx(Text, { dimColor: true, children: " Validates AI agent actions against OWL" }), _jsx(Text, { dimColor: true, children: " ontologies and SHACL constraints." }), _jsxs(Box, { marginTop: 1, children: [_jsx(Text, { children: " Web: " }), _jsx(Text, { color: "cyan", children: "https://safeclaw.eu" })] }), _jsxs(Box, { children: [_jsx(Text, { children: " Docs: " }), _jsx(Text, { color: "cyan", children: "https://safeclaw.eu/docs" })] }), _jsxs(Box, { children: [_jsx(Text, { children: " Repo: " }), _jsx(Text, { color: "cyan", children: "https://github.com/tendlyeu/SafeClaw" })] }), _jsx(Box, { marginTop: 1, children: _jsx(Text, { dimColor: true, children: " q to quit" }) })] }));
|
|
5
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export default function App(): import("react/jsx-runtime").JSX.Element;
|
package/dist/tui/App.js
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { useState } from 'react';
|
|
3
|
+
import { Text, Box, useInput, useApp } from 'ink';
|
|
4
|
+
import { loadConfig } from './config.js';
|
|
5
|
+
import Status from './Status.js';
|
|
6
|
+
import Settings from './Settings.js';
|
|
7
|
+
import About from './About.js';
|
|
8
|
+
const TABS = ['Status', 'Settings', 'About'];
|
|
9
|
+
export default function App() {
|
|
10
|
+
const { exit } = useApp();
|
|
11
|
+
const [tab, setTab] = useState('Status');
|
|
12
|
+
const [config, setConfig] = useState(loadConfig());
|
|
13
|
+
useInput((input, key) => {
|
|
14
|
+
if (input === 'q' && tab !== 'Settings') {
|
|
15
|
+
exit();
|
|
16
|
+
return;
|
|
17
|
+
}
|
|
18
|
+
if (key.tab || (input === '1' || input === '2' || input === '3')) {
|
|
19
|
+
if (input === '1')
|
|
20
|
+
setTab('Status');
|
|
21
|
+
else if (input === '2')
|
|
22
|
+
setTab('Settings');
|
|
23
|
+
else if (input === '3')
|
|
24
|
+
setTab('About');
|
|
25
|
+
else {
|
|
26
|
+
const idx = TABS.indexOf(tab);
|
|
27
|
+
setTab(TABS[(idx + 1) % TABS.length]);
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
});
|
|
31
|
+
return (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Box, { borderStyle: "single", borderColor: "green", paddingX: 1, children: [_jsx(Text, { bold: true, color: "green", children: "SafeClaw " }), _jsx(Text, { dimColor: true, children: "v0.2.0" })] }), _jsxs(Box, { paddingX: 1, gap: 2, children: [TABS.map((t, i) => (_jsx(Text, { bold: tab === t, color: tab === t ? 'cyan' : 'white', dimColor: tab !== t, children: `${i + 1}:${t}` }, t))), _jsx(Text, { dimColor: true, children: " tab/1-3 to switch" })] }), _jsxs(Box, { marginTop: 1, children: [tab === 'Status' && _jsx(Status, { config: config }), tab === 'Settings' && (_jsx(Settings, { config: config, onConfigChange: setConfig })), tab === 'About' && _jsx(About, {})] })] }));
|
|
32
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import { type SafeClawConfig } from './config.js';
|
|
2
|
+
interface SettingsProps {
|
|
3
|
+
config: SafeClawConfig;
|
|
4
|
+
onConfigChange: (config: SafeClawConfig) => void;
|
|
5
|
+
}
|
|
6
|
+
export default function Settings({ config, onConfigChange }: SettingsProps): import("react/jsx-runtime").JSX.Element;
|
|
7
|
+
export {};
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { useState } from 'react';
|
|
3
|
+
import { Text, Box, useInput } from 'ink';
|
|
4
|
+
import { saveConfig } from './config.js';
|
|
5
|
+
const ENFORCEMENT_MODES = ['enforce', 'warn-only', 'audit-only', 'disabled'];
|
|
6
|
+
const FAIL_MODES = ['closed', 'open'];
|
|
7
|
+
const SETTINGS = [
|
|
8
|
+
{ key: 'enabled', label: 'Enabled', type: 'toggle' },
|
|
9
|
+
{ key: 'enforcement', label: 'Enforcement', type: 'cycle', values: ENFORCEMENT_MODES },
|
|
10
|
+
{ key: 'failMode', label: 'Fail Mode', type: 'cycle', values: FAIL_MODES },
|
|
11
|
+
{ key: 'serviceUrl', label: 'Service URL', type: 'text' },
|
|
12
|
+
];
|
|
13
|
+
export default function Settings({ config, onConfigChange }) {
|
|
14
|
+
const [selected, setSelected] = useState(0);
|
|
15
|
+
const [editing, setEditing] = useState(false);
|
|
16
|
+
const [editBuffer, setEditBuffer] = useState('');
|
|
17
|
+
const updateConfig = (patch) => {
|
|
18
|
+
const updated = { ...config, ...patch };
|
|
19
|
+
saveConfig(updated);
|
|
20
|
+
onConfigChange(updated);
|
|
21
|
+
};
|
|
22
|
+
useInput((input, key) => {
|
|
23
|
+
if (editing) {
|
|
24
|
+
if (key.return) {
|
|
25
|
+
updateConfig({ serviceUrl: editBuffer });
|
|
26
|
+
setEditing(false);
|
|
27
|
+
}
|
|
28
|
+
else if (key.escape) {
|
|
29
|
+
setEditing(false);
|
|
30
|
+
}
|
|
31
|
+
else if (key.backspace || key.delete) {
|
|
32
|
+
setEditBuffer(prev => prev.slice(0, -1));
|
|
33
|
+
}
|
|
34
|
+
else if (input && !key.ctrl && !key.meta) {
|
|
35
|
+
setEditBuffer(prev => prev + input);
|
|
36
|
+
}
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
39
|
+
if (key.upArrow) {
|
|
40
|
+
setSelected(prev => Math.max(0, prev - 1));
|
|
41
|
+
}
|
|
42
|
+
else if (key.downArrow) {
|
|
43
|
+
setSelected(prev => Math.min(SETTINGS.length - 1, prev + 1));
|
|
44
|
+
}
|
|
45
|
+
else if (key.return || key.rightArrow || key.leftArrow) {
|
|
46
|
+
const setting = SETTINGS[selected];
|
|
47
|
+
if (setting.type === 'toggle') {
|
|
48
|
+
updateConfig({ enabled: !config.enabled });
|
|
49
|
+
}
|
|
50
|
+
else if (setting.type === 'cycle' && setting.values) {
|
|
51
|
+
const currentKey = setting.key;
|
|
52
|
+
const current = config[currentKey];
|
|
53
|
+
const idx = setting.values.indexOf(current);
|
|
54
|
+
const dir = key.leftArrow ? -1 : 1;
|
|
55
|
+
const next = setting.values[(idx + dir + setting.values.length) % setting.values.length];
|
|
56
|
+
updateConfig({ [currentKey]: next });
|
|
57
|
+
}
|
|
58
|
+
else if (setting.type === 'text' && key.return) {
|
|
59
|
+
setEditing(true);
|
|
60
|
+
setEditBuffer(config.serviceUrl);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
});
|
|
64
|
+
return (_jsxs(Box, { flexDirection: "column", paddingX: 1, children: [_jsx(Box, { marginBottom: 1, children: _jsx(Text, { bold: true, children: "Settings" }) }), SETTINGS.map((setting, i) => {
|
|
65
|
+
const isSelected = i === selected;
|
|
66
|
+
const prefix = isSelected ? '▸ ' : ' ';
|
|
67
|
+
let value;
|
|
68
|
+
if (setting.key === 'enabled') {
|
|
69
|
+
value = config.enabled ? 'ON' : 'OFF';
|
|
70
|
+
}
|
|
71
|
+
else if (setting.key === 'serviceUrl' && editing && isSelected) {
|
|
72
|
+
value = editBuffer + '█';
|
|
73
|
+
}
|
|
74
|
+
else {
|
|
75
|
+
value = String(config[setting.key]);
|
|
76
|
+
}
|
|
77
|
+
const showArrows = isSelected && setting.type === 'cycle';
|
|
78
|
+
return (_jsxs(Box, { children: [_jsxs(Text, { color: isSelected ? 'cyan' : undefined, bold: isSelected, children: [prefix, setting.label.padEnd(16)] }), showArrows && _jsx(Text, { dimColor: true, children: '◀ ' }), _jsx(Text, { color: setting.key === 'enabled'
|
|
79
|
+
? config.enabled ? 'green' : 'red'
|
|
80
|
+
: undefined, children: value }), showArrows && _jsx(Text, { dimColor: true, children: ' ▶' })] }, setting.key));
|
|
81
|
+
}), _jsx(Box, { marginTop: 1, children: _jsx(Text, { dimColor: true, children: editing
|
|
82
|
+
? ' type to edit · enter to save · esc to cancel'
|
|
83
|
+
: ' ↑↓ navigate · ←→ change · enter to edit URL · q quit' }) }), _jsx(Box, { children: _jsx(Text, { dimColor: true, children: ' Saves to ~/.safeclaw/config.json' }) })] }));
|
|
84
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { useState, useEffect } from 'react';
|
|
3
|
+
import { Text, Box, useInput } from 'ink';
|
|
4
|
+
import { exec } from 'child_process';
|
|
5
|
+
export default function Status({ config }) {
|
|
6
|
+
const [health, setHealth] = useState(null);
|
|
7
|
+
const [error, setError] = useState(null);
|
|
8
|
+
const [lastCheck, setLastCheck] = useState(null);
|
|
9
|
+
const [openclawStatus, setOpenclawStatus] = useState('checking');
|
|
10
|
+
const [restartMsg, setRestartMsg] = useState(null);
|
|
11
|
+
const checkHealth = async () => {
|
|
12
|
+
try {
|
|
13
|
+
const res = await fetch(`${config.serviceUrl}/health`, {
|
|
14
|
+
signal: AbortSignal.timeout(config.timeoutMs * 2),
|
|
15
|
+
});
|
|
16
|
+
if (res.ok) {
|
|
17
|
+
const data = await res.json();
|
|
18
|
+
setHealth(data);
|
|
19
|
+
setError(null);
|
|
20
|
+
}
|
|
21
|
+
else {
|
|
22
|
+
setHealth(null);
|
|
23
|
+
setError(`HTTP ${res.status}`);
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
catch {
|
|
27
|
+
setHealth(null);
|
|
28
|
+
setError('Cannot connect');
|
|
29
|
+
}
|
|
30
|
+
setLastCheck(new Date());
|
|
31
|
+
};
|
|
32
|
+
const checkOpenClaw = () => {
|
|
33
|
+
exec('openclaw daemon status', { timeout: 10000 }, (err, stdout) => {
|
|
34
|
+
if (err) {
|
|
35
|
+
setOpenclawStatus('not running');
|
|
36
|
+
}
|
|
37
|
+
else {
|
|
38
|
+
const output = stdout.toLowerCase();
|
|
39
|
+
setOpenclawStatus(output.includes('running') ? 'running' : 'not running');
|
|
40
|
+
}
|
|
41
|
+
});
|
|
42
|
+
};
|
|
43
|
+
const restartOpenClaw = () => {
|
|
44
|
+
setRestartMsg('Restarting...');
|
|
45
|
+
exec('openclaw daemon restart', { timeout: 15000 }, (err) => {
|
|
46
|
+
if (err) {
|
|
47
|
+
setRestartMsg('Restart failed');
|
|
48
|
+
}
|
|
49
|
+
else {
|
|
50
|
+
setRestartMsg('Restarted');
|
|
51
|
+
checkOpenClaw();
|
|
52
|
+
}
|
|
53
|
+
setTimeout(() => setRestartMsg(null), 3000);
|
|
54
|
+
});
|
|
55
|
+
};
|
|
56
|
+
useEffect(() => {
|
|
57
|
+
checkHealth();
|
|
58
|
+
checkOpenClaw();
|
|
59
|
+
const interval = setInterval(() => {
|
|
60
|
+
checkHealth();
|
|
61
|
+
checkOpenClaw();
|
|
62
|
+
}, 10000);
|
|
63
|
+
return () => clearInterval(interval);
|
|
64
|
+
}, []);
|
|
65
|
+
useInput((input) => {
|
|
66
|
+
if (input === 'r') {
|
|
67
|
+
restartOpenClaw();
|
|
68
|
+
}
|
|
69
|
+
});
|
|
70
|
+
const connected = health !== null;
|
|
71
|
+
const dot = '●';
|
|
72
|
+
const serviceDotColor = connected ? 'green' : 'red';
|
|
73
|
+
const serviceText = connected
|
|
74
|
+
? `Connected (${config.serviceUrl.replace(/^https?:\/\//, '').replace(/\/api\/v1$/, '')})`
|
|
75
|
+
: error ?? 'Disconnected';
|
|
76
|
+
const openclawDotColor = openclawStatus === 'running' ? 'green' : openclawStatus === 'checking' ? 'yellow' : 'red';
|
|
77
|
+
const openclawText = openclawStatus === 'checking' ? 'Checking...'
|
|
78
|
+
: openclawStatus === 'running' ? 'Running'
|
|
79
|
+
: 'Not running';
|
|
80
|
+
return (_jsxs(Box, { flexDirection: "column", paddingX: 1, children: [_jsx(Box, { marginBottom: 1, children: _jsx(Text, { bold: true, children: "Status" }) }), _jsxs(Box, { children: [_jsx(Text, { dimColor: true, children: ' Service ' }), _jsxs(Text, { color: serviceDotColor, children: [dot, " "] }), _jsx(Text, { children: serviceText })] }), _jsxs(Box, { children: [_jsx(Text, { dimColor: true, children: ' OpenClaw ' }), _jsxs(Text, { color: openclawDotColor, children: [dot, " "] }), _jsx(Text, { children: openclawText }), restartMsg && _jsx(Text, { dimColor: true, children: ` (${restartMsg})` })] }), _jsxs(Box, { children: [_jsx(Text, { dimColor: true, children: ' Enforcement ' }), _jsx(Text, { children: config.enforcement })] }), _jsxs(Box, { children: [_jsx(Text, { dimColor: true, children: ' Fail Mode ' }), _jsx(Text, { children: config.failMode })] }), _jsxs(Box, { children: [_jsx(Text, { dimColor: true, children: ' Enabled ' }), _jsx(Text, { color: config.enabled ? 'green' : 'red', children: config.enabled ? 'ON' : 'OFF' })] }), health?.version && (_jsxs(Box, { marginTop: 1, children: [_jsx(Text, { dimColor: true, children: ' Service v' }), _jsx(Text, { children: health.version })] })), lastCheck && (_jsx(Box, { marginTop: 1, children: _jsxs(Text, { dimColor: true, children: [' Last check: ', lastCheck.toLocaleTimeString()] }) })), _jsxs(Box, { marginTop: 1, children: [_jsx(Text, { dimColor: true, children: ' Press ' }), _jsx(Text, { bold: true, children: "r" }), _jsx(Text, { dimColor: true, children: ' to restart OpenClaw daemon' })] })] }));
|
|
81
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SafeClaw shared configuration module.
|
|
3
|
+
*
|
|
4
|
+
* Used by both the OpenClaw plugin (index.ts) and the TUI.
|
|
5
|
+
* Reads ~/.safeclaw/config.json, applies env-var overrides,
|
|
6
|
+
* and exposes helpers for saving and hashing config state.
|
|
7
|
+
*/
|
|
8
|
+
export interface SafeClawConfig {
|
|
9
|
+
serviceUrl: string;
|
|
10
|
+
apiKey: string;
|
|
11
|
+
timeoutMs: number;
|
|
12
|
+
enabled: boolean;
|
|
13
|
+
enforcement: 'enforce' | 'warn-only' | 'audit-only' | 'disabled';
|
|
14
|
+
failMode: 'open' | 'closed';
|
|
15
|
+
agentId: string;
|
|
16
|
+
agentToken: string;
|
|
17
|
+
}
|
|
18
|
+
export declare const CONFIG_PATH: string;
|
|
19
|
+
export declare function loadConfig(): SafeClawConfig;
|
|
20
|
+
/**
|
|
21
|
+
* Persist managed config fields back to ~/.safeclaw/config.json.
|
|
22
|
+
* Reads the existing file (if any), merges the fields SafeClaw manages,
|
|
23
|
+
* and writes the result. Fields not managed by SafeClaw are preserved.
|
|
24
|
+
*/
|
|
25
|
+
export declare function saveConfig(config: SafeClawConfig): void;
|
|
26
|
+
/**
|
|
27
|
+
* SHA-256 hash of the four TUI-managed config fields.
|
|
28
|
+
* Used to detect whether the on-disk config has drifted from the in-memory state.
|
|
29
|
+
*/
|
|
30
|
+
export declare function configHash(config: SafeClawConfig): string;
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SafeClaw shared configuration module.
|
|
3
|
+
*
|
|
4
|
+
* Used by both the OpenClaw plugin (index.ts) and the TUI.
|
|
5
|
+
* Reads ~/.safeclaw/config.json, applies env-var overrides,
|
|
6
|
+
* and exposes helpers for saving and hashing config state.
|
|
7
|
+
*/
|
|
8
|
+
import { readFileSync, existsSync, writeFileSync, mkdirSync } from 'fs';
|
|
9
|
+
import { join, dirname } from 'path';
|
|
10
|
+
import { homedir } from 'os';
|
|
11
|
+
import crypto from 'crypto';
|
|
12
|
+
// --- Constants ---
|
|
13
|
+
export const CONFIG_PATH = join(homedir(), '.safeclaw', 'config.json');
|
|
14
|
+
// --- Functions ---
|
|
15
|
+
export function loadConfig() {
|
|
16
|
+
const defaults = {
|
|
17
|
+
serviceUrl: 'https://api.safeclaw.eu/api/v1',
|
|
18
|
+
apiKey: '',
|
|
19
|
+
timeoutMs: 500,
|
|
20
|
+
enabled: true,
|
|
21
|
+
enforcement: 'enforce',
|
|
22
|
+
failMode: 'closed',
|
|
23
|
+
agentId: '',
|
|
24
|
+
agentToken: '',
|
|
25
|
+
};
|
|
26
|
+
// Load from config file first
|
|
27
|
+
if (existsSync(CONFIG_PATH)) {
|
|
28
|
+
try {
|
|
29
|
+
const raw = JSON.parse(readFileSync(CONFIG_PATH, 'utf-8'));
|
|
30
|
+
if (raw.enabled === false)
|
|
31
|
+
defaults.enabled = false;
|
|
32
|
+
if (raw.remote?.serviceUrl)
|
|
33
|
+
defaults.serviceUrl = raw.remote.serviceUrl;
|
|
34
|
+
if (raw.remote?.apiKey)
|
|
35
|
+
defaults.apiKey = raw.remote.apiKey;
|
|
36
|
+
if (raw.remote?.timeoutMs)
|
|
37
|
+
defaults.timeoutMs = raw.remote.timeoutMs;
|
|
38
|
+
if (raw.enforcement?.mode)
|
|
39
|
+
defaults.enforcement = raw.enforcement.mode;
|
|
40
|
+
if (raw.enforcement?.failMode)
|
|
41
|
+
defaults.failMode = raw.enforcement.failMode;
|
|
42
|
+
if (raw.agentId)
|
|
43
|
+
defaults.agentId = raw.agentId;
|
|
44
|
+
if (raw.agentToken)
|
|
45
|
+
defaults.agentToken = raw.agentToken;
|
|
46
|
+
}
|
|
47
|
+
catch {
|
|
48
|
+
// Config file unreadable — use defaults
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
// Env vars override config file
|
|
52
|
+
if (process.env.SAFECLAW_URL)
|
|
53
|
+
defaults.serviceUrl = process.env.SAFECLAW_URL;
|
|
54
|
+
if (process.env.SAFECLAW_API_KEY)
|
|
55
|
+
defaults.apiKey = process.env.SAFECLAW_API_KEY;
|
|
56
|
+
if (process.env.SAFECLAW_TIMEOUT_MS)
|
|
57
|
+
defaults.timeoutMs = parseInt(process.env.SAFECLAW_TIMEOUT_MS, 10);
|
|
58
|
+
if (process.env.SAFECLAW_ENABLED === 'false')
|
|
59
|
+
defaults.enabled = false;
|
|
60
|
+
if (process.env.SAFECLAW_ENFORCEMENT)
|
|
61
|
+
defaults.enforcement = process.env.SAFECLAW_ENFORCEMENT;
|
|
62
|
+
if (process.env.SAFECLAW_FAIL_MODE)
|
|
63
|
+
defaults.failMode = process.env.SAFECLAW_FAIL_MODE;
|
|
64
|
+
if (process.env.SAFECLAW_AGENT_ID)
|
|
65
|
+
defaults.agentId = process.env.SAFECLAW_AGENT_ID;
|
|
66
|
+
if (process.env.SAFECLAW_AGENT_TOKEN)
|
|
67
|
+
defaults.agentToken = process.env.SAFECLAW_AGENT_TOKEN;
|
|
68
|
+
defaults.serviceUrl = defaults.serviceUrl.replace(/\/+$/, '');
|
|
69
|
+
const validModes = ['enforce', 'warn-only', 'audit-only', 'disabled'];
|
|
70
|
+
if (!validModes.includes(defaults.enforcement)) {
|
|
71
|
+
console.warn(`[SafeClaw] Invalid enforcement mode "${defaults.enforcement}", defaulting to "enforce"`);
|
|
72
|
+
defaults.enforcement = 'enforce';
|
|
73
|
+
}
|
|
74
|
+
const validFailModes = ['open', 'closed'];
|
|
75
|
+
if (!validFailModes.includes(defaults.failMode)) {
|
|
76
|
+
console.warn(`[SafeClaw] Invalid fail mode "${defaults.failMode}", defaulting to "closed"`);
|
|
77
|
+
defaults.failMode = 'closed';
|
|
78
|
+
}
|
|
79
|
+
return defaults;
|
|
80
|
+
}
|
|
81
|
+
/**
|
|
82
|
+
* Persist managed config fields back to ~/.safeclaw/config.json.
|
|
83
|
+
* Reads the existing file (if any), merges the fields SafeClaw manages,
|
|
84
|
+
* and writes the result. Fields not managed by SafeClaw are preserved.
|
|
85
|
+
*/
|
|
86
|
+
export function saveConfig(config) {
|
|
87
|
+
let existing = {};
|
|
88
|
+
if (existsSync(CONFIG_PATH)) {
|
|
89
|
+
try {
|
|
90
|
+
existing = JSON.parse(readFileSync(CONFIG_PATH, 'utf-8'));
|
|
91
|
+
}
|
|
92
|
+
catch {
|
|
93
|
+
// Unreadable — start fresh
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
// Merge managed fields into existing structure
|
|
97
|
+
existing.enabled = config.enabled;
|
|
98
|
+
if (!existing.remote || typeof existing.remote !== 'object') {
|
|
99
|
+
existing.remote = {};
|
|
100
|
+
}
|
|
101
|
+
existing.remote.serviceUrl = config.serviceUrl;
|
|
102
|
+
if (!existing.enforcement || typeof existing.enforcement !== 'object') {
|
|
103
|
+
existing.enforcement = {};
|
|
104
|
+
}
|
|
105
|
+
existing.enforcement.mode = config.enforcement;
|
|
106
|
+
existing.enforcement.failMode = config.failMode;
|
|
107
|
+
// Ensure parent directory exists
|
|
108
|
+
mkdirSync(dirname(CONFIG_PATH), { recursive: true });
|
|
109
|
+
writeFileSync(CONFIG_PATH, JSON.stringify(existing, null, 2) + '\n', 'utf-8');
|
|
110
|
+
}
|
|
111
|
+
/**
|
|
112
|
+
* SHA-256 hash of the four TUI-managed config fields.
|
|
113
|
+
* Used to detect whether the on-disk config has drifted from the in-memory state.
|
|
114
|
+
*/
|
|
115
|
+
export function configHash(config) {
|
|
116
|
+
const payload = JSON.stringify({
|
|
117
|
+
enabled: config.enabled,
|
|
118
|
+
enforcement: config.enforcement,
|
|
119
|
+
failMode: config.failMode,
|
|
120
|
+
serviceUrl: config.serviceUrl,
|
|
121
|
+
});
|
|
122
|
+
return crypto.createHash('sha256').update(payload).digest('hex');
|
|
123
|
+
}
|