boxlite 0.10.0

Embeddable virtual machine runtime for secure, isolated code execution
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
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
//! The box's main command: `BoxOptions.cmd` becomes the container's init
//! (docker `run` semantics), `attach()` streams it, and `BoxOptions.tty`
//! decides whether it gets a terminal.
//!
//! These exercise the paths the CLI's `run` takes, at the layer where a test
//! can actually reach them: `run -t` is rejected unless the CLI's own stdin is
//! a terminal, which it never is under a test harness.

mod common;

use boxlite::{BoxCommand, BoxOptions, LiteBox, RootfsSpec};
use tokio_stream::StreamExt;

/// Create a box whose main command is `cmd`, optionally on a PTY.
fn main_command_opts(cmd: &[&str], tty: bool) -> BoxOptions {
    BoxOptions {
        rootfs: RootfsSpec::Image("alpine:latest".into()),
        auto_delete: Some(0),
        cmd: Some(cmd.iter().map(|s| s.to_string()).collect()),
        tty,
        ..Default::default()
    }
}

/// Start a box, attach to its main command, and collect what it prints.
///
/// The command is expected to keep running after it speaks: attaching to a
/// process that already exited is a different scenario, and this helper is not
/// it.
async fn attached_stdout(opts: BoxOptions) -> String {
    let home = boxlite_test_utils::home::PerTestBoxHome::new();
    let runtime = boxlite::BoxliteRuntime::new(boxlite::runtime::options::BoxliteOptions {
        home_dir: home.path.clone(),
        image_registries: common::test_registries(),
    })
    .expect("create runtime");

    let handle = runtime.create(opts, None).await.expect("create box");
    handle.start().await.expect("start box");

    let mut execution = handle
        .attach(None)
        .await
        .expect("attach to the main command");

    let mut stdout = String::new();
    if let Some(mut stream) = execution.stdout() {
        // The command prints one line then sleeps, so take the first chunk that
        // carries our answer rather than waiting for an EOF that will not come
        // until the box is torn down.
        while let Some(chunk) = stream.next().await {
            stdout.push_str(&chunk);
            if stdout.contains("TTY") || stdout.contains("NOTTY") {
                break;
            }
        }
    }

    let _ = handle.stop().await;
    let _ = runtime.remove(handle.id().as_str(), true).await;
    let _ = runtime.shutdown(Some(common::TEST_SHUTDOWN_TIMEOUT)).await;

    stdout
}

async fn wait_for_file(handle: &LiteBox, path: &str) {
    tokio::time::timeout(std::time::Duration::from_secs(30), async {
        loop {
            let execution = handle
                .exec(BoxCommand::new("test").args(["-e", path]))
                .await
                .expect("check main command marker");
            if execution
                .wait()
                .await
                .expect("wait for marker check")
                .exit_code
                == 0
            {
                break;
            }
            tokio::time::sleep(std::time::Duration::from_millis(100)).await;
        }
    })
    .await
    .expect("main command must reach marker");
}

#[tokio::test]
async fn main_command_exits_after_large_output_without_attach() {
    let home = boxlite_test_utils::home::PerTestBoxHome::new();
    let runtime = boxlite::BoxliteRuntime::new(boxlite::runtime::options::BoxliteOptions {
        home_dir: home.path.clone(),
        image_registries: common::test_registries(),
    })
    .expect("create runtime");

    let handle = runtime
        .create(
            main_command_opts(&["sh", "-c", "head -c 1048576 /dev/zero; exit 23"], false),
            None,
        )
        .await
        .expect("create box");

    let completed = tokio::time::timeout(std::time::Duration::from_secs(30), async {
        handle.start().await.expect("start box");
        loop {
            if handle.info().await.expect("get box info").status == boxlite::BoxStatus::Stopped {
                return true;
            }
            tokio::time::sleep(std::time::Duration::from_millis(100)).await;
        }
    })
    .await
    .unwrap_or(false);

    let _ = handle.stop().await;
    let _ = runtime.remove(handle.id().as_str(), true).await;
    let _ = runtime.shutdown(Some(common::TEST_SHUTDOWN_TIMEOUT)).await;

    assert!(
        completed,
        "a main command that has no Attach consumer must still drain and exit"
    );
}

