ace-herdr 0.11.1 → 0.11.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 34cafec7d5b1076108a40ea6d8e13a4cc3080fc2c46dcf7a9d16f99e9ed2f6a0
4
- data.tar.gz: dc53fb2035bf16379f5d5709736dad1db1e623df6d50be0793d8f6fb04b94613
3
+ metadata.gz: 327da1e07e403516ce5ad886cf4ad3954f71a5688d025a1995f584c8dbc1cd76
4
+ data.tar.gz: 4c444095327d49b82f13f5fccb7cfc2e88d87f625e56acd940c6facb3ce9c1a0
5
5
  SHA512:
6
- metadata.gz: 6ca77bbdb7381472639c809b0273fe24378eb94a7e3f95defba964a0b390063aa33ea019a7c1b12ed7021ab8d3bbe04a30a128d6a6fb8f70751880980cecc73b
7
- data.tar.gz: 605c6aebfb87035502223311f8525f4d540d193eed47e99533ddbd79b210dea135235b6d17cad6975e7375bc84dea0abefa8733f58e22e4c4ed1107ad45d52c7
6
+ metadata.gz: 2bbb0087de71da48121519600864ae1c43ad555cfc33095016b71bc1c05e6c3a7a4f5f4533110a025d6f613f42f69c79c68ed1c04a8e02074e947cd8edcafdb3
7
+ data.tar.gz: 5cf72643c36c6bd00e670ecfe9a4ed9039dd87476e755b98759818b1e8fe5d45bf4f2912e4903f63e722c2232338b48f33cb222b39092f18c356c982743a7402
data/CHANGELOG.md CHANGED
@@ -7,6 +7,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.11.2] - 2026-11-12
11
+
12
+ ### Changed
13
+
14
+ - Pin native handoff schema 2 retaining terminal IDs and guarded process handles with close-on-exec descriptors.
15
+
16
+
10
17
  ## [0.11.1] - 2026-11-12
11
18
 
12
19
  ### Fixed
@@ -1,7 +1,7 @@
1
1
  From 7a2e78b4e92d2b694bd8a06c1ef4943915c399b2 Mon Sep 17 00:00:00 2001
2
2
  From: Miguel <mc@cs3b.com>
3
3
  Date: Wed, 7 Oct 2026 00:43:20 +0100
4
- Subject: [PATCH 1/7] feat: guard and permanently drain original-terminal input
4
+ Subject: [PATCH 1/8] feat: guard and permanently drain original-terminal input
5
5
 
6
6
  ---
7
7
  docs/next/api/herdr-api.schema.json | 338 ++++++
@@ -3498,7 +3498,7 @@ index 9171340..a971ef6 100644
3498
3498
  From 5d98e178453958691f17b58d26fd20b51a985cd7 Mon Sep 17 00:00:00 2001
3499
3499
  From: Miguel <mc@cs3b.com>
3500
3500
  Date: Wed, 4 Nov 2026 02:57:12 +0000
3501
- Subject: [PATCH 2/7] fix: validate Pi report acknowledgements before accepting
3501
+ Subject: [PATCH 2/8] fix: validate Pi report acknowledgements before accepting
3502
3502
  delivery
3503
3503
 
3504
3504
  ---
@@ -3647,7 +3647,7 @@ index 673225a..546289d 100644
3647
3647
  From b6d67f318c260b3c0efcbef89bf1034544852776 Mon Sep 17 00:00:00 2001
3648
3648
  From: Miguel <mc@cs3b.com>
3649
3649
  Date: Wed, 4 Nov 2026 04:17:44 +0000
3650
- Subject: [PATCH 3/7] fix: resolve agent queries by immutable terminal identity
3650
+ Subject: [PATCH 3/8] fix: resolve agent queries by immutable terminal identity
3651
3651
 
3652
3652
  ---
3653
3653
  src/app/terminal_targets.rs | 35 +++++++++++++++++++++++++++++++++++
@@ -3713,7 +3713,7 @@ index 658eeaa..003c30c 100644
3713
3713
  From 93e768dcacbfd63171957935ae9e2637a91689e6 Mon Sep 17 00:00:00 2001
3714
3714
  From: Miguel <mc@cs3b.com>
3715
3715
  Date: Tue, 10 Nov 2026 22:59:15 +0000
3716
- Subject: [PATCH 4/7] feat: launch ordinary agents with private explicit
3716
+ Subject: [PATCH 4/8] feat: launch ordinary agents with private explicit
3717
3717
  environments
3718
3718
 
3719
3719
  ---
