ironclaw 0.24.0

Secure personal AI assistant that protects your data and expands its capabilities on the fly
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
//! Restart tool for graceful process restart.
//!
//! ## Architecture
//!
//! IronClaw runs inside a Docker container with an entrypoint loop that monitors exit codes:
//! - **Exit code 0** (clean): Reset failure counter, wait `IRONCLAW_RESTART_DELAY` (default 5s), restart
//! - **Exit code ≠ 0** (failure): Increment failure counter, exit after `IRONCLAW_MAX_FAILURES` (default 10)
//!
//! This tool triggers a restart by calling `std::process::exit(0)` after a brief delay, allowing
//! the HTTP response to be flushed before the process terminates. The entrypoint loop then
//! detects the clean exit and automatically restarts the process.
//!
//! ## Security
//!
//! - **Approval Model:** User approval happens at the command level via web modal confirmation,
//!   not at tool execution level. This allows approved commands to execute in autonomous jobs.
//! - **Web-Only Access:** The `/restart` command only works via the web gateway (enforced in commands.rs)
//! - **Parameter Validation:** Delay clamped to 1-30 seconds
//!
//! ## Known Limitations
//!
//! - Hard exit without graceful shutdown (no destructor cleanup, no RwLock drains)
//! - In-flight jobs are paused during restart and resumed by the entrypoint
//! - Future: Implement graceful shutdown with CancellationToken for proper resource cleanup

use async_trait::async_trait;
use std::time::Duration;

use crate::context::JobContext;
#[allow(unused_imports)]
use crate::tools::tool::{ApprovalRequirement, Tool, ToolError, ToolOutput};

/// Tool for triggering a graceful process restart via exit code 0.
///
/// This tool signals the Docker entrypoint loop to restart the process by exiting cleanly
/// (exit code 0). User approval happens at the command level (via the web modal confirmation),
/// not at tool execution level. The `/restart` command is only callable via the web gateway
/// interface to prevent unauthorized restarts.
pub struct RestartTool;

#[async_trait]
impl Tool for RestartTool {
    fn name(&self) -> &str {
        "restart"
    }

    fn description(&self) -> &str {
        "Restart the IronClaw agent process. The process exits cleanly (code 0) and the \
         container entrypoint loop restarts it automatically within a few seconds."
    }

    fn parameters_schema(&self) -> serde_json::Value {
        serde_json::json!({
            "type": "object",
            "properties": {
                "delay_secs": {
                    "type": "integer",
                    "description": "Seconds to wait before exiting (default: 2, min: 1, max: 30)",
                    "minimum": 1,
                    "maximum": 30
                }
            }
        })
    }

