openclaw-safeclaw-plugin 1.1.0 → 1.2.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/dist/index.js CHANGED
@@ -15,13 +15,21 @@ const { version: PLUGIN_VERSION } = require('./package.json');
15
15
  const CONFIG_RELOAD_INTERVAL_MS = 60_000; // Reload config every 60 seconds
16
16
  let config = loadConfig();
17
17
  let configLoadedAt = Date.now();
18
+ // OpenClaw plugin config — merged on top of file config when available
19
+ let _ocPluginConfig = {};
20
+ // Logger — defaults to console, replaced by api.logger when available
21
+ let log = console;
18
22
  function getConfig() {
19
23
  const now = Date.now();
20
24
  if (now - configLoadedAt >= CONFIG_RELOAD_INTERVAL_MS) {
21
25
  config = loadConfig();
22
26
  configLoadedAt = now;
23
27
  }
24
- return config;
28
+ // OpenClaw config takes priority over file config
29
+ return {
30
+ ...config,
31
+ ...Object.fromEntries(Object.entries(_ocPluginConfig).filter(([_, v]) => v != null)),
32
+ };
25
33
  }
26
34
  // --- HTTP Client ---
27
35
  async function post(path, body) {
@@ -47,10 +55,10 @@ async function post(path, body) {
47
55
  const rawDetail = errBody.detail ?? `HTTP ${res.status}`;
48
56
  const detail = typeof rawDetail === 'string' ? rawDetail : JSON.stringify(rawDetail);
49
57
  const hint = errBody.hint ? ` (${errBody.hint})` : '';
50
- console.warn(`[SafeClaw] ${path}: ${detail}${hint}`);
58
+ log.warn(`[SafeClaw] ${path}: ${detail}${hint}`);
51
59
  }
52
60
  catch {
53
- console.warn(`[SafeClaw] HTTP ${res.status} from ${path}`);
61
+ log.warn(`[SafeClaw] HTTP ${res.status} from ${path}`);
54
62
  }
55
63
  return null; // Caller checks failMode
56
64
  }
@@ -58,22 +66,43 @@ async function post(path, body) {
58
66
  }
59
67
  catch (e) {
60
68
  if (e instanceof DOMException && e.name === 'TimeoutError') {
61
- console.warn(`[SafeClaw] Timeout after ${cfg.timeoutMs}ms on ${path} (${cfg.serviceUrl})`);
69
+ log.warn(`[SafeClaw] Timeout after ${cfg.timeoutMs}ms on ${path} (${cfg.serviceUrl})`);
62
70
  }
63
71
  else if (e instanceof TypeError && (e.message.includes('fetch') || e.message.includes('ECONNREFUSED'))) {
64
- console.warn(`[SafeClaw] Connection refused: ${cfg.serviceUrl}${path} — is the service running?`);
72
+ log.warn(`[SafeClaw] Connection refused: ${cfg.serviceUrl}${path} — is the service running?`);
65
73
  }
66
74
  else {
67
- console.warn(`[SafeClaw] Service unavailable: ${cfg.serviceUrl}${path}`);
75
+ log.warn(`[SafeClaw] Service unavailable: ${cfg.serviceUrl}${path}`);
68
76
  }
69
77
  return null; // Caller checks failMode
70
78
  }
71
79
  }
80
+ async function get(path) {
81
+ const cfg = getConfig();
82
+ if (!cfg.enabled)
83
+ return null;
84
+ const headers = {};
85
+ if (cfg.apiKey)
86
+ headers['Authorization'] = `Bearer ${cfg.apiKey}`;
87
+ try {
88
+ const res = await fetch(`${cfg.serviceUrl}${path}`, {
89
+ signal: AbortSignal.timeout(cfg.timeoutMs),
90
+ headers,
91
+ });
92
+ if (!res.ok)
93
+ return null;
94
+ return await res.json();
95
+ }
96
+ catch {
97
+ return null;
98
+ }
99
+ }
100
+ // --- Plugin Definition ---
72
101
  let handshakeCompleted = false;