#[tokio::test]
async fn late_attach_reports_output_gap() {
    let home = boxlite_test_utils::home::PerTestBoxHome::new();
    let runtime = boxlite::BoxliteRuntime::new(boxlite::runtime::options::BoxliteOptions {
        home_dir: home.path.clone(),
        image_registries: common::test_registries(),
    })
    .expect("create runtime");

    let handle = runtime
        .create(
            main_command_opts(
                &[
                    "sh",
                    "-c",
                    "head -c 2097152 /dev/zero; touch /tmp/main-output-ready; sleep 30",
                ],
                false,
            ),
            None,
        )
        .await
        .expect("create box");

    handle.start().await.expect("start box");
    wait_for_file(&handle, "/tmp/main-output-ready").await;

    let mut execution = handle
        .attach(None)
        .await
        .expect("attach to the main command");
    let mut stdout = execution.stdout().expect("stdout stream");
    let dropped = tokio::time::timeout(std::time::Duration::from_secs(5), async {
        while let Some(chunk) = stdout.next().await {
            if chunk.contains("[boxlite] stdout output dropped") {
                return Some(chunk);
            }
        }
        None
    })
    .await
    .unwrap_or(None);

    let _ = handle.stop().await;
    let _ = runtime.remove(handle.id().as_str(), true).await;
    let _ = runtime.shutdown(Some(common::TEST_SHUTDOWN_TIMEOUT)).await;

    let dropped = dropped.expect("late attach must report overwritten output");
    assert!(
        dropped.contains("stdout output dropped"),
        "late attach must report the stdout gap: {dropped:?}"
    );
}

/// `tty: true` must give the *main command* a real terminal.
///
/// `test -t 0` asks the kernel, so this cannot pass unless init's fd 0 really
/// is a tty: the guest has to set OCI `process.terminal`, receive the PTY
/// master over the console socket, and wire it to init's session.
///
/// This is the regression guard for `run -it`. Once COMMAND became init, it
/// stopped travelling the exec path that used to build its PTY, and init's
/// spec was hard-coded `terminal(false)` — so `-it` silently degraded to
/// pipes: no prompt, no job control, and every `test -t 0` inside the box
/// answering NOTTY.
#[tokio::test]
async fn main_command_gets_a_pty_when_tty_is_set() {
    let stdout = attached_stdout(main_command_opts(
        &["sh", "-c", "test -t 0 && echo TTY || echo NOTTY; sleep 30"],
        true,
    ))
    .await;

    assert!(
        stdout.contains("TTY") && !stdout.contains("NOTTY"),
        "init's stdin must be a terminal when tty is set, got: {stdout:?}"
    );
}

/// The control: without `tty`, the main command is on pipes, as before.
///
/// Without this the test above proves nothing — "TTY" could just be what the
/// box always says.
#[tokio::test]
async fn main_command_gets_pipes_when_tty_is_unset() {
    let stdout = attached_stdout(main_command_opts(
        &["sh", "-c", "test -t 0 && echo TTY || echo NOTTY; sleep 30"],
        false,
    ))
    .await;

    assert!(
        stdout.contains("NOTTY"),
        "init's stdin must be a pipe when tty is unset, got: {stdout:?}"
    );
}

/// A stopped box with **no** main command of its own must still wake up on
/// `exec`. This is the cloud's auto-stop contract and it is not optional.
///
/// The cloud reaps idle boxes on a cron, leaving them Stopped, and revives them
/// on the next SDK call — which goes straight to `/exec` and never calls start.
/// The guard that stops a *job* from re-running itself must therefore key on the
/// box's config, not merely on its status: a box without `cmd` boots the image's
/// own default (an agent daemon), and restarting that is the designed behaviour.
///
/// Gating on status alone silently repealed this. Nothing in `apps/` compensates:
/// the data-plane proxy forwards `/exec` and only bumps `lastActivityAt`.
#[tokio::test]
async fn a_stopped_box_without_a_main_command_still_restarts_on_exec() {
    let home = boxlite_test_utils::home::PerTestBoxHome::new();
    let runtime = boxlite::BoxliteRuntime::new(boxlite::runtime::options::BoxliteOptions {
        home_dir: home.path.clone(),
        image_registries: common::test_registries(),
    })
    .expect("create runtime");

    // No `cmd`: init is the image default, exactly as a cloud box is created.
    let opts = BoxOptions {
        rootfs: RootfsSpec::Image("alpine:latest".into()),
        auto_delete: Some(0),
        ..Default::default()
    };
    let handle = runtime.create(opts, None).await.expect("create box");
    handle.start().await.expect("start box");
    handle.stop().await.expect("stop box");

    // The reaper stopped it; the next SDK call must bring it back by itself.
    let fresh = runtime
        .get(handle.id().as_str())
        .await
        .expect("get box")
        .expect("box exists");
    drop(handle);

    let execution = fresh
        .exec(boxlite::BoxCommand::new("echo").args(vec!["awake".to_string()]))
        .await
        .expect("exec must implicitly restart a stopped box that has no main command");
    let result = execution.wait().await.expect("wait");
    assert_eq!(result.exit_code, 0, "the revived box must actually run it");

    let _ = fresh.stop().await;
    let _ = runtime.remove(fresh.id().as_str(), true).await;
    let _ = runtime.shutdown(Some(common::TEST_SHUTDOWN_TIMEOUT)).await;
}