    async fn execute(
        &self,
        params: serde_json::Value,
        _ctx: &JobContext,
    ) -> Result<ToolOutput, ToolError> {
        tracing::info!("[RestartTool::execute] Restart tool invoked");
        let start = std::time::Instant::now();

        // Check if running inside a Docker container via IRONCLAW_IN_DOCKER env var.
        // The Docker entrypoint sets this to "true". For local development, it's unset or "false".
        // The entrypoint restart loop only works inside a Docker container (ironclaw-worker).
        let in_docker = std::env::var("IRONCLAW_IN_DOCKER")
            .map(|v| v.to_lowercase() == "true")
            .unwrap_or(false);

        tracing::debug!("[RestartTool::execute] IRONCLAW_IN_DOCKER={}", in_docker);

        if !in_docker {
            tracing::error!("[RestartTool::execute] Not in Docker, rejecting restart");
            return Err(ToolError::ExecutionFailed(
                "Restart is only available when running inside the Docker container. \
                 For local development, please restart IronClaw manually."
                    .to_string(),
            ));
        }

        // Extract delay_secs parameter, defaulting to 2 seconds
        let delay = params
            .get("delay_secs")
            .and_then(|v| v.as_u64())
            .unwrap_or(2)
            // Validate delay against schema bounds (1-30 seconds)
            .clamp(1, 30);
        tracing::info!("[RestartTool::execute] Delay set to {} seconds", delay);

        // Spawn a background task so the response is flushed before exit.
        // We use std::process::exit(0) to trigger a Docker container restart:
        //
        // - The ironclaw-worker Docker container runs an entrypoint loop that monitors
        //   the exit code of the `ironclaw run` process:
        //   * Exit code 0 = clean restart: reset failure counter, wait IRONCLAW_RESTART_DELAY
        //     (default 5s), then restart the process
        //   * Exit code ≠ 0 = failure: increment counter, exit after IRONCLAW_MAX_FAILURES
        //     (default 10 failures)
        //
        // - std::process::exit(0) is a hard exit (no destructors, no graceful shutdown).
        //   This is intentional because:
        //   1. The HTTP response must be sent before exit (hence tokio::spawn + delay)
        //   2. In-flight jobs are paused/resumed by the entrypoint loop
        //   3. Database connections are pooled and reopened on restart
        //   4. The brief delay allows the response to flush before termination
        //
        // - Future improvement: implement graceful shutdown with CancellationToken
        //   to properly drain Axum, close DB connections, and checkpoint jobs.
        // Check if restart is disabled (e.g., in tests). This allows tests to verify
        // parameter parsing and output without actually terminating the process.
        let restart_disabled = std::env::var("IRONCLAW_DISABLE_RESTART")
            .map(|v| {
                let v = v.to_lowercase();
                v == "1" || v == "true"
            })
            .unwrap_or(false);

        tracing::info!(
            "[RestartTool::execute] Spawning background task to exit in {} seconds (disabled={})",
            delay,
            restart_disabled
        );
        tokio::spawn(async move {
            tracing::info!("[RestartTool] Sleeping for {} seconds before exit", delay);
            tokio::time::sleep(Duration::from_secs(delay)).await;
            if !restart_disabled {
                tracing::warn!("[RestartTool] Calling std::process::exit(0) NOW");
                std::process::exit(0);
            } else {
                tracing::info!(
                    "[RestartTool] Exit disabled (IRONCLAW_DISABLE_RESTART set), skipping std::process::exit(0)"
                );
            }
        });

        let msg = format!(
            "Restarting in {delay} second(s). The process will exit cleanly and the \
             entrypoint restart loop will bring IronClaw back online."
        );
        tracing::info!("[RestartTool::execute] Returning success response: {}", msg);
        Ok(ToolOutput::text(msg, start.elapsed()))
    }

    fn requires_sanitization(&self) -> bool {
        false
    }

    // NOTE: Approval is handled at the command level (/restart via web modal confirmation),
    // not at the tool execution level. By the time the tool executes, the user has already
    // confirmed via the web interface. So we don't require approval here.
    // This allows the tool to execute in autonomous jobs created from approved commands.
}

#[cfg(test)]
mod tests {
    use super::*;

    /// Helper to simulate Docker environment for testing
    fn enable_docker_env() {
        unsafe {
            std::env::set_var("IRONCLAW_IN_DOCKER", "true");
        }
    }

    #[test]
    fn test_restart_tool_approval_handled_at_command_level() {
        // Approval is handled at the /restart command level (web modal confirmation),
        // not at tool execution. Tool execution approval is for user-interactive approvals
        // that happen during job execution. The restart confirmation modal provides that gate.
        let tool = RestartTool;
        let approval = tool.requires_approval(&serde_json::json!({}));
        // Default (Never) allows tool to execute in autonomous jobs created from approved commands
        assert!(matches!(approval, ApprovalRequirement::Never));
    }

    #[test]
    fn test_restart_tool_name() {
        let tool = RestartTool;
        assert_eq!(tool.name(), "restart");
    }

    #[test]
    fn test_restart_tool_parameters_schema() {
        let tool = RestartTool;
        let schema = tool.parameters_schema();

        // Verify schema has delay_secs property with bounds
        let props = schema.get("properties").unwrap();
        assert!(props.get("delay_secs").is_some());

        let delay_schema = props.get("delay_secs").unwrap();
        assert_eq!(delay_schema.get("minimum").unwrap().as_u64().unwrap(), 1);
        assert_eq!(delay_schema.get("maximum").unwrap().as_u64().unwrap(), 30);
    }

    #[test]
    fn test_restart_tool_requires_sanitization() {
        let tool = RestartTool;
        assert!(!tool.requires_sanitization());
    }

    #[tokio::test]
    async fn test_restart_tool_delay_parameter_validation() {
        enable_docker_env();
        let tool = RestartTool;
        let ctx = crate::context::JobContext::new("test", "test restart");

        // Test with valid delay
        let result = tool
            .execute(serde_json::json!({"delay_secs": 5}), &ctx)
            .await;
        assert!(result.is_ok());
        let output = result.unwrap();
        let text = output.result.as_str().expect("result should be a string");
        assert!(text.contains("Restarting in 5 second(s)"));

        // Test with no delay parameter (should use default 2)
        let result = tool.execute(serde_json::json!({}), &ctx).await;
        assert!(result.is_ok());
        let output = result.unwrap();
        let text = output.result.as_str().expect("result should be a string");
        assert!(text.contains("Restarting in 2 second(s)"));
    }