73
102
  async function performHandshake() {
74
103
  const cfg = getConfig();
75
104
  if (!cfg.apiKey) {
76
- console.warn('[SafeClaw] No API key configured — skipping handshake');
105
+ log.warn('[SafeClaw] No API key configured — skipping handshake');
77
106
  return false;
78
107
  }
79
108
  const r = await post('/handshake', {
@@ -81,37 +110,37 @@ async function performHandshake() {
81
110
  configHash: configHash(cfg),
82
111
  });
83
112
  if (r === null) {
84
- console.warn('[SafeClaw] Handshake failed — API key may be invalid or service unreachable');
113
+ log.warn('[SafeClaw] Handshake failed — API key may be invalid or service unreachable');
85
114
  return false;
86
115
  }
87
- console.log(`[SafeClaw] Handshake OK — org=${r.orgId}, scope=${r.scope}, engine=${r.engineReady ? 'ready' : 'not ready'}`);
116
+ log.info(`[SafeClaw] Handshake OK — org=${r.orgId}, scope=${r.scope}, engine=${r.engineReady ? 'ready' : 'not ready'}`);
88
117
  handshakeCompleted = true;
89
118
  return true;
90
119
  }
91
120
  async function checkConnection() {
92
121
  const cfg = getConfig();
93
122
  const label = `[SafeClaw]`;
94
- console.log(`${label} Connecting to ${cfg.serviceUrl} ...`);
95
- console.log(`${label} Mode: enforcement=${cfg.enforcement}, failMode=${cfg.failMode}`);
123
+ log.info(`${label} Connecting to ${cfg.serviceUrl} ...`);
124
+ log.info(`${label} Mode: enforcement=${cfg.enforcement}, failMode=${cfg.failMode}`);
96
125
  try {
97
126
  const res = await fetch(`${cfg.serviceUrl}/health`, {
98
127
  signal: AbortSignal.timeout(cfg.timeoutMs * 2),
99
128
  });
100
129
  if (res.ok) {
101
130
  const data = await res.json();
102
- console.log(`${label} Connected — service ${data.status ?? 'ok'}`);
131
+ log.info(`${label} Connected — service ${data.status ?? 'ok'}`);
103
132
  }
104
133
  else {
105
- console.warn(`${label} Service responded with HTTP ${res.status}`);
134
+ log.warn(`${label} Service responded with HTTP ${res.status}`);
106
135
  }
107
136
  }
108
137
  catch {
109
- console.warn(`${label} Cannot reach service at ${cfg.serviceUrl}`);
138
+ log.warn(`${label} Cannot reach service at ${cfg.serviceUrl}`);
110
139
  if (cfg.failMode === 'closed') {
111
- console.warn(`${label} fail-mode=closed tool calls will be BLOCKED until service is reachable`);
140
+ log.warn(`${label} fail-mode=closed tool calls will be BLOCKED until service is reachable`);
112
141
  }
113
142
  else {
114
- console.warn(`${label} fail-mode=open tool calls will be ALLOWED despite no connection`);
143
+ log.warn(`${label} fail-mode=open tool calls will be ALLOWED despite no connection`);
115
144
  }
116
145
  }
117
146
  }
@@ -124,6 +153,8 @@ export default {
124
153
  console.log('[SafeClaw] Plugin disabled');
125
154
  return;
126
155
  }
156
+ log = api.logger ?? console;
157
+ _ocPluginConfig = api.pluginConfig ?? {};
127
158
  // Generate a unique instance ID for this plugin run (fallback when agentId is not configured)
128
159
  const instanceId = getConfig().agentId || `instance-${crypto.randomUUID()}`;
129
160
  // Heartbeat watchdog — send config hash to service every 30s
@@ -139,20 +170,8 @@ export default {
139
170
  // Heartbeat failure is non-fatal
140
171
  }
141
172
  };
142
- // Start heartbeat only after connection check + handshake completes (#84)
173
+ // Clean shutdown: send shutdown heartbeat and clear interval (#194)
143
174
  let heartbeatInterval;
144
- checkConnection()
145
- .then(() => performHandshake())
146
- .then((ok) => {
147
- if (!ok && getConfig().failMode === 'closed') {
148
- console.warn('[SafeClaw] Handshake failed with fail-mode=closed — tool calls will be BLOCKED');
149
- }
150
- heartbeatInterval = setInterval(sendHeartbeat, 30000);
151
- return sendHeartbeat();
152
- })
153
- .catch(() => { });
154
- // Clean shutdown: send shutdown heartbeat and clear interval
155
- // Use async shutdown for SIGINT/SIGTERM where async is supported (#55)
156
175
  const shutdown = async () => {
157
176
  if (heartbeatInterval)
158
177
  clearInterval(heartbeatInterval);
@@ -167,29 +186,56 @@ export default {
167
186
  // Best-effort shutdown notification
168
187
  }
169
188
  };