/// A retained handle whose VM has died must refuse, not hand back the corpse —
/// and this is the one box where nothing else would stop it.
///
/// The re-run gate deliberately *passes* a stopped box with no main command of
/// its own, because the cloud's auto-restart depends on exactly that. So for this
/// box, and only this box, `live_state()` is the last thing standing between the
/// caller and a dead VM: its `OnceCell` is already initialized, cannot be
/// re-initialized, and would hand back the `LiveState` of a guest that is gone —
/// the restart the caller was promised silently never happening.
///
/// Killing the shim is what a self-stop looks like from the host: the guest
/// powers the VM off and the shim dies. Reaching that state via an image whose
/// default exits would need a different image; killing the shim is the same
/// state, arrived at directly.
#[tokio::test]
async fn a_stopped_no_command_box_refuses_to_serve_its_dead_vm() {
    let home = boxlite_test_utils::home::PerTestBoxHome::new();
    let runtime = boxlite::BoxliteRuntime::new(boxlite::runtime::options::BoxliteOptions {
        home_dir: home.path.clone(),
        image_registries: common::test_registries(),
    })
    .expect("create runtime");

    // No `cmd` and no `entrypoint`: the gate lets this box's exec through.
    let opts = BoxOptions {
        rootfs: RootfsSpec::Image("alpine:latest".into()),
        auto_delete: Some(0),
        ..Default::default()
    };
    let handle = runtime.create(opts, None).await.expect("create box");
    handle.start().await.expect("start box");
    let shim = handle
        .info()
        .await
        .expect("get box info")
        .pid
        .expect("a running box has a shim");

    // The VM dies underneath the handle.
    let killed = std::process::Command::new("kill")
        .args(["-9", &shim.to_string()])
        .status()
        .expect("run kill");
    assert!(killed.success(), "the shim must actually be killed");

    let mut status = handle.info().await.expect("get box info").status;
    for _ in 0..60 {
        status = handle.info().await.expect("get box info").status;
        if status != boxlite::BoxStatus::Running {
            break;
        }
        tokio::time::sleep(std::time::Duration::from_millis(500)).await;
    }
    assert_ne!(
        status,
        boxlite::BoxStatus::Running,
        "precondition: the box must be observed to have stopped"
    );

    // The gate passes this box (no main command), so the refusal must come from
    // live_state() — or the caller gets a corpse.
    let err = match handle.exec(boxlite::BoxCommand::new("echo")).await {
        Ok(_) => panic!("a spent handle must refuse, not hand back the dead VM"),
        Err(e) => e,
    };
    let msg = err.to_string();
    assert!(
        msg.contains("spent") || msg.contains("no longer running"),
        "the refusal must say the handle is spent, got: {msg}"
    );

    // And the box itself is fine — a fresh handle restarts it on exec, which is
    // the auto-restart the cloud relies on. The refusal is about the handle, not
    // the box.
    let box_id = handle.id().to_string();
    drop(handle);

    let fresh = runtime
        .get(&box_id)
        .await
        .expect("get box")
        .expect("box exists");
    let execution = fresh
        .exec(boxlite::BoxCommand::new("echo").args(vec!["awake".to_string()]))
        .await
        .expect("a fresh handle must restart the box and run the exec");
    let result = execution.wait().await.expect("wait");
    assert_eq!(result.exit_code, 0, "the revived box must actually run it");

    let _ = fresh.stop().await;
    let _ = runtime.remove(&box_id, true).await;
    let _ = runtime.shutdown(Some(common::TEST_SHUTDOWN_TIMEOUT)).await;
}

