Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
4 changes: 2 additions & 2 deletions crates/pty-core/src/registry/list.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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<i32> {
read_explicit_control_target_in(&session_dir(), name, metadata, true)
Expand Down
9 changes: 8 additions & 1 deletion crates/pty/src/cli/attach.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Expand Down
79 changes: 75 additions & 4 deletions crates/pty/tests/cli_attach_guard.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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] {
Expand Down
Loading