170
- process.on('SIGINT', async () => { await shutdown(); process.exit(0); });
171
- process.on('SIGTERM', async () => { await shutdown(); process.exit(0); });
172
- // THE GATE — constraint checking on every tool call
189
+ // Start heartbeat after connection check + handshake completes (#84)
190
+ const startupPromise = checkConnection()
191
+ .then(() => performHandshake())
192
+ .then((ok) => {
193
+ if (!ok && getConfig().failMode === 'closed') {
194
+ log.warn('[SafeClaw] Handshake failed with fail-mode=closed — tool calls will be BLOCKED');
195
+ }
196
+ heartbeatInterval = setInterval(sendHeartbeat, 30000);
197
+ return sendHeartbeat();
198
+ })
199
+ .catch(() => { });
200
+ // Register as an OpenClaw service so the gateway manages our lifecycle.
201
+ // The service's stop() method sends the shutdown heartbeat — no process.exit()
202
+ // needed, which avoids killing the entire gateway process (#194).
203
+ if (api.registerService) {
204
+ api.registerService({
205
+ id: 'safeclaw-governance',
206
+ start() { },
207
+ async stop() {
208
+ await startupPromise; // Ensure startup finished before tearing down
209
+ await shutdown();
210
+ },
211
+ });
212
+ }
213
+ else {
214
+ // Fallback for older OpenClaw versions without registerService:
215
+ // No process.exit() — just clean up on beforeExit
216
+ process.on('beforeExit', () => { shutdown().catch(() => { }); });
217
+ }
218
+ // THE GATE — constraint checking on every tool call (#195: use correct OpenClaw field names)
173
219
  api.on('before_tool_call', async (event, ctx) => {
174
220
  const cfg = getConfig();
175
221
  if (!handshakeCompleted && cfg.failMode === 'closed' && cfg.enforcement === 'enforce') {
176
222
  return { block: true, blockReason: 'SafeClaw handshake not completed (fail-closed)' };
177
223
  }
178
224
  const r = await post('/evaluate/tool-call', {
179
- sessionId: ctx.sessionId ?? event.sessionId,
180
- userId: ctx.userId ?? event.userId,
181
- toolName: event.toolName ?? event.tool_name,
225
+ sessionId: ctx.sessionId ?? event.sessionId ?? '',
226
+ userId: ctx.agentId ?? '',
227
+ toolName: event.toolName ?? '',
182
228
  params: event.params ?? {},
183
- sessionHistory: event.sessionHistory ?? [],
229
+ runId: ctx.runId ?? '',
184
230
  });
185
231
  if (r === null && cfg.failMode === 'closed' && cfg.enforcement === 'enforce') {
186
232
  return { block: true, blockReason: `SafeClaw service unavailable at ${cfg.serviceUrl} (fail-closed)` };
187
233
  }
188
234
  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)`);
235
+ log.warn(`[SafeClaw] Service unavailable at ${cfg.serviceUrl} (fail-closed mode, warn-only)`);
190
236
  }
191
237
  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)`);
238
+ log.warn(`[SafeClaw] Service unavailable at ${cfg.serviceUrl} (fail-closed mode, audit-only)`);
193
239
  }
194
240
  if (r?.block) {
195
241
  const blockReason = r.reason || 'Blocked by SafeClaw (no reason provided)';
@@ -197,71 +243,195 @@ export default {
197
243
  return { block: true, blockReason };
198
244
  }
199
245
  if (cfg.enforcement === 'warn-only') {
200
- console.warn(`[SafeClaw] Warning: ${blockReason}`);
246
+ log.warn(`[SafeClaw] Warning: ${blockReason}`);
201
247
  }
202
248
  // audit-only: logged server-side, no action here
203
249
  }
204
250
  }, { priority: 100 });
205
251
  // Context injection — prepend governance context to agent system prompt