/// A failed first boot must not poison the handle into creating a container it
/// never runs.
///
/// `run` foreground boots by `attach()`ing — which now *creates* the container
/// without running it — and then `start()`ing it. "Create but don't run yet"
/// used to be a flag threaded into the boot; `get_or_try_init` leaves its cell
/// empty when a boot fails, so the flag could outlive the call that meant it and
/// a later plain `start()` would create the container and never send
/// `Container.Start`. Booting is now unconditionally create-only and running init
/// is a separate `OnceCell` set only on success, so a failed `attach()` strands
/// nothing: the next `start()` boots and runs normally.
#[tokio::test]
async fn a_failed_attach_does_not_poison_the_next_start() {
    let home = boxlite_test_utils::home::PerTestBoxHome::new();
    let runtime = boxlite::BoxliteRuntime::new(boxlite::runtime::options::BoxliteOptions {
        home_dir: home.path.clone(),
        image_registries: common::test_registries(),
    })
    .expect("create runtime");

    let handle = runtime
        .create(
            main_command_opts(&["sh", "-c", "sleep 30"], false),
            Some("poison".to_string()),
        )
        .await
        .expect("create box");

    // Fail the first boot *transiently*. That is the whole point: the same box and
    // the same handle must be startable afterwards. A permanent failure (a bad
    // image) could never expose a stranded flag, because the retry would fail for
    // the same reason and never reach the pipeline — which is exactly the hole a
    // reviewer caught in the first version of this test. Putting a regular *file*
    // where the boxes directory belongs stops the box's own directory from being
    // created, and is undone immediately. (A read-only directory is not enough:
    // mode bits do not bind root, and CI runs this suite as root.)
    let boxes_dir = home.path.join("boxes");
    if boxes_dir.exists() {
        std::fs::remove_dir_all(&boxes_dir).expect("clear boxes dir");
    }
    std::fs::write(&boxes_dir, b"").expect("plant file where the boxes dir belongs");

    let failed = handle.attach(None).await;

    std::fs::remove_file(&boxes_dir).expect("remove planted file");
    std::fs::create_dir_all(&boxes_dir).expect("restore boxes dir");
    assert!(
        failed.is_err(),
        "precondition: the boot must fail while the boxes path is not a directory"
    );

    // Same box, same BoxImpl. A boot mode stranded on the handle would now create
    // the container and never send Container.Start: the box would come up Running
    // with a main command that never ran.
    handle
        .start()
        .await
        .expect("a plain start must boot normally after a failed attached start");

    // Proof that init actually *ran*, not merely got created: exec into it.
    // libcontainer refuses an exec against a container still in `Created`, which is
    // precisely what a stranded flag leaves behind.
    let execution = handle
        .exec(boxlite::BoxCommand::new("echo").args(vec!["ran".to_string()]))
        .await
        .expect("the box's init must be running, not merely created");
    let result = execution.wait().await.expect("wait");
    assert_eq!(
        result.exit_code, 0,
        "the started box must really be running"
    );

    let _ = handle.stop().await;
    let _ = runtime.remove(handle.id().as_str(), true).await;
    let _ = runtime.shutdown(Some(common::TEST_SHUTDOWN_TIMEOUT)).await;
}