@@ -4804,7 +4804,7 @@ index 0000000..afe8c3a
4804
4804
  From 83939be994e8b911140adbb9ba64aa4858cce96b Mon Sep 17 00:00:00 2001
4805
4805
  From: Miguel <mc@cs3b.com>
4806
4806
  Date: Tue, 10 Nov 2026 23:06:09 +0000
4807
- Subject: [PATCH 5/7] fix: require fresh environments on cold agent restoration
4807
+ Subject: [PATCH 5/8] fix: require fresh environments on cold agent restoration
4808
4808
 
4809
4809
  ---
4810
4810
  src/app/agents.rs | 3 ++
@@ -4919,7 +4919,7 @@ index b6d6187..66ae240 100644
4919
4919
  From f475b86b1fc0a72103fbdf5cc0aba7dfc54cdcb4 Mon Sep 17 00:00:00 2001
4920
4920
  From: Miguel <mc@cs3b.com>
4921
4921
  Date: Tue, 10 Nov 2026 23:16:46 +0000
4922
- Subject: [PATCH 6/7] fix: clean ordinary session credentials when agents exit
4922
+ Subject: [PATCH 6/8] fix: clean ordinary session credentials when agents exit
4923
4923
 
4924
4924
  ---
4925
4925
  src/persist/restore.rs | 13 +--
@@ -5271,7 +5271,7 @@ index afe8c3a..9dedb83 100644
5271
5271
  From 5176d899a567985490682c37eb7f0198eab159b0 Mon Sep 17 00:00:00 2001
5272
5272
  From: Miguel <mc@cs3b.com>
5273
5273
  Date: Wed, 11 Nov 2026 23:46:22 +0000
5274
- Subject: [PATCH 7/7] fix: preserve child options in explicit environment
5274
+ Subject: [PATCH 7/8] fix: preserve child options in explicit environment
5275
5275
  launcher
5276
5276
 
5277
5277
  ---
@@ -5346,3 +5346,471 @@ index 9dedb83..64d4645 100644
5346
5346
  --
5347
5347
  2.54.0 (Apple Git-157)
5348
5348
 
