diff --git a/README.md b/README.md index 8785b479..836ece20 100644 --- a/README.md +++ b/README.md @@ -488,6 +488,12 @@ Node one. A script that captures only standard output sees an empty result and can mistake that for success, which is what happens if you forget `--force`; the exit code tells you the truth. +`attach` also accepts live pre-boot-ID daemons when their recorded daemon PID +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. + `run` and `restart` are different and deliberately so: from inside a session `run` runs the command directly and `restart` restarts without attaching. Both did what was asked, so both exit 0. diff --git a/crates/pty-core/src/registry/list.rs b/crates/pty-core/src/registry/list.rs index f0e85e1c..33f69c9e 100644 --- a/crates/pty-core/src/registry/list.rs +++ b/crates/pty-core/src/registry/list.rs @@ -433,7 +433,7 @@ pub fn check_daemon_identity(pid: i32, metadata: &SessionMetadata) -> DaemonIden result } -/// Identity evidence for explicit stop, signal and remove controls only. +/// Identity evidence for explicit attach, stop, signal and remove controls only. /// `LegacyVerified` is not full daemon identity proof: it must never authorize /// inventory liveness, generation replacement, launch or retirement. A stop /// reports which of `Match` and `LegacyVerified` authorized it. @@ -740,7 +740,7 @@ pub fn read_control_target_authority_with( read_explicit_control_authority_in(&session_dir(), name, metadata, false) } -/// Resolve an explicit child-signal target. Unlike legacy stop, signals +/// Resolve an explicit child-signal or attach target. Unlike legacy stop, these /// always require a recorded daemon PID and start token, even with a sidecar. pub fn read_control_signal_target_with(name: &str, metadata: Option<&SessionMetadata>) -> Option { read_explicit_control_target_in(&session_dir(), name, metadata, true) diff --git a/crates/pty/src/cli/attach.rs b/crates/pty/src/cli/attach.rs index 659e205b..79fb78d2 100644 --- a/crates/pty/src/cli/attach.rs +++ b/crates/pty/src/cli/attach.rs @@ -143,7 +143,14 @@ fn attach_session( eprintln!("Session \"{name}\" not found."); return Ok(1); }; - if session.identity == registry::DaemonIdentityCheck::Unknown { + // Reuse explicit controls' recorded-PID/token policy for pre-boot-ID + // daemons. This does not upgrade inventory identity or rewrite metadata. + let legacy_verified = session.identity == registry::DaemonIdentityCheck::Unknown + && 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() + }); + if session.identity == registry::DaemonIdentityCheck::Unknown && !legacy_verified { eprintln!("pty attach: refusing session \"{name}\": daemon identity is unknown."); return Ok(1); } diff --git a/crates/pty/tests/cli_attach_guard.rs b/crates/pty/tests/cli_attach_guard.rs index ab72259e..d1684fdc 100644 --- a/crates/pty/tests/cli_attach_guard.rs +++ b/crates/pty/tests/cli_attach_guard.rs @@ -33,19 +33,90 @@ fn delayed_status_does_not_allow_unforced_attach() { } #[test] -fn auto_restart_preserves_missing_boot_live_child() { +fn auto_restart_accepts_verified_missing_boot_without_restarting() { let rig = Rig::new(); let mut fixture = cli_common::MissingBootSession::new(&rig, "unknown", serde_json::json!({})); // --auto-restart is this CLI's noninteractive restart policy (-r). - // Unknown stays conservatively running; the independent identity result - // must refuse before any restart or transport-based ownership fallback. + // Legacy token verification admits the record, but the independent + // nesting guard still refuses a daemon that cannot answer STATUS. let out = rig.run(&["attach", "--auto-restart", "unknown"]); assert_eq!(out.code, 1, "{out:?}"); - assert!(out.stderr.contains("identity is unknown"), "{out:?}"); + assert!(out.stderr.contains("cannot verify the current session"), "{out:?}"); assert!(!out.stdout.contains("Restarting"), "{out:?}"); fixture.assert_preserved(&rig); } +#[test] +fn legacy_attach_reaches_transport_without_upgrading_metadata() { + for sidecar in [false, true] { + let rig = Rig::new(); + let listener = UnixListener::bind(rig.path("legacy.sock")).unwrap(); + listener.set_nonblocking(true).unwrap(); + let pid = std::process::id() as i32; + rig.write_meta("legacy", json!({ + "generation": "063ff7f9-style", + "daemonPid": pid, + "daemonStartToken": pty_core::registry::read_process_start_token(pid).unwrap(), + })); + if sidecar { + std::fs::write(rig.path("legacy.pid"), pid.to_string()).unwrap(); + } + let before = std::fs::read(rig.path("legacy.json")).unwrap(); + // Closing each accepted connection lets attach finish without a real + // terminal or daemon. A connection proves admission reached do_attach. + let done = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)); + let stop = done.clone(); + let server = std::thread::spawn(move || { + let mut accepted = 0; + while !stop.load(std::sync::atomic::Ordering::Relaxed) { + match listener.accept() { + Ok((socket, _)) => { accepted += 1; drop(socket); } + Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => { + std::thread::sleep(Duration::from_millis(5)); + } + Err(error) => panic!("accept legacy attach: {error}"), + } + } + accepted + }); + let out = rig.run(&["attach", "--force", "--no-restart", "legacy"]); + done.store(true, std::sync::atomic::Ordering::Relaxed); + assert!(server.join().unwrap() > 0, "{out:?}"); + assert!(!out.stderr.contains("identity is unknown"), "{out:?}"); + assert_eq!(std::fs::read(rig.path("legacy.json")).unwrap(), before); + } +} + +#[test] +fn legacy_attach_without_matching_token_never_reaches_transport_even_with_force() { + for token in [None, Some("mismatching:token")] { + for force in [false, true] { + let rig = Rig::new(); + let listener = UnixListener::bind(rig.path("unverified.sock")).unwrap(); + listener.set_nonblocking(true).unwrap(); + let pid = std::process::id() as i32; + rig.write_meta("unverified", json!({ + "daemonPid": pid, + "daemonStartToken": token, + })); + std::fs::write(rig.path("unverified.pid"), pid.to_string()).unwrap(); + let before = std::fs::read(rig.path("unverified.json")).unwrap(); + let args = if force { + vec!["attach", "--force", "--no-restart", "unverified"] + } else { + vec!["attach", "--no-restart", "unverified"] + }; + let out = rig.run(&args); + assert_eq!(out.code, 1, "{out:?}"); + if token.is_none() { + assert!(out.stderr.contains("identity is unknown"), "{out:?}"); + } + assert_eq!(listener.accept().unwrap_err().kind(), std::io::ErrorKind::WouldBlock); + assert_eq!(std::fs::read(rig.path("unverified.json")).unwrap(), before); + } + } +} + #[test] fn auto_restart_revoked_binding_preserves_retained_artifacts_without_running_child() { for retained_socket in [false, true] {