/// A box the runtime *adopts* — already running when this process found it —
/// must be followed to its exit too, not just one this process started.
///
/// The watcher used to be armed only by our own `start()`. But a long-lived
/// runtime meets already-running boxes on every restart: `boxlite serve` comes
/// back up, recovers them, and may never touch them again. Such a box would run
/// to completion entirely unobserved and be reported Running forever — which is
/// the exact lie the watcher exists to stop telling, told to precisely the
/// audience it was written for.
#[tokio::test]
async fn an_adopted_running_box_is_followed_to_its_exit() {
    let home = boxlite_test_utils::home::PerTestBoxHome::new();
    let opts = || boxlite::runtime::options::BoxliteOptions {
        home_dir: home.path.clone(),
        image_registries: common::test_registries(),
    };

    // A first runtime starts a detached box, then goes away without stopping it.
    // `detach` is what lets the shim outlive its runtime.
    {
        let first = boxlite::BoxliteRuntime::new(opts()).expect("create runtime");
        let mut box_opts = main_command_opts(&["sh", "-c", "sleep 5; exit 9"], false);
        box_opts.detach = true;
        let handle = first
            .create(box_opts, Some("adopted".to_string()))
            .await
            .expect("create box");
        handle.start().await.expect("start box");
        // `start()` only *spawns* the container start — boot creates the
        // container, running its init is the separate step (docker's create →
        // attach → start), and `start()` returns as soon as the VM is up.
        // Leaving the block here would drop the runtime mid-spawn and race it,
        // testing that race instead of the adoption below. `exec` single-flights
        // on the same `container_start` cell, so it both performs and awaits
        // that step: past this point the main command is genuinely running.
        handle
            .exec(BoxCommand::new("true"))
            .await
            .expect("run the container init before abandoning the runtime")
            .wait()
            .await
            .expect("await the probe exec");
    }

    // A second runtime adopts it: `get()` hands out a handle, which is where the
    // watcher gets armed for a box we did not start.
    let second = boxlite::BoxliteRuntime::new(opts()).expect("create second runtime");
    let adopted = second
        .get("adopted")
        .await
        .expect("get box")
        .expect("box exists");
    assert_eq!(
        adopted.info().await.expect("get box info").status,
        boxlite::BoxStatus::Running,
        "precondition: the box must still be running when we adopt it"
    );

    // Its main command now exits on its own. Nothing in this process started the
    // box, so only an armed watcher can notice.
    let mut info = adopted.info().await.expect("get box info");
    for _ in 0..60 {
        info = adopted.info().await.expect("get box info");
        if info.status != boxlite::BoxStatus::Running {
            break;
        }
        tokio::time::sleep(std::time::Duration::from_millis(500)).await;
    }

    assert_ne!(
        info.status,
        boxlite::BoxStatus::Running,
        "an adopted box's exit must be observed — otherwise it is reported Running forever"
    );
    assert_eq!(
        info.exit_code,
        Some(9),
        "and its exit code must be surfaced, not just its death"
    );

    let _ = second.remove(adopted.id().as_str(), true).await;
    let _ = second.shutdown(Some(common::TEST_SHUTDOWN_TIMEOUT)).await;
}

/// A box that stopped itself must never accept `start()` on the spent handle
/// and pretend it worked — and a fresh handle must be able to restart it.
///
/// Boxes can now end on their own: the main command exits, the guest powers the
/// VM off, and the exit watcher marks the box Stopped. That leaves the handle
/// holding a dead `LiveState` in a `OnceCell` that cannot be re-initialized, so
/// `start()` would sail past every guard — the token is uncancelled (only
/// `stop()` cancels it) and `Stopped` is startable — hand back the corpse, boot
/// nothing, and return Ok. A long-lived runtime would think it had restarted a
/// box that never came back.
#[tokio::test]
async fn a_self_stopped_box_refuses_to_restart_on_the_spent_handle() {
    let home = boxlite_test_utils::home::PerTestBoxHome::new();
    let runtime = boxlite::BoxliteRuntime::new(boxlite::runtime::options::BoxliteOptions {
        home_dir: home.path.clone(),
        image_registries: common::test_registries(),
    })
    .expect("create runtime");

    // A main command that exits on its own — the box stops itself.
    let handle = runtime
        .create(
            main_command_opts(&["sh", "-c", "exit 7"], false),
            Some("self-stop".to_string()),
        )
        .await
        .expect("create box");
    handle.start().await.expect("start box");

    // Wait for the watcher to observe the shim's death and record the exit.
    let mut info = handle.info().await.expect("get box info");
    for _ in 0..60 {
        info = handle.info().await.expect("get box info");
        if info.status != boxlite::BoxStatus::Running {
            break;
        }
        tokio::time::sleep(std::time::Duration::from_millis(500)).await;
    }
    assert_ne!(
        info.status,
        boxlite::BoxStatus::Running,
        "the box must stop itself once its main command exits"
    );
    assert_eq!(
        info.exit_code,
        Some(7),
        "the live watcher must surface the main command's exit code"
    );

    // The spent handle must refuse, not silently no-op.
    let err = handle
        .start()
        .await
        .expect_err("starting a spent handle must fail rather than boot nothing");
    let msg = err.to_string();
    assert!(
        msg.contains("spent") || msg.contains("fresh"),
        "the refusal must tell the caller to get a fresh handle, got: {msg}"
    );

    // And a fresh handle really does restart it — the refusal above is about
    // the handle, not the box. The runtime caches live handles behind Weak
    // refs, so the spent one must be dropped before `get()` will build a new
    // BoxImpl from persisted state; that is the same contract as after stop().
    drop(handle);
    let fresh = runtime
        .get("self-stop")
        .await
        .expect("get box")
        .expect("box exists");
    fresh.start().await.expect("a fresh handle must restart it");
    assert_eq!(
        fresh.info().await.expect("get box info").status,
        boxlite::BoxStatus::Running,
        "the restarted box must actually be running"
    );

    let _ = fresh.stop().await;
    let _ = runtime.remove(fresh.id().as_str(), true).await;
    let _ = runtime.shutdown(Some(common::TEST_SHUTDOWN_TIMEOUT)).await;
}

