diff --git a/README.md b/README.md index 836ece20..9b66b73b 100644 --- a/README.md +++ b/README.md @@ -493,6 +493,9 @@ and start token verify against the live leader, using the same legacy authority as explicit controls such as `kill`. Missing, unreadable or mismatching tokens remain refused; `--force` never bypasses identity verification. This does not write a synthetic `daemonBootId` or upgrade the session's inventory identity. +A legacy record that already has an exit while its daemon is still finishing +is refused the same way, before any restart prompt: a restart waits for proof +that the daemon is gone, and a live legacy daemon cannot give it. `run` and `restart` are different and deliberately so: from inside a session `run` runs the command directly and `restart` restarts without attaching. diff --git a/crates/pty/src/cli/attach.rs b/crates/pty/src/cli/attach.rs index 79fb78d2..0cbd3186 100644 --- a/crates/pty/src/cli/attach.rs +++ b/crates/pty/src/cli/attach.rs @@ -145,7 +145,11 @@ fn attach_session( }; // Reuse explicit controls' recorded-PID/token policy for pre-boot-ID // daemons. This does not upgrade inventory identity or rewrite metadata. + // Only while running: a restart needs proof that the daemon is gone, which + // a live legacy daemon can't give, so an exit it is still finishing is + // refused here rather than offered a restart that would then be refused. let legacy_verified = session.identity == registry::DaemonIdentityCheck::Unknown + && session.status == SessionStatus::Running && session.metadata.as_ref().is_some_and(|metadata| { metadata.daemon_boot_id.is_none() && registry::read_control_signal_target_with(name, Some(metadata)).is_some() diff --git a/crates/pty/tests/cli_attach_guard.rs b/crates/pty/tests/cli_attach_guard.rs index d1684fdc..3d6da6f1 100644 --- a/crates/pty/tests/cli_attach_guard.rs +++ b/crates/pty/tests/cli_attach_guard.rs @@ -117,6 +117,47 @@ fn legacy_attach_without_matching_token_never_reaches_transport_even_with_force( } } +#[test] +fn legacy_exit_with_a_live_daemon_is_refused_before_the_restart_prompt() { + let rig = Rig::new(); + let pid = std::process::id() as i32; + let rerun = rig.scratch.join("rerun"); + // The exit is written and the verified legacy daemon is still finishing. + rig.write_meta( + "finishing", + json!({ + "generation": "063ff7f9-style", + "daemonPid": pid, + "daemonStartToken": pty_core::registry::read_process_start_token(pid).unwrap(), + "command": "sh", + "args": ["-c", "touch \"$1\"", "rerun", rerun], + "exitedAt": "2026-11-04T00:00:00.000Z", + "exitCode": 0, + }), + ); + std::fs::write(rig.path("finishing.pid"), pid.to_string()).unwrap(); + let before = std::fs::read(rig.path("finishing.json")).unwrap(); + // A closed stdin reads as yes to the prompt, and -r never asks. + for out in [ + rig.run(&["attach", "finishing"]), + rig.run_stdin(&["attach", "finishing"], "y\n"), + rig.run(&["attach", "-r", "finishing"]), + rig.run(&["attach", "--force", "finishing"]), + ] { + assert_eq!(out.code, 1, "{out:?}"); + assert!( + out.stderr + .contains("refusing session \"finishing\": daemon identity is unknown"), + "{out:?}" + ); + let shown = format!("{}{}", out.stdout, out.stderr); + assert!(!shown.contains("Restart?"), "{out:?}"); + assert!(!shown.contains("exited with code"), "{out:?}"); + } + assert!(!rerun.exists()); + assert_eq!(std::fs::read(rig.path("finishing.json")).unwrap(), before); +} + #[test] fn auto_restart_revoked_binding_preserves_retained_artifacts_without_running_child() { for retained_socket in [false, true] {