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/dist/index.js
CHANGED
|
@@ -7,23 +7,38 @@
|
|
|
7
7
|
* to the SafeClaw service and acts on the responses.
|
|
8
8
|
*/
|
|
9
9
|
import { loadConfig, configHash } from './tui/config.js';
|
|
10
|
+
import crypto from 'crypto';
|
|
11
|
+
import { createRequire } from 'module';
|
|
12
|
+
const require = createRequire(import.meta.url);
|
|
13
|
+
const { version: PLUGIN_VERSION } = require('./package.json');
|
|
10
14
|
// --- Configuration ---
|
|
11
|
-
const
|
|
15
|
+
const CONFIG_RELOAD_INTERVAL_MS = 60_000; // Reload config every 60 seconds
|
|
16
|
+
let config = loadConfig();
|
|
17
|
+
let configLoadedAt = Date.now();
|
|
18
|
+
function getConfig() {
|
|
19
|
+
const now = Date.now();
|
|
20
|
+
if (now - configLoadedAt >= CONFIG_RELOAD_INTERVAL_MS) {
|
|
21
|
+
config = loadConfig();
|
|
22
|
+
configLoadedAt = now;
|
|
23
|
+
}
|
|
24
|
+
return config;
|
|
25
|
+
}
|
|
12
26
|
// --- HTTP Client ---
|
|
13
27
|
async function post(path, body) {
|
|
14
|
-
|
|
28
|
+
const cfg = getConfig();
|
|
29
|
+
if (!cfg.enabled)
|
|
15
30
|
return null;
|
|
16
31
|
const headers = { 'Content-Type': 'application/json' };
|
|
17
|
-
if (
|
|
18
|
-
headers['Authorization'] = `Bearer ${
|
|
32
|
+
if (cfg.apiKey) {
|
|
33
|
+
headers['Authorization'] = `Bearer ${cfg.apiKey}`;
|
|
19
34
|
}
|
|
20
|
-
const agentFields =
|
|
35
|
+
const agentFields = cfg.agentId ? { agentId: cfg.agentId, agentToken: cfg.agentToken } : {};
|
|
21
36
|
try {
|
|
22
|
-
const res = await fetch(`${
|
|
37
|
+
const res = await fetch(`${cfg.serviceUrl}${path}`, {
|
|
23
38
|
method: 'POST',
|
|
24
39
|
headers,
|
|
25
40
|
body: JSON.stringify({ ...body, ...agentFields }),
|
|
26
|
-
signal: AbortSignal.timeout(
|
|
41
|
+
signal: AbortSignal.timeout(cfg.timeoutMs),
|
|
27
42
|
});
|
|
28
43
|
if (!res.ok) {
|
|
29
44
|
// Try to parse structured error body from service
|
|
@@ -43,26 +58,27 @@ async function post(path, body) {
|
|
|
43
58
|
}
|
|
44
59
|
catch (e) {
|
|
45
60
|
if (e instanceof DOMException && e.name === 'TimeoutError') {
|
|
46
|
-
console.warn(`[SafeClaw] Timeout after ${
|
|
61
|
+
console.warn(`[SafeClaw] Timeout after ${cfg.timeoutMs}ms on ${path} (${cfg.serviceUrl})`);
|
|
47
62
|
}
|
|
48
63
|
else if (e instanceof TypeError && (e.message.includes('fetch') || e.message.includes('ECONNREFUSED'))) {
|
|
49
|
-
console.warn(`[SafeClaw] Connection refused: ${
|
|
64
|
+
console.warn(`[SafeClaw] Connection refused: ${cfg.serviceUrl}${path} — is the service running?`);
|
|
50
65
|
}
|
|
51
66
|
else {
|
|
52
|
-
console.warn(`[SafeClaw] Service unavailable: ${
|
|
67
|
+
console.warn(`[SafeClaw] Service unavailable: ${cfg.serviceUrl}${path}`);
|
|
53
68
|
}
|
|
54
69
|
return null; // Caller checks failMode
|
|
55
70
|
}
|
|
56
71
|
}
|
|
57
72
|
let handshakeCompleted = false;
|
|
58
73
|
async function performHandshake() {
|
|
59
|
-
|
|
74
|
+
const cfg = getConfig();
|
|
75
|
+
if (!cfg.apiKey) {
|
|
60
76
|
console.warn('[SafeClaw] No API key configured — skipping handshake');
|
|
61
77
|
return false;
|
|
62
78
|
}
|
|
63
79
|
const r = await post('/handshake', {
|
|
64
|
-
pluginVersion:
|
|
65
|
-
configHash: configHash(
|
|
80
|
+
pluginVersion: PLUGIN_VERSION,
|
|
81
|
+
configHash: configHash(cfg),
|
|
66
82
|
});
|
|
67
83
|
if (r === null) {
|
|
68
84
|
console.warn('[SafeClaw] ✗ Handshake failed — API key may be invalid or service unreachable');
|
|
@@ -73,12 +89,13 @@ async function performHandshake() {
|
|
|
73
89
|
return true;
|
|
74
90
|
}
|
|
75
91
|
async function checkConnection() {
|
|
92
|
+
const cfg = getConfig();
|
|
76
93
|
const label = `[SafeClaw]`;
|
|
77
|
-
console.log(`${label} Connecting to ${
|
|
78
|
-
console.log(`${label} Mode: enforcement=${
|
|
94
|
+
console.log(`${label} Connecting to ${cfg.serviceUrl} ...`);
|
|
95
|
+
console.log(`${label} Mode: enforcement=${cfg.enforcement}, failMode=${cfg.failMode}`);
|
|
79
96
|
try {
|
|
80
|
-
const res = await fetch(`${
|
|
81
|
-
signal: AbortSignal.timeout(
|
|
97
|
+
const res = await fetch(`${cfg.serviceUrl}/health`, {
|
|
98
|
+
signal: AbortSignal.timeout(cfg.timeoutMs * 2),
|
|
82
99
|
});
|
|
83
100
|
if (res.ok) {
|
|
84
101
|
const data = await res.json();
|
|
@@ -89,8 +106,8 @@ async function checkConnection() {
|
|
|
89
106
|
}
|
|
90
107
|
}
|
|
91
108
|
catch {
|
|
92
|
-
console.warn(`${label} ✗ Cannot reach service at ${
|
|
93
|
-
if (
|
|
109
|
+
console.warn(`${label} ✗ Cannot reach service at ${cfg.serviceUrl}`);
|
|
110
|
+
if (cfg.failMode === 'closed') {
|
|
94
111
|
console.warn(`${label} fail-mode=closed → tool calls will be BLOCKED until service is reachable`);
|
|
95
112
|
}
|
|
96
113
|
else {
|
|
@@ -101,18 +118,20 @@ async function checkConnection() {
|
|
|
101
118
|
export default {
|
|
102
119
|
id: 'safeclaw',
|
|
103
120
|
name: 'SafeClaw Neurosymbolic Governance',
|
|
104
|
-
version:
|
|
121
|
+
version: PLUGIN_VERSION,
|
|
105
122
|
register(api) {
|
|
106
|
-
if (!
|
|
123
|
+
if (!getConfig().enabled) {
|
|
107
124
|
console.log('[SafeClaw] Plugin disabled');
|
|
108
125
|
return;
|
|
109
126
|
}
|
|
127
|
+
// Generate a unique instance ID for this plugin run (fallback when agentId is not configured)
|
|
128
|
+
const instanceId = getConfig().agentId || `instance-${crypto.randomUUID()}`;
|
|
110
129
|
// Heartbeat watchdog — send config hash to service every 30s
|
|
111
130
|
const sendHeartbeat = async () => {
|
|
112
131
|
try {
|
|
113
132
|
await post('/heartbeat', {
|
|
114
|
-
agentId:
|
|
115
|
-
configHash: configHash(
|
|
133
|
+
agentId: instanceId,
|
|
134
|
+
configHash: configHash(getConfig()),
|
|
116
135
|
status: 'alive',
|
|
117
136
|
});
|
|
118
137
|
}
|
|
@@ -120,32 +139,40 @@ export default {
|
|
|
120
139
|
// Heartbeat failure is non-fatal
|
|
121
140
|
}
|
|
122
141
|
};
|
|
123
|
-
// Start heartbeat after connection check + handshake
|
|
142
|
+
// Start heartbeat only after connection check + handshake completes (#84)
|
|
143
|
+
let heartbeatInterval;
|
|
124
144
|
checkConnection()
|
|
125
145
|
.then(() => performHandshake())
|
|
126
146
|
.then((ok) => {
|
|
127
|
-
if (!ok &&
|
|
128
|
-
console.warn('[SafeClaw]
|
|
147
|
+
if (!ok && getConfig().failMode === 'closed') {
|
|
148
|
+
console.warn('[SafeClaw] Handshake failed with fail-mode=closed — tool calls will be BLOCKED');
|
|
129
149
|
}
|
|
150
|
+
heartbeatInterval = setInterval(sendHeartbeat, 30000);
|
|
130
151
|
return sendHeartbeat();
|
|
131
152
|
})
|
|
132
153
|
.catch(() => { });
|
|
133
|
-
const heartbeatInterval = setInterval(sendHeartbeat, 30000);
|
|
134
154
|
// Clean shutdown: send shutdown heartbeat and clear interval
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
155
|
+
// Use async shutdown for SIGINT/SIGTERM where async is supported (#55)
|
|
156
|
+
const shutdown = async () => {
|
|
157
|
+
if (heartbeatInterval)
|
|
158
|
+
clearInterval(heartbeatInterval);
|
|
159
|
+
try {
|
|
160
|
+
await post('/heartbeat', {
|
|
161
|
+
agentId: instanceId,
|
|
162
|
+
configHash: configHash(getConfig()),
|
|
163
|
+
status: 'shutdown',
|
|
164
|
+
});
|
|
165
|
+
}
|
|
166
|
+
catch {
|
|
167
|
+
// Best-effort shutdown notification
|
|
168
|
+
}
|
|
142
169
|
};
|
|
143
|
-
process.on('
|
|
144
|
-
process.on('
|
|
145
|
-
process.on('SIGTERM', () => { shutdown(); process.exit(0); });
|
|
170
|
+
process.on('SIGINT', async () => { await shutdown(); process.exit(0); });
|
|
171
|
+
process.on('SIGTERM', async () => { await shutdown(); process.exit(0); });
|
|
146
172
|
// THE GATE — constraint checking on every tool call
|
|
147
173
|
api.on('before_tool_call', async (event, ctx) => {
|
|
148
|
-
|
|
174
|
+
const cfg = getConfig();
|
|
175
|
+
if (!handshakeCompleted && cfg.failMode === 'closed' && cfg.enforcement === 'enforce') {
|
|
149
176
|
return { block: true, blockReason: 'SafeClaw handshake not completed (fail-closed)' };
|
|
150
177
|
}
|
|
151
178
|
const r = await post('/evaluate/tool-call', {
|
|
@@ -155,21 +182,22 @@ export default {
|
|
|
155
182
|
params: event.params ?? {},
|
|
156
183
|
sessionHistory: event.sessionHistory ?? [],
|
|
157
184
|
});
|
|
158
|
-
if (r === null &&
|
|
159
|
-
return { block: true, blockReason: `SafeClaw service unavailable at ${
|
|
185
|
+
if (r === null && cfg.failMode === 'closed' && cfg.enforcement === 'enforce') {
|
|
186
|
+
return { block: true, blockReason: `SafeClaw service unavailable at ${cfg.serviceUrl} (fail-closed)` };
|
|
160
187
|
}
|
|
161
|
-
else if (r === null &&
|
|
162
|
-
console.warn(`[SafeClaw] Service unavailable at ${
|
|
188
|
+
else if (r === null && cfg.failMode === 'closed' && cfg.enforcement === 'warn-only') {
|
|
189
|
+
console.warn(`[SafeClaw] Service unavailable at ${cfg.serviceUrl} (fail-closed mode, warn-only)`);
|
|
163
190
|
}
|
|
164
|
-
else if (r === null &&
|
|
165
|
-
console.warn(`[SafeClaw] Service unavailable at ${
|
|
191
|
+
else if (r === null && cfg.failMode === 'closed' && cfg.enforcement === 'audit-only') {
|
|
192
|
+
console.warn(`[SafeClaw] Service unavailable at ${cfg.serviceUrl} (fail-closed mode, audit-only)`);
|
|
166
193
|
}
|
|
167
194
|
if (r?.block) {
|
|
168
|
-
|
|
169
|
-
|
|
195
|
+
const blockReason = r.reason || 'Blocked by SafeClaw (no reason provided)';
|
|
196
|
+
if (cfg.enforcement === 'enforce') {
|
|
197
|
+
return { block: true, blockReason };
|
|
170
198
|
}
|
|
171
|
-
if (
|
|
172
|
-
console.warn(`[SafeClaw] Warning: ${
|
|
199
|
+
if (cfg.enforcement === 'warn-only') {
|
|
200
|
+
console.warn(`[SafeClaw] Warning: ${blockReason}`);
|
|
173
201
|
}
|
|
174
202
|
// audit-only: logged server-side, no action here
|
|
175
203
|
}
|
|
@@ -186,24 +214,29 @@ export default {
|
|
|
186
214
|
}, { priority: 100 });
|
|
187
215
|
// Message governance — check outbound messages
|
|
188
216
|
api.on('message_sending', async (event, ctx) => {
|
|
217
|
+
const cfg = getConfig();
|
|
189
218
|
const r = await post('/evaluate/message', {
|
|
190
219
|
sessionId: ctx.sessionId ?? event.sessionId,
|
|
191
220
|
userId: ctx.userId ?? event.userId,
|
|
192
221
|
to: event.to,
|
|
193
222
|
content: event.content,
|
|
194
223
|
});
|
|
195
|
-
if (r === null &&
|
|
196
|
-
return { cancel: true };
|
|
224
|
+
if (r === null && cfg.failMode === 'closed' && cfg.enforcement === 'enforce') {
|
|
225
|
+
return { cancel: true, cancelReason: 'SafeClaw service unavailable (fail-closed mode)' };
|
|
197
226
|
}
|
|
198
|
-
else if (r === null &&
|
|
227
|
+
else if (r === null && cfg.failMode === 'closed' && cfg.enforcement === 'warn-only') {
|
|
199
228
|
console.warn('[SafeClaw] Service unavailable (fail-closed mode, warn-only)');
|
|
200
229
|
}
|
|
230
|
+
else if (r === null && cfg.failMode === 'closed' && cfg.enforcement === 'audit-only') {
|
|
231
|
+
console.info('[SafeClaw] audit-only: service unreachable, allowing message (fail-closed)');
|
|
232
|
+
}
|
|
201
233
|
if (r?.block) {
|
|
202
|
-
|
|
203
|
-
|
|
234
|
+
const blockReason = r.reason || 'Blocked by SafeClaw (no reason provided)';
|
|
235
|
+
if (cfg.enforcement === 'enforce') {
|
|
236
|
+
return { cancel: true, cancelReason: blockReason };
|
|
204
237
|
}
|
|
205
|
-
if (
|
|
206
|
-
console.warn(`[SafeClaw] Warning: ${
|
|
238
|
+
if (cfg.enforcement === 'warn-only') {
|
|
239
|
+
console.warn(`[SafeClaw] Warning: ${blockReason}`);
|
|
207
240
|
}
|
|
208
241
|
// audit-only: logged server-side, no action here
|
|
209
242
|
}
|
package/dist/tui/About.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
2
|
import { Text, Box } from 'ink';
|
|
3
3
|
export default function About() {
|
|
4
|
-
return (_jsxs(Box, { flexDirection: "column", paddingX: 1, children: [_jsx(Box, { marginBottom: 1, children: _jsx(Text, { bold: true, children: "About" }) }), _jsx(Text, { children: "
|
|
4
|
+
return (_jsxs(Box, { flexDirection: "column", paddingX: 1, children: [_jsx(Box, { marginBottom: 1, children: _jsx(Text, { bold: true, children: "About SafeClaw" }) }), _jsx(Text, { children: " Neurosymbolic governance for AI agents." }), _jsx(Text, { dimColor: true, children: " Validates tool calls, messages, and actions against" }), _jsx(Text, { dimColor: true, children: " OWL ontologies and SHACL constraints before execution." }), _jsx(Box, { marginTop: 1, marginBottom: 1, children: _jsx(Text, { bold: true, children: " Features" }) }), _jsx(Text, { dimColor: true, children: " - 9-step constraint pipeline (SHACL, policies, preferences, dependencies)" }), _jsx(Text, { dimColor: true, children: " - Role-based access control with per-user preferences" }), _jsx(Text, { dimColor: true, children: " - Multi-agent governance with delegation detection" }), _jsx(Text, { dimColor: true, children: " - Append-only audit trail with compliance reporting" }), _jsx(Text, { dimColor: true, children: " - Passive LLM security reviewer and classification observer" }), _jsx(Text, { dimColor: true, children: " - Natural language policy compilation" }), _jsx(Box, { marginTop: 1, marginBottom: 1, children: _jsx(Text, { bold: true, children: " CLI commands" }) }), _jsx(Text, { dimColor: true, children: " safeclaw-plugin This plugin CLI (connect, status, config, tui)" }), _jsx(Text, { dimColor: true, children: " safeclaw Service CLI (serve, audit, policy, pref, status)" }), _jsxs(Box, { marginTop: 1, children: [_jsx(Text, { children: " Web: " }), _jsx(Text, { color: "cyan", children: "https://safeclaw.eu" })] }), _jsxs(Box, { children: [_jsx(Text, { children: " Docs: " }), _jsx(Text, { color: "cyan", children: "https://safeclaw.eu/docs" })] }), _jsxs(Box, { children: [_jsx(Text, { children: " Repo: " }), _jsx(Text, { color: "cyan", children: "https://github.com/tendlyeu/SafeClaw" })] }), _jsx(Box, { marginTop: 1, children: _jsx(Text, { dimColor: true, children: " q to quit" }) })] }));
|
|
5
5
|
}
|
package/dist/tui/App.js
CHANGED
|
@@ -33,5 +33,5 @@ export default function App() {
|
|
|
33
33
|
}
|
|
34
34
|
}
|
|
35
35
|
});
|
|
36
|
-
return (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Box, { borderStyle: "single", borderColor: "green", paddingX: 1, children: [_jsx(Text, { bold: true, color: "green", children: "SafeClaw " }), _jsxs(Text, { dimColor: true, children: ["v", PKG_VERSION] })] }), _jsxs(Box, { paddingX: 1, gap: 2, children: [TABS.map((t, i) => (_jsx(Text, { bold: tab === t, color: tab === t ? 'cyan' : 'white', dimColor: tab !== t, children: `${i + 1}:${t}` }, t))), _jsx(Text, { dimColor: true, children: " tab/1-3
|
|
36
|
+
return (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Box, { borderStyle: "single", borderColor: "green", paddingX: 1, children: [_jsx(Text, { bold: true, color: "green", children: "SafeClaw " }), _jsxs(Text, { dimColor: true, children: ["v", PKG_VERSION] })] }), _jsxs(Box, { paddingX: 1, gap: 2, children: [TABS.map((t, i) => (_jsx(Text, { bold: tab === t, color: tab === t ? 'cyan' : 'white', dimColor: tab !== t, children: `${i + 1}:${t}` }, t))), _jsx(Text, { dimColor: true, children: " tab/1-3 switch q quit" })] }), _jsxs(Box, { marginTop: 1, children: [tab === 'Status' && _jsx(Status, { config: config }), tab === 'Settings' && (_jsx(Settings, { config: config, onConfigChange: setConfig })), tab === 'About' && _jsx(About, {})] })] }));
|
|
37
37
|
}
|
package/dist/tui/Settings.js
CHANGED
|
@@ -9,6 +9,7 @@ const SETTINGS = [
|
|
|
9
9
|
{ key: 'enforcement', label: 'Enforcement', type: 'cycle', values: ENFORCEMENT_MODES },
|
|
10
10
|
{ key: 'failMode', label: 'Fail Mode', type: 'cycle', values: FAIL_MODES },
|
|
11
11
|
{ key: 'serviceUrl', label: 'Service URL', type: 'text' },
|
|
12
|
+
{ key: 'apiKey', label: 'API Key', type: 'text' },
|
|
12
13
|
];
|
|
13
14
|
export default function Settings({ config, onConfigChange }) {
|
|
14
15
|
const [selected, setSelected] = useState(0);
|
|
@@ -22,7 +23,18 @@ export default function Settings({ config, onConfigChange }) {
|
|
|
22
23
|
useInput((input, key) => {
|
|
23
24
|
if (editing) {
|
|
24
25
|
if (key.return) {
|
|
25
|
-
|
|
26
|
+
const editingKey = SETTINGS[selected].key;
|
|
27
|
+
if (editingKey === 'apiKey' && editBuffer && !editBuffer.startsWith('sc_')) {
|
|
28
|
+
// Reject invalid API keys — must start with sc_
|
|
29
|
+
return;
|
|
30
|
+
}
|
|
31
|
+
try {
|
|
32
|
+
updateConfig({ [editingKey]: editBuffer });
|
|
33
|
+
}
|
|
34
|
+
catch {
|
|
35
|
+
// saveConfig may throw on invalid URL — stay in edit mode
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
26
38
|
setEditing(false);
|
|
27
39
|
}
|
|
28
40
|
else if (key.escape) {
|
|
@@ -57,7 +69,7 @@ export default function Settings({ config, onConfigChange }) {
|
|
|
57
69
|
}
|
|
58
70
|
else if (setting.type === 'text' && key.return) {
|
|
59
71
|
setEditing(true);
|
|
60
|
-
setEditBuffer(config.
|
|
72
|
+
setEditBuffer(String(config[setting.key] ?? ''));
|
|
61
73
|
}
|
|
62
74
|
}
|
|
63
75
|
});
|
|
@@ -68,8 +80,13 @@ export default function Settings({ config, onConfigChange }) {
|
|
|
68
80
|
if (setting.key === 'enabled') {
|
|
69
81
|
value = config.enabled ? 'ON' : 'OFF';
|
|
70
82
|
}
|
|
71
|
-
else if (setting.
|
|
72
|
-
value = editBuffer + '█';
|
|
83
|
+
else if (setting.type === 'text' && editing && isSelected) {
|
|
84
|
+
value = (setting.key === 'apiKey' ? '*'.repeat(editBuffer.length) : editBuffer) + '█';
|
|
85
|
+
}
|
|
86
|
+
else if (setting.key === 'apiKey') {
|
|
87
|
+
value = config.apiKey
|
|
88
|
+
? `${config.apiKey.slice(0, 6)}..${config.apiKey.slice(-4)}`
|
|
89
|
+
: '(not set)';
|
|
73
90
|
}
|
|
74
91
|
else {
|
|
75
92
|
value = String(config[setting.key]);
|
|
@@ -79,6 +96,8 @@ export default function Settings({ config, onConfigChange }) {
|
|
|
79
96
|
? config.enabled ? 'green' : 'red'
|
|
80
97
|
: undefined, children: value }), showArrows && _jsx(Text, { dimColor: true, children: ' ▶' })] }, setting.key));
|
|
81
98
|
}), _jsx(Box, { marginTop: 1, children: _jsx(Text, { dimColor: true, children: editing
|
|
82
|
-
?
|
|
83
|
-
|
|
99
|
+
? SETTINGS[selected].key === 'apiKey' && editBuffer && !editBuffer.startsWith('sc_')
|
|
100
|
+
? ' API key must start with sc_ · esc to cancel'
|
|
101
|
+
: ' type to edit · enter to save · esc to cancel'
|
|
102
|
+
: ' ↑↓ navigate · ←→/enter cycle/toggle · enter to edit text fields · q quit' }) }), _jsx(Box, { marginTop: 1, children: _jsx(Text, { dimColor: true, children: ' Enforcement: enforce=block violations, warn-only=log only, audit-only=record only, disabled=off' }) }), _jsx(Box, { children: _jsx(Text, { dimColor: true, children: ' Fail Mode: open=allow if service unreachable, closed=block if service unreachable' }) }), _jsx(Box, { marginTop: 1, children: _jsx(Text, { dimColor: true, children: ' All changes save to ~/.safeclaw/config.json immediately.' }) })] }));
|
|
84
103
|
}
|
package/dist/tui/Status.js
CHANGED
|
@@ -1,13 +1,22 @@
|
|
|
1
|
-
import { jsx as _jsx,
|
|
1
|
+
import { jsxs as _jsxs, jsx as _jsx, Fragment as _Fragment } from "react/jsx-runtime";
|
|
2
2
|
import { useState, useEffect } from 'react';
|
|
3
3
|
import { Text, Box, useInput } from 'ink';
|
|
4
4
|
import { exec } from 'child_process';
|
|
5
|
+
import { existsSync, readFileSync } from 'fs';
|
|
6
|
+
import { join, dirname } from 'path';
|
|
7
|
+
import { homedir } from 'os';
|
|
8
|
+
import { fileURLToPath } from 'url';
|
|
9
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
10
|
+
const PKG_VERSION = JSON.parse(readFileSync(join(__dirname, '..', '..', 'package.json'), 'utf-8')).version;
|
|
5
11
|
export default function Status({ config }) {
|
|
6
12
|
const [health, setHealth] = useState(null);
|
|
7
13
|
const [error, setError] = useState(null);
|
|
8
14
|
const [lastCheck, setLastCheck] = useState(null);
|
|
9
15
|
const [openclawStatus, setOpenclawStatus] = useState('checking');
|
|
10
16
|
const [restartMsg, setRestartMsg] = useState(null);
|
|
17
|
+
const [evaluateStatus, setEvaluateStatus] = useState('checking');
|
|
18
|
+
const [handshakeStatus, setHandshakeStatus] = useState('checking');
|
|
19
|
+
const [handshakeDetail, setHandshakeDetail] = useState('');
|
|
11
20
|
const checkHealth = async () => {
|
|
12
21
|
try {
|
|
13
22
|
const res = await fetch(`${config.serviceUrl}/health`, {
|
|
@@ -17,18 +26,76 @@ export default function Status({ config }) {
|
|
|
17
26
|
const data = await res.json();
|
|
18
27
|
setHealth(data);
|
|
19
28
|
setError(null);
|
|
29
|
+
// If healthy, run deeper checks
|
|
30
|
+
checkEvaluate();
|
|
31
|
+
if (config.apiKey)
|
|
32
|
+
checkHandshake();
|
|
33
|
+
else {
|
|
34
|
+
setHandshakeStatus('fail');
|
|
35
|
+
setHandshakeDetail('No API key');
|
|
36
|
+
}
|
|
20
37
|
}
|
|
21
38
|
else {
|
|
22
39
|
setHealth(null);
|
|
23
40
|
setError(`HTTP ${res.status}`);
|
|
41
|
+
setEvaluateStatus('fail');
|
|
42
|
+
setHandshakeStatus('fail');
|
|
24
43
|
}
|
|
25
44
|
}
|
|
26
45
|
catch {
|
|
27
46
|
setHealth(null);
|
|
28
47
|
setError('Cannot connect');
|
|
48
|
+
setEvaluateStatus('fail');
|
|
49
|
+
setHandshakeStatus('fail');
|
|
29
50
|
}
|
|
30
51
|
setLastCheck(new Date());
|
|
31
52
|
};
|
|
53
|
+
const checkEvaluate = async () => {
|
|
54
|
+
try {
|
|
55
|
+
const res = await fetch(`${config.serviceUrl}/evaluate/tool-call`, {
|
|
56
|
+
method: 'POST',
|
|
57
|
+
headers: {
|
|
58
|
+
'Content-Type': 'application/json',
|
|
59
|
+
...(config.apiKey ? { 'Authorization': `Bearer ${config.apiKey}` } : {}),
|
|
60
|
+
},
|
|
61
|
+
body: JSON.stringify({
|
|
62
|
+
sessionId: 'tui-check', userId: 'tui-check',
|
|
63
|
+
toolName: 'echo', params: { message: 'tui-check' },
|
|
64
|
+
}),
|
|
65
|
+
signal: AbortSignal.timeout(config.timeoutMs),
|
|
66
|
+
});
|
|
67
|
+
setEvaluateStatus(res.ok || res.status === 422 || res.status === 401 || res.status === 403 ? 'ok' : 'fail');
|
|
68
|
+
}
|
|
69
|
+
catch {
|
|
70
|
+
setEvaluateStatus('fail');
|
|
71
|
+
}
|
|
72
|
+
};
|
|
73
|
+
const checkHandshake = async () => {
|
|
74
|
+
try {
|
|
75
|
+
const res = await fetch(`${config.serviceUrl}/handshake`, {
|
|
76
|
+
method: 'POST',
|
|
77
|
+
headers: {
|
|
78
|
+
'Content-Type': 'application/json',
|
|
79
|
+
'Authorization': `Bearer ${config.apiKey}`,
|
|
80
|
+
},
|
|
81
|
+
body: JSON.stringify({ pluginVersion: PKG_VERSION, configHash: '' }),
|
|
82
|
+
signal: AbortSignal.timeout(config.timeoutMs),
|
|
83
|
+
});
|
|
84
|
+
if (res.ok) {
|
|
85
|
+
const data = await res.json();
|
|
86
|
+
setHandshakeStatus('ok');
|
|
87
|
+
setHandshakeDetail(`org=${data.orgId ?? '?'}`);
|
|
88
|
+
}
|
|
89
|
+
else {
|
|
90
|
+
setHandshakeStatus('fail');
|
|
91
|
+
setHandshakeDetail(`HTTP ${res.status}`);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
catch {
|
|
95
|
+
setHandshakeStatus('fail');
|
|
96
|
+
setHandshakeDetail('timeout');
|
|
97
|
+
}
|
|
98
|
+
};
|
|
32
99
|
const checkOpenClaw = () => {
|
|
33
100
|
exec('openclaw daemon status', { timeout: 10000 }, (err, stdout) => {
|
|
34
101
|
if (err) {
|
|
@@ -67,6 +134,7 @@ export default function Status({ config }) {
|
|
|
67
134
|
restartOpenClaw();
|
|
68
135
|
}
|
|
69
136
|
});
|
|
137
|
+
// Derived display values
|
|
70
138
|
const connected = health !== null;
|
|
71
139
|
const dot = '●';
|
|
72
140
|
const serviceDotColor = connected ? 'green' : 'red';
|
|
@@ -77,5 +145,29 @@ export default function Status({ config }) {
|
|
|
77
145
|
const openclawText = openclawStatus === 'checking' ? 'Checking...'
|
|
78
146
|
: openclawStatus === 'running' ? 'Running'
|
|
79
147
|
: 'Not running';
|
|
80
|
-
|
|
148
|
+
// Plugin file checks
|
|
149
|
+
const extensionDir = join(homedir(), '.openclaw', 'extensions', 'safeclaw');
|
|
150
|
+
const pluginInstalled = existsSync(join(extensionDir, 'openclaw.plugin.json'))
|
|
151
|
+
&& existsSync(join(extensionDir, 'index.js'));
|
|
152
|
+
// Plugin enabled in OpenClaw config
|
|
153
|
+
let pluginEnabled = false;
|
|
154
|
+
const ocConfigPath = join(homedir(), '.openclaw', 'openclaw.json');
|
|
155
|
+
if (existsSync(ocConfigPath)) {
|
|
156
|
+
try {
|
|
157
|
+
const ocConfig = JSON.parse(readFileSync(ocConfigPath, 'utf-8'));
|
|
158
|
+
pluginEnabled = !!ocConfig?.plugins?.entries?.safeclaw?.enabled;
|
|
159
|
+
}
|
|
160
|
+
catch { /* ignore */ }
|
|
161
|
+
}
|
|
162
|
+
const apiKeyMasked = config.apiKey
|
|
163
|
+
? `${config.apiKey.slice(0, 6)}..${config.apiKey.slice(-4)}`
|
|
164
|
+
: '(not set)';
|
|
165
|
+
const statusDot = (status) => {
|
|
166
|
+
if (status === 'checking')
|
|
167
|
+
return _jsxs(Text, { color: "yellow", children: [dot, " "] });
|
|
168
|
+
if (status === 'ok' || status === true)
|
|
169
|
+
return _jsxs(Text, { color: "green", children: [dot, " "] });
|
|
170
|
+
return _jsxs(Text, { color: "red", children: [dot, " "] });
|
|
171
|
+
};
|
|
172
|
+
return (_jsxs(Box, { flexDirection: "column", paddingX: 1, children: [_jsx(Box, { marginBottom: 1, children: _jsx(Text, { bold: true, children: "Status" }) }), _jsxs(Box, { children: [_jsx(Text, { dimColor: true, children: ' Service ' }), _jsxs(Text, { color: serviceDotColor, children: [dot, " "] }), _jsx(Text, { children: serviceText })] }), connected && (_jsxs(_Fragment, { children: [_jsxs(Box, { children: [_jsx(Text, { dimColor: true, children: ' Evaluate ' }), statusDot(evaluateStatus), _jsx(Text, { children: evaluateStatus === 'ok' ? 'Responding' : evaluateStatus === 'checking' ? 'Checking...' : 'Not responding' })] }), _jsxs(Box, { children: [_jsx(Text, { dimColor: true, children: ' Handshake ' }), statusDot(handshakeStatus), _jsx(Text, { children: handshakeStatus === 'ok' ? handshakeDetail : handshakeStatus === 'checking' ? 'Checking...' : handshakeDetail })] })] })), _jsxs(Box, { children: [_jsx(Text, { dimColor: true, children: ' OpenClaw ' }), _jsxs(Text, { color: openclawDotColor, children: [dot, " "] }), _jsx(Text, { children: openclawText }), restartMsg && _jsx(Text, { dimColor: true, children: ` (${restartMsg})` })] }), _jsxs(Box, { children: [_jsx(Text, { dimColor: true, children: ' Plugin ' }), statusDot(pluginInstalled), _jsx(Text, { children: pluginInstalled ? 'Installed' : 'Not installed' })] }), _jsxs(Box, { children: [_jsx(Text, { dimColor: true, children: ' Config ' }), statusDot(pluginEnabled), _jsx(Text, { children: pluginEnabled ? 'Enabled' : 'Not enabled' })] }), _jsxs(Box, { children: [_jsx(Text, { dimColor: true, children: ' API Key ' }), statusDot(!!config.apiKey), _jsx(Text, { children: apiKeyMasked })] }), _jsxs(Box, { children: [_jsx(Text, { dimColor: true, children: ' Enforcement ' }), _jsx(Text, { children: config.enforcement })] }), _jsxs(Box, { children: [_jsx(Text, { dimColor: true, children: ' Fail Mode ' }), _jsx(Text, { children: config.failMode })] }), _jsxs(Box, { children: [_jsx(Text, { dimColor: true, children: ' Enabled ' }), _jsx(Text, { color: config.enabled ? 'green' : 'red', children: config.enabled ? 'ON' : 'OFF' })] }), health?.version && (_jsxs(Box, { marginTop: 1, children: [_jsx(Text, { dimColor: true, children: ' Service v' }), _jsx(Text, { children: health.version })] })), lastCheck && (_jsx(Box, { marginTop: 1, children: _jsxs(Text, { dimColor: true, children: [' Last check: ', lastCheck.toLocaleTimeString()] }) })), _jsxs(Box, { marginTop: 1, children: [_jsx(Text, { dimColor: true, children: ' Press ' }), _jsx(Text, { bold: true, children: "r" }), _jsx(Text, { dimColor: true, children: ' to restart OpenClaw daemon' })] })] }));
|
|
81
173
|
}
|
package/dist/tui/config.d.ts
CHANGED
|
@@ -17,6 +17,10 @@ export interface SafeClawConfig {
|
|
|
17
17
|
}
|
|
18
18
|
export declare const CONFIG_PATH: string;
|
|
19
19
|
export declare function loadConfig(): SafeClawConfig;
|
|
20
|
+
/**
|
|
21
|
+
* Check whether a URL is a valid http:// or https:// URL.
|
|
22
|
+
*/
|
|
23
|
+
export declare function isValidServiceUrl(url: string): boolean;
|
|
20
24
|
/**
|
|
21
25
|
* Persist managed config fields back to ~/.safeclaw/config.json.
|
|
22
26
|
* Reads the existing file (if any), merges the fields SafeClaw manages,
|
package/dist/tui/config.js
CHANGED
|
@@ -14,7 +14,7 @@ export const CONFIG_PATH = join(homedir(), '.safeclaw', 'config.json');
|
|
|
14
14
|
// --- Functions ---
|
|
15
15
|
export function loadConfig() {
|
|
16
16
|
const defaults = {
|
|
17
|
-
serviceUrl: '
|
|
17
|
+
serviceUrl: 'http://localhost:8420/api/v1',
|
|
18
18
|
apiKey: '',
|
|
19
19
|
timeoutMs: 5000,
|
|
20
20
|
enabled: true,
|
|
@@ -48,13 +48,23 @@ export function loadConfig() {
|
|
|
48
48
|
// Config file unreadable — use defaults
|
|
49
49
|
}
|
|
50
50
|
}
|
|
51
|
+
else {
|
|
52
|
+
console.warn(`[SafeClaw] No config file found at ${CONFIG_PATH} — using defaults (serviceUrl=${defaults.serviceUrl})`);
|
|
53
|
+
}
|
|
51
54
|
// Env vars override config file
|
|
52
55
|
if (process.env.SAFECLAW_URL)
|
|
53
56
|
defaults.serviceUrl = process.env.SAFECLAW_URL;
|
|
54
57
|
if (process.env.SAFECLAW_API_KEY)
|
|
55
58
|
defaults.apiKey = process.env.SAFECLAW_API_KEY;
|
|
56
|
-
if (process.env.SAFECLAW_TIMEOUT_MS)
|
|
57
|
-
|
|
59
|
+
if (process.env.SAFECLAW_TIMEOUT_MS) {
|
|
60
|
+
const parsed = parseInt(process.env.SAFECLAW_TIMEOUT_MS, 10);
|
|
61
|
+
if (!Number.isNaN(parsed) && parsed > 0) {
|
|
62
|
+
defaults.timeoutMs = parsed;
|
|
63
|
+
}
|
|
64
|
+
else {
|
|
65
|
+
console.warn(`[SafeClaw] Invalid SAFECLAW_TIMEOUT_MS="${process.env.SAFECLAW_TIMEOUT_MS}", using default ${defaults.timeoutMs}ms`);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
58
68
|
if (process.env.SAFECLAW_ENABLED === 'false')
|
|
59
69
|
defaults.enabled = false;
|
|
60
70
|
if (process.env.SAFECLAW_ENFORCEMENT)
|
|
@@ -78,6 +88,20 @@ export function loadConfig() {
|
|
|
78
88
|
}
|
|
79
89
|
return defaults;
|
|
80
90
|
}
|
|
91
|
+
/**
|
|
92
|
+
* Check whether a URL is a valid http:// or https:// URL.
|
|
93
|
+
*/
|
|
94
|
+
export function isValidServiceUrl(url) {
|
|
95
|
+
if (!url || url.trim() === '')
|
|
96
|
+
return false;
|
|
97
|
+
try {
|
|
98
|
+
const parsed = new URL(url);
|
|
99
|
+
return parsed.protocol === 'http:' || parsed.protocol === 'https:';
|
|
100
|
+
}
|
|
101
|
+
catch {
|
|
102
|
+
return false;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
81
105
|
/**
|
|
82
106
|
* Persist managed config fields back to ~/.safeclaw/config.json.
|
|
83
107
|
* Reads the existing file (if any), merges the fields SafeClaw manages,
|
|
@@ -93,17 +117,32 @@ export function saveConfig(config) {
|
|
|
93
117
|
// Unreadable — start fresh
|
|
94
118
|
}
|
|
95
119
|
}
|
|
120
|
+
// Normalize serviceUrl: strip trailing slashes (consistent with loadConfig)
|
|
121
|
+
config.serviceUrl = config.serviceUrl.replace(/\/+$/, '');
|
|
122
|
+
// Validate serviceUrl before saving
|
|
123
|
+
if (!isValidServiceUrl(config.serviceUrl)) {
|
|
124
|
+
throw new Error(`Invalid service URL: "${config.serviceUrl}" — must be a valid http:// or https:// URL`);
|
|
125
|
+
}
|
|
96
126
|
// Merge managed fields into existing structure
|
|
97
127
|
existing.enabled = config.enabled;
|
|
98
128
|
if (!existing.remote || typeof existing.remote !== 'object') {
|
|
99
129
|
existing.remote = {};
|
|
100
130
|
}
|
|
101
131
|
existing.remote.serviceUrl = config.serviceUrl;
|
|
132
|
+
if (config.apiKey) {
|
|
133
|
+
existing.remote.apiKey = config.apiKey;
|
|
134
|
+
}
|
|
102
135
|
if (!existing.enforcement || typeof existing.enforcement !== 'object') {
|
|
103
136
|
existing.enforcement = {};
|
|
104
137
|
}
|
|
105
138
|
existing.enforcement.mode = config.enforcement;
|
|
106
139
|
existing.enforcement.failMode = config.failMode;
|
|
140
|
+
if (config.agentId) {
|
|
141
|
+
existing.agentId = config.agentId;
|
|
142
|
+
}
|
|
143
|
+
if (config.agentToken) {
|
|
144
|
+
existing.agentToken = config.agentToken;
|
|
145
|
+
}
|
|
107
146
|
// Ensure parent directory exists
|
|
108
147
|
mkdirSync(dirname(CONFIG_PATH), { recursive: true, mode: 0o700 });
|
|
109
148
|
writeFileSync(CONFIG_PATH, JSON.stringify(existing, null, 2) + '\n', { encoding: 'utf-8', mode: 0o600 });
|