/// `attach()` refuses a stopped box.
///
/// Attaching now boots the box create-only and subscribes — it never runs the
/// command, so it dropped the re-run guard `exec`/`cp` keep. What it must still
/// refuse is a box that has already stopped: there is no session to follow, and
/// silently rebooting one just to attach would surprise the caller (docker
/// refuses attaching to a stopped container too).
#[tokio::test]
async fn attach_refuses_a_stopped_box() {
    let home = boxlite_test_utils::home::PerTestBoxHome::new();
    let runtime = boxlite::BoxliteRuntime::new(boxlite::runtime::options::BoxliteOptions {
        home_dir: home.path.clone(),
        image_registries: common::test_registries(),
    })
    .expect("create runtime");

    // A main command that exits on its own — the box stops itself. Its name holds
    // neither "attach" nor "stopped", so the assertion below tests the real
    // message, not the id echoed back into it.
    let handle = runtime
        .create(
            main_command_opts(&["sh", "-c", "exit 0"], false),
            Some("exit-job".to_string()),
        )
        .await
        .expect("create box");
    handle.start().await.expect("start box");

    // Wait for the watcher to mark it Stopped.
    let mut info = handle.info().await.expect("get box info");
    for _ in 0..60 {
        info = handle.info().await.expect("get box info");
        if info.status != boxlite::BoxStatus::Running {
            break;
        }
        tokio::time::sleep(std::time::Duration::from_millis(500)).await;
    }
    assert_ne!(
        info.status,
        boxlite::BoxStatus::Running,
        "precondition: the box must stop itself once its main command exits"
    );

    // A *fresh* handle on the stopped box — not spent (its `live` cell is empty),
    // so the only thing that can refuse the attach is attach()'s own status gate,
    // not the spent-handle guard.
    drop(handle);
    let stopped = runtime
        .get("exit-job")
        .await
        .expect("get box")
        .expect("box exists");
    assert_eq!(
        stopped.info().await.expect("get box info").status,
        boxlite::BoxStatus::Stopped,
        "precondition: a fresh handle on the box reports it Stopped"
    );

    // `Execution` is not `Debug`, so match rather than `expect_err`.
    let msg = match stopped.attach(None).await {
        Ok(_) => panic!("attaching to a stopped box must fail, not reboot it"),
        Err(e) => e.to_string(),
    };
    assert!(
        msg.contains("attach") && msg.contains("stopped"),
        "the refusal must name the operation and the state, got: {msg}"
    );

    let _ = runtime.remove(stopped.id().as_str(), true).await;
    let _ = runtime.shutdown(Some(common::TEST_SHUTDOWN_TIMEOUT)).await;
}

/// `attach(Some(id))` — reattaching to an exec by id — is REST-only.
///
/// The single `attach(execution_id)` folds in what used to be `attach_exec`. A
/// local, in-process exec keeps the `Execution` it was created with and never
/// drops its stream, so there is nothing to reattach to by id: the local backend
/// supports the main session (`None`) only and refuses the `Some(id)` arm.
#[tokio::test]
async fn attach_by_exec_id_is_unsupported_on_the_local_backend() {
    let home = boxlite_test_utils::home::PerTestBoxHome::new();
    let runtime = boxlite::BoxliteRuntime::new(boxlite::runtime::options::BoxliteOptions {
        home_dir: home.path.clone(),
        image_registries: common::test_registries(),
    })
    .expect("create runtime");

    // Never started: the `Some(id)` arm is refused before any box state matters,
    // so no VM is booted (and the box name holds neither "local" nor "reattach",
    // keeping the message assertion honest).
    let handle = runtime
        .create(
            main_command_opts(&["sh", "-c", "sleep 30"], false),
            Some("job-a".to_string()),
        )
        .await
        .expect("create box");

    let msg = match handle.attach(Some("some-exec-id")).await {
        Ok(_) => panic!("local attach(Some(id)) must be Unsupported, not succeed"),
        Err(e) => e.to_string(),
    };
    assert!(
        msg.contains("local") && msg.contains("reattach"),
        "the error must explain local reattach is unsupported, got: {msg}"
    );

    let _ = runtime.remove(handle.id().as_str(), true).await;
    let _ = runtime.shutdown(Some(common::TEST_SHUTDOWN_TIMEOUT)).await;
}