    #[tokio::test]
    async fn test_restart_tool_delay_clamping() {
        enable_docker_env();
        let tool = RestartTool;
        let ctx = crate::context::JobContext::new("test", "test restart");

        // Test with too small delay (should clamp to 1)
        let result = tool
            .execute(serde_json::json!({"delay_secs": 0}), &ctx)
            .await;
        assert!(result.is_ok());
        let output = result.unwrap();
        let text = output.result.as_str().expect("result should be a string");
        assert!(text.contains("Restarting in 1 second(s)"));

        // Test with too large delay (should clamp to 30)
        let result = tool
            .execute(serde_json::json!({"delay_secs": 100}), &ctx)
            .await;
        assert!(result.is_ok());
        let output = result.unwrap();
        let text = output.result.as_str().expect("result should be a string");
        assert!(text.contains("Restarting in 30 second(s)"));
    }

    #[test]
    fn test_restart_tool_description() {
        let tool = RestartTool;
        let desc = tool.description();
        assert!(desc.contains("Restart"));
        assert!(desc.contains("IronClaw"));
        assert!(desc.contains("exits cleanly"));
        assert!(desc.contains("code 0"));
    }

    #[test]
    fn test_restart_tool_schema_completeness() {
        let tool = RestartTool;
        let schema = tool.parameters_schema();

        // Verify schema structure
        assert_eq!(schema.get("type").unwrap().as_str().unwrap(), "object");

        let props = schema.get("properties").unwrap();
        assert!(props.is_object());

        let delay_schema = props.get("delay_secs").unwrap();
        assert_eq!(
            delay_schema.get("type").unwrap().as_str().unwrap(),
            "integer"
        );
        assert!(delay_schema.get("description").is_some());
    }

    #[tokio::test]
    async fn test_restart_tool_boundary_values() {
        enable_docker_env();
        let tool = RestartTool;
        let ctx = crate::context::JobContext::new("test", "test restart");

        // Test minimum boundary (exactly 1)
        let result = tool
            .execute(serde_json::json!({"delay_secs": 1}), &ctx)
            .await;
        assert!(result.is_ok());
        let output = result.unwrap();
        let text = output.result.as_str().unwrap();
        assert!(text.contains("Restarting in 1 second(s)"));

        // Test maximum boundary (exactly 30)
        let result = tool
            .execute(serde_json::json!({"delay_secs": 30}), &ctx)
            .await;
        assert!(result.is_ok());
        let output = result.unwrap();
        let text = output.result.as_str().unwrap();
        assert!(text.contains("Restarting in 30 second(s)"));

        // Test middle value
        let result = tool
            .execute(serde_json::json!({"delay_secs": 15}), &ctx)
            .await;
        assert!(result.is_ok());
        let output = result.unwrap();
        let text = output.result.as_str().unwrap();
        assert!(text.contains("Restarting in 15 second(s)"));
    }

    #[tokio::test]
    async fn test_restart_tool_invalid_parameter_types() {
        enable_docker_env();
        let tool = RestartTool;
        let ctx = crate::context::JobContext::new("test", "test restart");

        // String instead of integer - should use default
        let result = tool
            .execute(serde_json::json!({"delay_secs": "5"}), &ctx)
            .await;
        assert!(result.is_ok());
        let output = result.unwrap();
        let text = output.result.as_str().unwrap();
        assert!(text.contains("Restarting in 2 second(s)")); // Falls back to default

        // Null value - should use default
        let result = tool
            .execute(serde_json::json!({"delay_secs": null}), &ctx)
            .await;
        assert!(result.is_ok());
        let output = result.unwrap();
        let text = output.result.as_str().unwrap();
        assert!(text.contains("Restarting in 2 second(s)"));

        // Float value - should use default (as_u64 fails on floats)
        let result = tool
            .execute(serde_json::json!({"delay_secs": 5.5}), &ctx)
            .await;
        assert!(result.is_ok());
        let output = result.unwrap();
        let text = output.result.as_str().unwrap();
        assert!(text.contains("Restarting in 2 second(s)"));
    }