206
- api.on('before_agent_start', async (event, ctx) => {
252
+ // (#195: before_agent_start is deprecated; use before_prompt_build + prependSystemContext)
253
+ api.on('before_prompt_build', async (event, ctx) => {
207
254
  const r = await post('/context/build', {
208
- sessionId: ctx.sessionId ?? event.sessionId,
209
- userId: ctx.userId ?? event.userId,
255
+ sessionId: ctx.sessionId ?? event.sessionId ?? '',
256
+ userId: ctx.agentId ?? '',
210
257
  });
211
258
  if (r?.prependContext) {
212
- return { prependContext: r.prependContext };
259
+ return { prependSystemContext: r.prependContext };
213
260
  }
214
261
  }, { priority: 100 });
215
262
  // Message governance — check outbound messages
263
+ // (#195: use ctx.conversationId/sessionId, ctx.accountId; return only { cancel: true })
216
264
  api.on('message_sending', async (event, ctx) => {
217
265
  const cfg = getConfig();
218
266
  const r = await post('/evaluate/message', {
219
- sessionId: ctx.sessionId ?? event.sessionId,
220
- userId: ctx.userId ?? event.userId,
267
+ sessionId: ctx.conversationId ?? ctx.sessionId ?? event.sessionId ?? '',
268
+ userId: ctx.accountId ?? '',
221
269
  to: event.to,
222
270
  content: event.content,
271
+ channelId: ctx.channelId ?? '',
223
272
  });
224
273
  if (r === null && cfg.failMode === 'closed' && cfg.enforcement === 'enforce') {
225
- return { cancel: true, cancelReason: 'SafeClaw service unavailable (fail-closed mode)' };
274
+ log.warn('[SafeClaw] Blocking message: service unavailable (fail-closed mode)');
275
+ return { cancel: true };
226
276
  }
227
277
  else if (r === null && cfg.failMode === 'closed' && cfg.enforcement === 'warn-only') {
228
- console.warn('[SafeClaw] Service unavailable (fail-closed mode, warn-only)');
278
+ log.warn('[SafeClaw] Service unavailable (fail-closed mode, warn-only)');
229
279
  }
230
280
  else if (r === null && cfg.failMode === 'closed' && cfg.enforcement === 'audit-only') {
231
- console.info('[SafeClaw] audit-only: service unreachable, allowing message (fail-closed)');
281
+ log.info('[SafeClaw] audit-only: service unreachable, allowing message (fail-closed)');
232
282
  }
233
283
  if (r?.block) {
234
284
  const blockReason = r.reason || 'Blocked by SafeClaw (no reason provided)';
235
285
  if (cfg.enforcement === 'enforce') {
236
- return { cancel: true, cancelReason: blockReason };
286
+ log.warn(`[SafeClaw] Blocking message: ${blockReason}`);
287
+ return { cancel: true };
237
288
  }
238
289
  if (cfg.enforcement === 'warn-only') {
239
- console.warn(`[SafeClaw] Warning: ${blockReason}`);
290
+ log.warn(`[SafeClaw] Warning: ${blockReason}`);
240
291
  }
241
292
  // audit-only: logged server-side, no action here
242
293
  }
243
294
  }, { priority: 100 });
244
295
  // Async logging — fire-and-forget, no return value needed
296
+ // (#195: use event.prompt for input, event.lastAssistant for output; add provider/model)
245
297
  api.on('llm_input', (event, ctx) => {
246
298
  post('/log/llm-input', {
247
- sessionId: ctx.sessionId ?? event.sessionId,
248
- content: event.content,
299
+ sessionId: event.sessionId ?? ctx.sessionId ?? '',
300
+ content: event.prompt ?? '',
301
+ provider: event.provider ?? '',
302
+ model: event.model ?? '',
249
303
  }).catch(() => { });
250
304
  });
251
305
  api.on('llm_output', (event, ctx) => {
252
306
  post('/log/llm-output', {
253
- sessionId: ctx.sessionId ?? event.sessionId,
254
- content: event.content,
307
+ sessionId: event.sessionId ?? ctx.sessionId ?? '',
308
+ content: event.lastAssistant ?? '',
309
+ provider: event.provider ?? '',
310
+ model: event.model ?? '',
311
+ usage: event.usage ?? {},
255
312
  }).catch(() => { });
256
313
  });
314
+ // (#195: use event.toolName, !event.error for success, add durationMs and error)
257
315
  api.on('after_tool_call', (event, ctx) => {
258
316
  post('/record/tool-result', {
259
- sessionId: ctx.sessionId ?? event.sessionId,
260
- toolName: event.toolName ?? event.tool_name,
317
+ sessionId: ctx.sessionId ?? event.sessionId ?? '',
318
+ toolName: event.toolName ?? '',
261
319
  params: event.params ?? {},
262
320
  result: event.result ?? '',
263
- success: event.success ?? false,
264
- }).catch((e) => console.warn('[SafeClaw] Failed to record tool result:', e));
321
+ success: !event.error,
322
+ error: event.error ? String(event.error) : '',
323
+ durationMs: event.durationMs ?? 0,
324
+ }).catch((e) => log.warn('[SafeClaw] Failed to record tool result:', e));
265
325
  });