5349
+
5350
+ From 84753e6b9955fac56a316e1e99d3cbe7219fad9b Mon Sep 17 00:00:00 2001
5351
+ From: Miguel <mc@cs3b.com>
5352
+ Date: Thu, 12 Nov 2026 11:55:39 +0000
5353
+ Subject: [PATCH 8/8] fix: preserve terminal identity and origin handles across
5354
+ live handoff
5355
+
5356
+ ---
5357
+ src/handoff_runtime.rs | 4 ++
5358
+ src/pane.rs | 23 +++++++--
5359
+ src/persist/restore.rs | 16 +++++-
5360
+ src/platform/guarded_prompt.rs | 4 ++
5361
+ src/platform/linux.rs | 2 +-
5362
+ src/platform/linux/guarded_origin.rs | 77 ++++++++++++++++++++++++++++
5363
+ src/platform/mod.rs | 9 ++++
5364
+ src/pty/actor/unix.rs | 4 ++
5365
+ src/server/handoff.rs | 22 +++++++-
5366
+ src/server/headless/bootstrap.rs | 11 +++-
5367
+ src/server/headless/lifecycle.rs | 8 ++-
5368
+ src/terminal/runtime.rs | 8 ++-
5369
+ 12 files changed, 174 insertions(+), 14 deletions(-)
5370
+
5371
+ diff --git a/src/handoff_runtime.rs b/src/handoff_runtime.rs
5372
+ index 12481f6..c85134d 100644
5373
+ --- a/src/handoff_runtime.rs
5374
+ +++ b/src/handoff_runtime.rs
5375
+ @@ -12,6 +12,8 @@ use serde::{Deserialize, Serialize};
5376
+ #[derive(Debug, Clone, Serialize, Deserialize)]
5377
+ pub(crate) struct HandoffRuntimeState {
5378
+ pub pane_id: u32,
5379
+ + pub terminal_id: crate::terminal::TerminalId,
5380
+ + pub guarded_origin: Option<crate::platform::guarded_prompt::GuardedPromptOrigin>,
5381
+ pub child_pid: u32,
5382
+ pub rows: u16,
5383
+ pub cols: u16,
5384
+ @@ -44,5 +46,7 @@ pub(crate) struct ImportedHandoffRuntime {
5385
+ #[cfg(unix)]
5386
+ pub master_fd: std::os::fd::RawFd,
5387
+ #[cfg(unix)]
5388
+ + pub origin_fd: Option<std::os::fd::OwnedFd>,
5389
+ + #[cfg(unix)]
5390
+ pub state: HandoffRuntimeState,
5391
+ }
5392
+ diff --git a/src/pane.rs b/src/pane.rs
5393
+ index eebd3ef..08246c1 100644
5394
+ --- a/src/pane.rs
5395
+ +++ b/src/pane.rs
5396
+ @@ -2148,11 +2148,14 @@ impl PaneRuntime {
5397
+ pub fn handoff_runtime_state(
5398
+ &self,
5399
+ pane_id: u32,
5400
+ + terminal_id: crate::terminal::TerminalId,
5401
+ ) -> crate::handoff_runtime::HandoffRuntimeState {
5402
+ let child_pid = self.child_pid.load(Ordering::Acquire);
5403
+ let (rows, cols, cell_width_px, cell_height_px) = self.current_size.get();
5404
+ crate::handoff_runtime::HandoffRuntimeState {
5405
+ pane_id,
5406
+ + guarded_origin: self.captured_input_origin(terminal_id.as_str()),
5407
+ + terminal_id,
5408
+ child_pid,
5409
+ rows,
5410
+ cols,
5411
+ @@ -2364,9 +2367,11 @@ impl PaneRuntime {
5412
+ render_notify: Arc<Notify>,
5413
+ render_dirty: Arc<RenderSignal>,
5414
+ ) -> std::io::Result<Self> {
5415
+ - let crate::handoff_runtime::ImportedHandoffRuntime { master_fd, state } = import;
5416
+ + let crate::handoff_runtime::ImportedHandoffRuntime { master_fd, origin_fd, state } = import;
5417
+ let crate::handoff_runtime::HandoffRuntimeState {
5418
+ pane_id,
5419
+ + guarded_origin,
5420
+ + terminal_id,
5421
+ child_pid,
5422
+ rows,
5423
+ cols,
5424
+ @@ -2381,8 +2386,9 @@ impl PaneRuntime {
5425
+ } = state;
5426
+ let pane_id = PaneId::from_raw(pane_id);
5427
+ use std::os::fd::FromRawFd;
5428
+ -
5429
+ let master_fd = unsafe { std::os::fd::OwnedFd::from_raw_fd(master_fd) };
5430
+ + let origin = crate::platform::import_guarded_child(guarded_origin, origin_fd)?;
5431
+ + let _ = terminal_id;
5432
+
5433
+ let (response_tx, _response_rx) = mpsc::channel::<Bytes>(1);
5434
+ let mut terminal = crate::ghostty::Terminal::new(cols, rows, scrollback_limit_bytes)
5435
+ @@ -2494,7 +2500,7 @@ impl PaneRuntime {
5436
+ });
5437
+ PaneRuntimeIo::Actor(PtyIoActor::spawn(PtyIoActorConfig {
5438
+ pane_id: pane_id.raw(),
5439
+ - origin: None,
5440
+ + origin,
5441
+ master_fd,
5442
+ initially_quiesced: true,
5443
+ on_read,
5444
+ @@ -3531,6 +3537,15 @@ impl PaneRuntime {
5445
+ self.io.try_send_bytes(bytes)
5446
+ }
5447
+
5448
+ + #[cfg(unix)]
5449
+ + pub(crate) fn duplicate_guarded_origin_fd(&self) -> std::io::Result<Option<std::os::fd::OwnedFd>> {
5450
+ + match &self.io {
5451
+ + PaneRuntimeIo::Actor(actor) => actor.duplicate_guarded_origin_fd(),
5452
+ + #[cfg(test)]
5453
+ + PaneRuntimeIo::TestChannel { .. } => Ok(None),
5454
+ + }
5455
+ + }
5456
+ +
5457
+ pub(crate) fn guarded_prompt_origin(
5458
+ &self,
5459
+ terminal_id: &str,
5460
+ @@ -4962,7 +4977,7 @@ mod tests {
5461
+ runtime.test_process_pty_bytes("\x1b]2;✳ 修复🙂标题\x1b\\".as_bytes());
5462
+ runtime.terminal.clear_agent_osc_state();
5463
+ assert_eq!(runtime.agent_osc_title(), "");
5464
+ - let pane = runtime.handoff_runtime_state(12);
5465
+ + let pane = runtime.handoff_runtime_state(12, crate::terminal::TerminalId::alloc());
5466
+
5467
+ assert_eq!(pane.keyboard_protocol_flags, 5);
5468
+ assert_eq!(pane.terminal_title.as_deref(), Some("✳ 修复🙂标题"));
5469
+ diff --git a/src/persist/restore.rs b/src/persist/restore.rs
5470
+ index 3b0ca57..1374ff6 100644
5471
+ --- a/src/persist/restore.rs
5472
+ +++ b/src/persist/restore.rs
5473
+ @@ -581,6 +581,10 @@ fn restore_tab(
5474
+ let imported_runtime = old_pane_id.and_then(|old_id| imported_panes.remove(&old_id));
5475
+ let was_imported = imported_runtime.is_some();
5476
+ #[cfg(unix)]
5477
+ + let imported_terminal_id = imported_runtime.as_ref().map(|imported| imported.state.terminal_id.clone());
5478
+ + #[cfg(not(unix))]
5479
+ + let imported_terminal_id: Option<TerminalId> = None;
5480
+ + #[cfg(unix)]
5481
+ let handoff_agent_state = imported_runtime
5482
+ .as_ref()
5483
+ .and_then(|imported| imported.state.agent_state.clone());
5484
+ @@ -637,6 +641,7 @@ fn restore_tab(
5485
+ TerminalRuntime::from_handoff_fd(
5486
+ crate::handoff_runtime::ImportedHandoffRuntime {
5487
+ master_fd: imported.master_fd,
5488
+ + origin_fd: imported.origin_fd,
5489
+ state: imported.state.with_pane_id(*id),
5490
+ },
5491
+ runtime_context.scrollback_limit_bytes,
5492
+ @@ -686,7 +691,7 @@ fn restore_tab(
5493
+
5494
+ match runtime_result {
5495
+ Ok(runtime) => {
5496
+ - let terminal_id = TerminalId::alloc();
5497
+ + let terminal_id = imported_terminal_id.unwrap_or_else(TerminalId::alloc);
5498
+ let mut terminal = TerminalState::new(terminal_id.clone(), cwd.clone());
5499
+ if was_imported {
5500
+ if let Some(argv) = saved_launch_argv {
5501
+ @@ -1957,13 +1962,15 @@ mod tests {
5502
+ runtime
5503
+ .pause_handoff_reader(std::time::Duration::from_secs(2))
5504
+ .unwrap();
5505
+ - let mut state = runtime.handoff_runtime_state(pane_id.raw());
5506
+ + let original_terminal_id = terminals.keys().next().unwrap().clone();
5507
+ + let mut state = runtime.handoff_runtime_state(pane_id.raw(), original_terminal_id.clone());
5508
+ state.agent_state = terminals.values().next().unwrap().handoff_agent_state();
5509
+ let state = serde_json::from_value(serde_json::to_value(state).unwrap()).unwrap();
5510
+ let mut imports = HashMap::from([(
5511
+ pane_id.raw(),
5512
+ crate::handoff_runtime::ImportedHandoffRuntime {
5513
+ master_fd: runtime.duplicate_handoff_fd().unwrap(),
5514
+ + origin_fd: runtime.duplicate_guarded_origin_fd().unwrap(),
5515
+ state,
5516
+ },
5517
+ )]);
5518
+ @@ -1978,6 +1985,11 @@ mod tests {
5519
+ Arc::new(RenderSignal::new()),
5520
+ )
5521
+ .unwrap();
5522
+ + assert!(restored_terminals.contains_key(&original_terminal_id));
5523
+ + assert_ne!(TerminalId::alloc(), original_terminal_id);
5524
+ + let restored_runtime = restored_runtimes.get(&original_terminal_id).unwrap();
5525
+ + assert_eq!(restored_runtime.guarded_prompt_origin(original_terminal_id.as_str()),
5526
+ + runtime.guarded_prompt_origin(original_terminal_id.as_str()));
5527
+ drop(restored_runtimes);
5528
+ drop(runtimes);
5529
+ let terminal = restored_terminals.values_mut().next().unwrap();
5530
+ diff --git a/src/platform/guarded_prompt.rs b/src/platform/guarded_prompt.rs
5531
+ index e35245d..ca14377 100644
5532
+ --- a/src/platform/guarded_prompt.rs
5533
+ +++ b/src/platform/guarded_prompt.rs
5534
+ @@ -103,6 +103,10 @@ impl GuardedPromptError {
5535
+
5536
+ pub(crate) trait OriginLiveness: Send + Sync {
5537
+ fn is_live(&self) -> std::io::Result<bool>;
5538
+ + #[cfg(unix)]
5539
+ + fn duplicate_fd(&self) -> std::io::Result<std::os::fd::OwnedFd> {
5540
+ + Err(std::io::Error::other("origin handle cannot be transferred"))
5541
+ + }
5542
+ }
5543
+
5544
+ pub(crate) struct GuardedChildOrigin {
5545
+ diff --git a/src/platform/linux.rs b/src/platform/linux.rs
5546
+ index 9edf05b..2298846 100644
5547
+ --- a/src/platform/linux.rs
5548
+ +++ b/src/platform/linux.rs
5549
+ @@ -27,7 +27,7 @@ pub(crate) use super::unix_common::{
5550
+ mod config_file_tests;
5551
+
5552
+ mod guarded_origin;
5553
+ -pub(crate) use guarded_origin::capture_guarded_child;
5554
+ +pub(crate) use guarded_origin::{capture_guarded_child, import_guarded_child};
5555
+
5556
+ mod shutdown;
5557
+ pub(crate) use shutdown::monitor_host_shutdown;
5558
+ diff --git a/src/platform/linux/guarded_origin.rs b/src/platform/linux/guarded_origin.rs
5559
+ index 2a44e8f..ffa6b49 100644
5560
+ --- a/src/platform/linux/guarded_origin.rs
5561
+ +++ b/src/platform/linux/guarded_origin.rs
5562
+ @@ -10,6 +10,7 @@ use std::{
5563
+
5564
+ struct LinuxChildHandle(OwnedFd);
5565
+ impl OriginLiveness for LinuxChildHandle {
5566
+ + fn duplicate_fd(&self) -> io::Result<OwnedFd> { self.0.try_clone() }
5567
+ fn is_live(&self) -> io::Result<bool> {
5568
+ let mut item = libc::pollfd {
5569
+ fd: self.0.as_raw_fd(),
5570
+ @@ -85,3 +86,79 @@ pub(crate) fn capture_guarded_child(pid: u32) -> io::Result<Arc<GuardedChildOrig
5571
+ handle: Arc::new(handle),
5572
+ }))
5573
+ }
5574
+ +
5575
+ +// Import the original kernel handle, never reopen a PID from a snapshot.
5576
+ +pub(crate) fn import_guarded_child(
5577
+ + expected: Option<crate::platform::guarded_prompt::GuardedPromptOrigin>,
5578
+ + fd: Option<OwnedFd>,
5579
+ +) -> io::Result<Option<Arc<GuardedChildOrigin>>> {
5580
+ + match (expected, fd) {
5581
+ + (None, None) => Ok(None),
5582
+ + (Some(expected), Some(fd)) => {
5583
+ + if !expected.valid() { return Err(io::Error::other("invalid handoff origin")); }
5584
+ + if unsafe { libc::fcntl(fd.as_raw_fd(), libc::F_SETFD, libc::FD_CLOEXEC) } == -1 {
5585
+ + return Err(io::Error::last_os_error());
5586
+ + }
5587
+ + let handle = LinuxChildHandle(fd);
5588
+ + let live = handle.is_live()?;
5589
+ + let same_process = if live {
5590
+ + let mut current = identity(expected.child.pid)?;
5591
+ + // Handoff reparents the surviving child when the old server exits.
5592
+ + // The original parent remains provenance, not a liveness condition.
5593
+ + current.parent_pid = expected.child.parent_pid;
5594
+ + current == expected.child
5595
+ + } else { true };
5596
+ + if !same_process {
5597
+ + return Err(io::Error::other("handoff child identity mismatch"));
5598
+ + }
5599
+ + Ok(Some(Arc::new(GuardedChildOrigin {
5600
+ + child: expected.child,
5601
+ + runtime_incarnation: expected.runtime_incarnation,
5602
+ + exited: AtomicBool::new(!live),
5603
+ + handle: Arc::new(handle),
5604
+ + })))
5605
+ + }
5606
+ + _ => Err(io::Error::other("handoff origin descriptor mismatch")),
5607
+ + }
5608
+ +}
5609
+ +
5610
+ +#[cfg(test)]
5611
+ +mod tests {
5612
+ + use super::*;
5613
+ + use std::process::Command;
5614
+ +
5615
+ + #[test]
5616
+ + fn handoff_transfers_original_handle_and_survives_parent_change() {
5617
+ + let mut child = Command::new("sleep").arg("30").spawn().unwrap();
5618
+ + let origin = capture_guarded_child(child.id()).unwrap();
5619
+ + let mut expected = origin.projection("term_ab12");
5620
+ + // A previous server was the spawn parent; reparenting must not rewrite
5621
+ + // that provenance or invalidate the still-live original pidfd.
5622
+ + expected.child.parent_pid = expected.child.parent_pid.saturating_add(1);
5623
+ + let fd = origin.handle.duplicate_fd().unwrap();
5624
+ + let imported_fd = fd.as_raw_fd();
5625
+ + assert_eq!(unsafe { libc::fcntl(imported_fd, libc::F_SETFD, 0) }, 0);
5626
+ + let imported = import_guarded_child(Some(expected.clone()), Some(fd)).unwrap().unwrap();
5627
+ + assert_ne!(unsafe { libc::fcntl(imported_fd, libc::F_GETFD) } & libc::FD_CLOEXEC, 0);
5628
+ + assert_eq!(imported.projection("term_ab12"), expected);
5629
+ + assert!(imported.check(&expected).is_ok());
5630
+ + let fd = imported.handle.duplicate_fd().unwrap();
5631
+ + assert_ne!(unsafe { libc::fcntl(fd.as_raw_fd(), libc::F_GETFD) } & libc::FD_CLOEXEC, 0);
5632
+ + let again = import_guarded_child(Some(expected.clone()), Some(fd)).unwrap().unwrap();
5633
+ + child.kill().unwrap();
5634
+ + child.wait().unwrap();
5635
+ + assert!(again.check(&expected).is_err());
5636
+ + }
5637
+ +
5638
+ + #[test]
5639
+ + fn handoff_rejects_changed_process_birth() {
5640
+ + let mut child = Command::new("sleep").arg("30").spawn().unwrap();
5641
+ + let origin = capture_guarded_child(child.id()).unwrap();
5642
+ + let mut expected = origin.projection("term_ab12");
5643
+ + expected.child.started_at.push('1');
5644
+ + let result = import_guarded_child(Some(expected), Some(origin.handle.duplicate_fd().unwrap()));
5645
+ + child.kill().unwrap();
5646
+ + child.wait().unwrap();
5647
+ + assert!(result.is_err());
5648
+ + }
5649
+ +}
5650
+ diff --git a/src/platform/mod.rs b/src/platform/mod.rs
5651
+ index 2470ff5..43faa74 100644
5652
+ --- a/src/platform/mod.rs
5653
+ +++ b/src/platform/mod.rs
5654
+ @@ -740,3 +740,12 @@ pub(crate) fn capture_guarded_child(
5655
+ "guarded child origin requires Linux",
5656
+ ))
5657
+ }
5658
+ +
5659
+ +#[cfg(all(unix, not(target_os = "linux")))]
5660
+ +pub(crate) fn import_guarded_child(
5661
+ + origin: Option<guarded_prompt::GuardedPromptOrigin>,
5662
+ + fd: Option<std::os::fd::OwnedFd>,
5663
+ +) -> std::io::Result<Option<std::sync::Arc<guarded_prompt::GuardedChildOrigin>>> {
5664
+ + if origin.is_some() || fd.is_some() { return Err(std::io::Error::other("origin import requires Linux")); }
5665
+ + Ok(None)
5666
+ +}
5667
+ diff --git a/src/pty/actor/unix.rs b/src/pty/actor/unix.rs
5668
+ index 7740265..cbe4203 100644
5669
+ --- a/src/pty/actor/unix.rs
5670
+ +++ b/src/pty/actor/unix.rs
5671
+ @@ -260,6 +260,10 @@ impl PtyIoActorHandle {
5672
+ Ok(result)
5673
+ }
5674
+
5675
+ + pub(crate) fn duplicate_guarded_origin_fd(&self) -> std::io::Result<Option<std::os::fd::OwnedFd>> {
5676
+ + self.origin.as_ref().map(|origin| origin.handle.duplicate_fd()).transpose()
5677
+ + }
5678
+ +
5679
+ pub(crate) fn guarded_origin(&self, terminal_id: &str) -> Option<GuardedPromptOrigin> {
5680
+ let origin = self.origin.as_ref()?;
5681
+ let expected = origin.projection(terminal_id);
5682
+ diff --git a/src/server/handoff.rs b/src/server/handoff.rs
5683
+ index aaa87f3..490fd63 100644
5684
+ --- a/src/server/handoff.rs
5685
+ +++ b/src/server/handoff.rs
5686
+ @@ -17,7 +17,7 @@ use serde::{Deserialize, Serialize};
5687
+ use tracing::{info, warn};
5688
+
5689
+ #[cfg(unix)]
5690
+ -const HANDOFF_VERSION: u32 = 1;
5691
+ +const HANDOFF_VERSION: u32 = 2;
5692
+ #[cfg(unix)]
5693
+ const READY_TIMEOUT: Duration = Duration::from_secs(30);
5694
+ #[cfg(unix)]
5695
+ @@ -262,9 +262,18 @@ pub(crate) fn receive(socket_path: &Path, token: &str) -> io::Result<ReceivedHan
5696
+ crate::build_info::version()
5697
+ )));
5698
+ }
5699
+ + let mut terminals = std::collections::HashSet::new();
5700
+ + let mut panes = std::collections::HashSet::new();
5701
+ + for pane in &manifest.panes {
5702
+ + if !terminals.insert(&pane.terminal_id) || !panes.insert(pane.pane_id) ||
5703
+ + pane.guarded_origin.as_ref().is_some_and(|origin| !origin.valid() || origin.terminal_id != pane.terminal_id.as_str() || origin.child.pid != pane.child_pid) {
5704
+ + return Err(io::Error::other("invalid or duplicate handoff terminal identity"));
5705
+ + }
5706
+ + }
5707
+ stream.write_all(b"validated\n")?;
5708
+ stream.flush()?;
5709
+ - let fds = recv_fds(&stream, manifest.panes.len())?;
5710
+ + let count = manifest.panes.iter().map(|pane| 1 + usize::from(pane.guarded_origin.is_some())).sum();
5711
+ + let fds = recv_fds(&stream, count)?;
5712
+ Ok(ReceivedHandoff {
5713
+ manifest,
5714
+ fds,
5715
+ @@ -517,6 +526,15 @@ fn recv_fd_batch(stream: &UnixStream, wanted: usize) -> io::Result<Vec<RawFd>> {
5716
+ if out.is_empty() {
5717
+ return Err(io::Error::other("handoff fd message missing SCM_RIGHTS"));
5718
+ }
5719
+ + // SCM_RIGHTS does not preserve close-on-exec. Protect every received
5720
+ + // handle before restoration can spawn any replacement shell.
5721
+ + for fd in &out {
5722
+ + if unsafe { libc::fcntl(*fd, libc::F_SETFD, libc::FD_CLOEXEC) } == -1 {
5723
+ + let error = io::Error::last_os_error();
5724
+ + close_raw_fds(&out);
5725
+ + return Err(error);
5726
+ + }
5727
+ + }
5728
+ Ok(out)
5729
+ }
5730
+
5731
+ diff --git a/src/server/headless/bootstrap.rs b/src/server/headless/bootstrap.rs
5732
+ index e5ff400..183636f 100644
5733
+ --- a/src/server/headless/bootstrap.rs
5734
+ +++ b/src/server/headless/bootstrap.rs
5735
+ @@ -136,12 +136,19 @@ fn run_handoff_import_server(socket_path: &Path, token: &str) -> io::Result<()>
5736
+ let should_quit = Arc::new(AtomicBool::new(false));
5737
+
5738
+ let mut imports = HashMap::new();
5739
+ - for (pane, fd) in received.manifest.panes.into_iter().zip(received.fds) {
5740
+ + use std::os::fd::{FromRawFd, IntoRawFd, OwnedFd};
5741
+ + let mut fds = received.fds.into_iter().map(|fd| unsafe { OwnedFd::from_raw_fd(fd) });
5742
+ + for pane in received.manifest.panes {
5743
+ + let fd = fds.next().ok_or_else(|| io::Error::other("missing handoff PTY"))?;
5744
+ + let origin_fd = if pane.guarded_origin.is_some() {
5745
+ + Some(fds.next().ok_or_else(|| io::Error::other("missing handoff origin"))?)
5746
+ + } else { None };
5747
+ let pane_id = pane.pane_id;
5748
+ imports.insert(
5749
+ pane_id,
5750
+ crate::handoff_runtime::ImportedHandoffRuntime {
5751
+ - master_fd: fd,
5752
+ + master_fd: fd.into_raw_fd(),
5753
+ + origin_fd,
5754
+ state: pane,
5755
+ },
5756
+ );
5757
+ diff --git a/src/server/headless/lifecycle.rs b/src/server/headless/lifecycle.rs
5758
+ index c700a9d..1a04ef9 100644
5759
+ --- a/src/server/headless/lifecycle.rs
5760
+ +++ b/src/server/headless/lifecycle.rs
5761
+ @@ -82,7 +82,7 @@ impl HeadlessServer {
5762
+ let Some(pane_id) = pane_by_terminal.get(terminal_id).copied() else {
5763
+ continue;
5764
+ };
5765
+ - let mut handoff_runtime = runtime.handoff_runtime_state(pane_id);
5766
+ + let mut handoff_runtime = runtime.handoff_runtime_state(pane_id, terminal_id.clone());
5767
+ handoff_runtime.agent_state = self
5768
+ .app
5769
+ .state
5770
+ @@ -128,11 +128,15 @@ impl HeadlessServer {
5771
+
5772
+ let mut fds = Vec::new();
5773
+ let duplicate_result = (|| {
5774
+ - for (terminal_id, _) in &handoff_entries {
5775
+ + for (terminal_id, state) in &handoff_entries {
5776
+ let Some(runtime) = self.app.terminal_runtimes.get(terminal_id) else {
5777
+ continue;
5778
+ };
5779
+ fds.push(runtime.duplicate_handoff_fd()?);
5780
+ + if state.guarded_origin.is_some() {
5781
+ + use std::os::fd::IntoRawFd;
5782
+ + fds.push(runtime.duplicate_guarded_origin_fd()?.ok_or_else(|| io::Error::other("handoff origin disappeared"))?.into_raw_fd());
5783
+ + }
5784
+ }
5785
+ Ok::<(), io::Error>(())
5786
+ })();
5787
+ diff --git a/src/terminal/runtime.rs b/src/terminal/runtime.rs
5788
+ index a971ef6..f80a69b 100644
5789
+ --- a/src/terminal/runtime.rs
5790
+ +++ b/src/terminal/runtime.rs
5791
+ @@ -50,8 +50,9 @@ impl TerminalRuntime {
5792
+ pub fn handoff_runtime_state(
5793
+ &self,
5794
+ pane_id: u32,
5795
+ + terminal_id: crate::terminal::TerminalId,
5796
+ ) -> crate::handoff_runtime::HandoffRuntimeState {
5797
+ - self.0.handoff_runtime_state(pane_id)
5798
+ + self.0.handoff_runtime_state(pane_id, terminal_id)
5799
+ }
5800
+
5801
+ #[cfg(unix)]
5802
+ @@ -478,6 +479,11 @@ impl TerminalRuntime {
5803
+ self.0.try_send_bytes(bytes)
5804
+ }
5805
+
5806
+ + #[cfg(unix)]
5807
+ + pub(crate) fn duplicate_guarded_origin_fd(&self) -> std::io::Result<Option<std::os::fd::OwnedFd>> {
5808
+ + self.0.duplicate_guarded_origin_fd()
5809
+ + }
5810
+ +
5811
+ pub(crate) fn guarded_prompt_origin(
5812
+ &self,
5813
+ terminal_id: &str,
5814
+ --
5815
+ 2.54.0 (Apple Git-157)
5816
+
@@ -2,10 +2,10 @@
2
2
  "schema": "ace.herdr.native-source/v1",
3
3
  "repository": "https://github.com/herdrdev/herdr.git",
4
4
  "baseline_commit": "7b116c05bfda646af39d2524c54e70c751f57ee8",
5
- "source_commit": "5176d899a567985490682c37eb7f0198eab159b0",
6
- "source_tree": "1cf8206e2d7b3a559d0bb3a036fa54d7228e7c99",
5
+ "source_commit": "84753e6b9955fac56a316e1e99d3cbe7219fad9b",
6
+ "source_tree": "e948aca926f1511806e03bbea4933c32630122b8",
7
7
  "patch_file": "guarded-prompt.patch",
8
- "patch_sha256": "5aebc2fe2b61b80f96cc5fb5ef63fe41ee9b69c8b5921ce0fbf3cb4c10ae98a6",
8
+ "patch_sha256": "c0cd34f66e90fe98a40a08b127b39172182a03a813e8553fcc7961edc6326936",
9
9
  "cargo_lock_sha256": "bb3ab17351af496a2cc316a11c81a97f90959cd37c3d5625f2f8028c0133ffca",
10
10
  "rust_toolchain_sha256": "0d9298c075817aba62477d23dcda3534cd836b5d5cf1960bd2de54df675ceffd",
11
11
  "rust_version": "1.96.1",
@@ -15,7 +15,7 @@
15
15
  "committer": {
16
16
  "name": "Miguel",
17
17
  "email": "mc@cs3b.com",
18
- "date": "2026-11-11T23:46:22+00:00"
18
+ "date": "2026-11-12T11:55:39+00:00"
19
19
  },
20
20
  "targets": [
21
21
  "x86_64-unknown-linux-musl",
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Ace
4
4
  module Herdr
5
- VERSION = "0.11.1"
5
+ VERSION = "0.11.2"
6
6
  end
7
7
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: ace-herdr
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.11.1
4
+ version: 0.11.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - Michal Czyz