    #[tokio::test]
    async fn test_restart_tool_output_structure() {
        enable_docker_env();
        let tool = RestartTool;
        let ctx = crate::context::JobContext::new("test", "test restart");

        let result = tool
            .execute(serde_json::json!({"delay_secs": 5}), &ctx)
            .await;

        assert!(result.is_ok());
        let output = result.unwrap();

        // Verify ToolOutput structure
        assert!(output.result.is_string());
        assert!(output.duration.as_secs() == 0); // Should be nearly instant
        assert!(output.cost.is_none()); // No cost tracking for restart
        assert!(output.raw.is_none()); // No raw output stored
    }

    #[tokio::test]
    async fn test_restart_tool_extra_parameters_ignored() {
        enable_docker_env();
        let tool = RestartTool;
        let ctx = crate::context::JobContext::new("test", "test restart");

        // Extra parameters should be ignored
        let result = tool
            .execute(
                serde_json::json!({
                    "delay_secs": 5,
                    "extra_field": "should be ignored",
                    "another": 123
                }),
                &ctx,
            )
            .await;

        assert!(result.is_ok());
        let output = result.unwrap();
        let text = output.result.as_str().unwrap();
        assert!(text.contains("Restarting in 5 second(s)"));
    }

    #[tokio::test]
    async fn test_restart_tool_negative_numbers() {
        enable_docker_env();
        let tool = RestartTool;
        let ctx = crate::context::JobContext::new("test", "test restart");

        // Negative number should clamp to 1
        let result = tool
            .execute(serde_json::json!({"delay_secs": -5}), &ctx)
            .await;
        assert!(result.is_ok());
        let output = result.unwrap();
        let text = output.result.as_str().unwrap();
        // as_u64() on negative number returns None, so falls to default 2
        assert!(text.contains("Restarting in 2 second(s)"));
    }

    #[tokio::test]
    async fn test_restart_tool_very_large_numbers() {
        enable_docker_env();
        let tool = RestartTool;
        let ctx = crate::context::JobContext::new("test", "test restart");

        // Very large number should clamp to 30
        let result = tool
            .execute(serde_json::json!({"delay_secs": u64::MAX}), &ctx)
            .await;
        assert!(result.is_ok());
        let output = result.unwrap();
        let text = output.result.as_str().unwrap();
        assert!(text.contains("Restarting in 30 second(s)"));
    }

    #[tokio::test]
    async fn test_restart_tool_empty_object() {
        enable_docker_env();
        let tool = RestartTool;
        let ctx = crate::context::JobContext::new("test", "test restart");

        // Empty object params should use all defaults
        let result = tool.execute(serde_json::json!({}), &ctx).await;
        assert!(result.is_ok());
        let output = result.unwrap();
        let text = output.result.as_str().unwrap();
        assert!(text.contains("Restarting in 2 second(s)"));
        assert!(text.contains("exit cleanly"));
        assert!(text.contains("entrypoint restart loop"));
    }

    #[test]
    fn test_restart_tool_approval_consistent_regardless_of_params() {
        let tool = RestartTool;

        // Approval requirement should be the same regardless of params
        let approval1 = tool.requires_approval(&serde_json::json!({"delay_secs": 5}));
        let approval2 = tool.requires_approval(&serde_json::json!({"delay_secs": 100}));
        let approval3 = tool.requires_approval(&serde_json::json!({}));

        // All should return the default (Never) since approval happens at command level
        assert!(matches!(approval1, ApprovalRequirement::Never));
        assert!(matches!(approval2, ApprovalRequirement::Never));
        assert!(matches!(approval3, ApprovalRequirement::Never));
    }

    #[test]
    fn test_restart_tool_requires_docker_environment() {
        // Test that restart is rejected when not in Docker (IRONCLAW_IN_DOCKER not set or false)
        // Uses sync test to avoid async/env var ordering issues with test parallelization.
        let in_docker = std::env::var("IRONCLAW_IN_DOCKER")
            .map(|v| v.to_lowercase() == "true")
            .unwrap_or(false);

        // Verify logic: when not in Docker, env var should be false/unset
        if !in_docker {
            // Simulating what the tool would do when IRONCLAW_IN_DOCKER is not set
            assert!(
                !in_docker,
                "Test environment should have IRONCLAW_IN_DOCKER unset or false"
            );
        }
    }
}