326
+ // Subagent governance — block delegation bypass attempts (#188)
327
+ api.on('subagent_spawning', async (event, ctx) => {
328
+ const cfg = getConfig();
329
+ const r = await post('/evaluate/subagent-spawn', {
330
+ sessionId: ctx.sessionId ?? event.sessionId,
331
+ userId: ctx.agentId,
332
+ parentAgentId: event.parentAgentId,
333
+ childConfig: event.childConfig ?? {},
334
+ reason: event.reason ?? '',
335
+ });
336
+ if (r?.block && cfg.enforcement === 'enforce') {
337
+ throw new Error(r.reason || 'Blocked by SafeClaw: delegation bypass detected');
338
+ }
339
+ if (r?.block && cfg.enforcement === 'warn-only') {
340
+ log.warn(`[SafeClaw] Subagent spawn warning: ${r.reason}`);
341
+ }
342
+ }, { priority: 100 });
343
+ // Subagent ended — record child agent lifecycle (#188)
344
+ api.on('subagent_ended', (event, ctx) => {
345
+ post('/record/subagent-ended', {
346
+ sessionId: ctx.sessionId ?? event.sessionId,
347
+ parentAgentId: event.parentAgentId,
348
+ childAgentId: event.childAgentId,
349
+ }).catch(() => { });
350
+ });
351
+ // Session lifecycle — notify service of session start (#189)
352
+ api.on('session_start', (event, ctx) => {
353
+ post('/session/start', {
354
+ sessionId: ctx.sessionId ?? event.sessionId,
355
+ userId: ctx.agentId,
356
+ agentId: instanceId,
357
+ metadata: event.metadata ?? {},
358
+ }).catch(() => { });
359
+ });
360
+ // Session lifecycle — notify service of session end (#189)
361
+ api.on('session_end', (event, ctx) => {
362
+ post('/session/end', {
363
+ sessionId: ctx.sessionId ?? event.sessionId,
364
+ userId: ctx.agentId,
365
+ agentId: instanceId,
366
+ }).catch(() => { });
367
+ });
368
+ // Inbound message governance — evaluate received messages (#190)
369
+ api.on('message_received', (event, ctx) => {
370
+ post('/evaluate/inbound-message', {
371
+ sessionId: ctx.sessionId ?? event.sessionId,
372
+ userId: ctx.agentId,
373
+ channel: event.channel ?? ctx.channelId ?? '',
374
+ sender: event.sender ?? '',
375
+ content: event.content ?? '',
376
+ metadata: event.metadata ?? {},
377
+ }).catch(() => { });
378
+ });
379
+ // Agent tools — let agents introspect governance state (#197)
380
+ if (api.registerTool) {
381
+ api.registerTool({
382
+ name: 'safeclaw_status',
383
+ description: 'Check SafeClaw governance service status, enforcement mode, and active constraints',
384
+ parameters: {},
385
+ async execute(_params, _ctx) {
386
+ const health = await get('/health');
387
+ const cfg = getConfig();
388
+ return {
389
+ status: health?.status ?? 'unreachable',
390
+ enforcement: cfg.enforcement,
391
+ failMode: cfg.failMode,
392
+ serviceUrl: cfg.serviceUrl,
393
+ handshakeCompleted,
394
+ };
395
+ },
396
+ });
397
+ api.registerTool({
398
+ name: 'safeclaw_check_action',
399
+ description: 'Check if a specific tool call would be allowed by SafeClaw governance (dry run, no side effects)',
400
+ parameters: {
401
+ type: 'object',
402
+ properties: {
403
+ toolName: { type: 'string', description: 'Tool name to check' },
404
+ params: { type: 'object', description: 'Tool parameters to validate' },
405
+ },
406
+ required: ['toolName'],
407
+ },
408
+ async execute(params, ctx) {
409
+ const r = await post('/evaluate/tool-call', {
410
+ sessionId: ctx.sessionId ?? '',
411
+ userId: ctx.agentId ?? '',
412
+ toolName: params.toolName,
413
+ params: params.params ?? {},
414
+ dryRun: true,
415
+ });
416
+ return r ?? { error: 'Service unreachable' };
417
+ },
418
+ });
419
+ }
420
+ // CLI extension — `safeclaw status` command (#197)
421
+ if (api.registerCli) {
422
+ api.registerCli(({ program }) => {
423
+ const cmd = program.command('safeclaw').description('SafeClaw governance controls');
424
+ cmd.command('status')
425
+ .description('Show SafeClaw service status and enforcement mode')
426
+ .action(async () => {
427
+ const cfg = getConfig();
428
+ const health = await get('/health');
429
+ console.log(`SafeClaw: ${health?.status ?? 'unreachable'}`);
430
+ console.log(` Enforcement: ${cfg.enforcement}`);
431
+ console.log(` Fail mode: ${cfg.failMode}`);
432
+ console.log(` Service: ${cfg.serviceUrl}`);
433
+ });
434
+ }, { commands: ['safeclaw'] });
435
+ }
266
436
  },
267
437
  };
@@ -6,6 +6,7 @@ import { existsSync, readFileSync } from 'fs';
6
6
  import { join, dirname } from 'path';
7
7
  import { homedir } from 'os';
8
8
  import { fileURLToPath } from 'url';
9
+ import { isNemoClawSandbox, getSandboxName } from './config.js';
9
10
  const __dirname = dirname(fileURLToPath(import.meta.url));
10
11
  const PKG_VERSION = JSON.parse(readFileSync(join(__dirname, '..', '..', 'package.json'), 'utf-8')).version;
11
12
  export default function Status({ config }) {
@@ -169,5 +170,5 @@ export default function Status({ config }) {
169
170
  return _jsxs(Text, { color: "green", children: [dot, " "] });
170
171
  return _jsxs(Text, { color: "red", children: [dot, " "] });
171
172
  };
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' })] })] }));
173
+ 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' })] }), isNemoClawSandbox() && (_jsxs(Box, { children: [_jsx(Text, { dimColor: true, children: ' NemoClaw ' }), _jsxs(Text, { color: "green", children: [dot, " "] }), _jsxs(Text, { children: ["Sandbox: ", getSandboxName()] })] })), 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' })] })] }));
173
174
  }
@@ -32,3 +32,5 @@ export declare function saveConfig(config: SafeClawConfig): void;
32
32
  * Used to detect whether the on-disk config has drifted from the in-memory state.
33
33
  */
34
34
  export declare function configHash(config: SafeClawConfig): string;
35
+ export declare function isNemoClawSandbox(): boolean;
36
+ export declare function getSandboxName(): string | null;
@@ -75,6 +75,13 @@ export function loadConfig() {
75
75
  defaults.agentId = process.env.SAFECLAW_AGENT_ID;
76
76
  if (process.env.SAFECLAW_AGENT_TOKEN)
77
77
  defaults.agentToken = process.env.SAFECLAW_AGENT_TOKEN;
78
+ // NemoClaw sandbox detection
79
+ if (process.env.OPENSHELL_SANDBOX) {
80
+ // Inside NemoClaw sandbox — localhost won't work, use container-to-host bridge
81
+ if (!process.env.SAFECLAW_URL && defaults.serviceUrl === 'http://localhost:8420/api/v1') {
82
+ defaults.serviceUrl = 'http://host.containers.internal:8420/api/v1';
83
+ }
84
+ }
78
85
  defaults.serviceUrl = defaults.serviceUrl.replace(/\/+$/, '');
79
86
  const validModes = ['enforce', 'warn-only', 'audit-only', 'disabled'];
80
87
  if (!validModes.includes(defaults.enforcement)) {
@@ -145,7 +152,16 @@ export function saveConfig(config) {
145
152
  }
146
153
  // Ensure parent directory exists
147
154
  mkdirSync(dirname(CONFIG_PATH), { recursive: true, mode: 0o700 });
148
- writeFileSync(CONFIG_PATH, JSON.stringify(existing, null, 2) + '\n', { encoding: 'utf-8', mode: 0o600 });
155
+ try {
156
+ writeFileSync(CONFIG_PATH, JSON.stringify(existing, null, 2) + '\n', { encoding: 'utf-8', mode: 0o600 });
157
+ }
158
+ catch (e) {
159
+ if (e.code === 'EROFS' || e.code === 'EACCES') {
160
+ // Sandbox filesystem is read-only — silently skip
161
+ return;
162
+ }
163
+ throw e;
164
+ }
149
165
  }
150
166
  /**
151
167
  * SHA-256 hash of the four TUI-managed config fields.
@@ -160,3 +176,10 @@ export function configHash(config) {
160
176
  });
161
177
  return crypto.createHash('sha256').update(payload).digest('hex');
162
178
  }
179
+ // --- NemoClaw sandbox helpers ---
180
+ export function isNemoClawSandbox() {
181
+ return !!process.env.OPENSHELL_SANDBOX;
182
+ }
183
+ export function getSandboxName() {
184
+ return process.env.OPENSHELL_SANDBOX || null;
185
+ }