@team-agent/installer 0.5.114 → 0.5.115

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.
@@ -1,9 +1,140 @@
1
- //! The sole new-runtime provider registration. This is not an admission claim:
1
+ //! The sole new-runtime provider registry. This is not an admission claim:
2
2
  //! the descriptor's Unverified gates still apply to each requested operation.
3
- use team_agent_contract::contract::descriptor::ProviderDescriptor;
4
- use team_agent_contract::kiro::KIRO_DESCRIPTOR;
3
+ use std::collections::BTreeMap;
4
+ use std::path::PathBuf;
5
+ use team_agent_contract::contract::{
6
+ descriptor::{PromptCarrier, ProviderDescriptor, ToolNaming},
7
+ hooks::{PlanHook, ProviderHooks},
8
+ plan::{LaunchPlan, ResolvedLaunch},
9
+ types::{ContractError, Support},
10
+ };
11
+ use team_agent_contract::kiro::{self, KiroAdapter, KIRO_DESCRIPTOR};
12
+ use team_agent_contract::providers::cursor::{self, CursorAdapter, CURSOR_DESCRIPTOR};
13
+ use team_agent_contract::runtime::{delivery::ControlDefinition, native_panel::NativePanelPolicy};
5
14
 
6
- const DESCRIPTORS: &[&ProviderDescriptor] = &[&KIRO_DESCRIPTOR];
15
+ const DESCRIPTORS: &[&ProviderDescriptor] = &[&KIRO_DESCRIPTOR, &CURSOR_DESCRIPTOR];
16
+
17
+ /// Leaf selection stays here; the framework, lifecycle and outbox remain shared.
18
+ #[derive(Clone, Copy, Debug, PartialEq, Eq)]
19
+ pub(super) enum NativeProvider {
20
+ Kiro,
21
+ Cursor,
22
+ }
23
+
24
+ impl NativeProvider {
25
+ pub fn descriptor(self) -> &'static ProviderDescriptor {
26
+ match self {
27
+ Self::Kiro => &KIRO_DESCRIPTOR,
28
+ Self::Cursor => &CURSOR_DESCRIPTOR,
29
+ }
30
+ }
31
+
32
+ pub fn action_budget(self) -> std::time::Duration {
33
+ // Kiro's existing budget is unchanged; Cursor's measured cold-ready
34
+ // path needs a larger finite bound, not an unbounded readiness wait.
35
+ std::time::Duration::from_secs(match self { Self::Kiro => 30, Self::Cursor => 90 })
36
+ }
37
+
38
+ pub fn controls(self) -> &'static [ControlDefinition] {
39
+ match self {
40
+ Self::Kiro => kiro::interaction::CONTROLS,
41
+ Self::Cursor => cursor::interaction::CONTROLS,
42
+ }
43
+ }
44
+
45
+ /// A terminal instruction bridge needs one durable, authorized first packet
46
+ /// before the native client can demonstrate its Team-tool registry.
47
+ pub fn first_business_bootstrap(self) -> bool {
48
+ matches!(self.descriptor().prompt.carrier, Support::Supported(PromptCarrier::RuntimeBridge))
49
+ }
50
+
51
+ /// Cursor has no measured neutral TUI tools panel. Its protocol binding
52
+ /// must come from the separately fenced native tool-roundtrip ledger.
53
+ pub fn uses_tool_roundtrips(self) -> bool {
54
+ matches!(self, Self::Cursor)
55
+ }
56
+
57
+ pub fn tool_panel(self) -> Result<&'static NativePanelPolicy, ContractError> {
58
+ match self {
59
+ Self::Kiro => Ok(&kiro::interaction::TOOL_PANEL),
60
+ Self::Cursor => Err(ContractError::Unverified {
61
+ field: "Cursor native Team-tool inspector",
62
+ reason: cursor::UNVERIFIED,
63
+ }),
64
+ }
65
+ }
66
+
67
+ pub fn server_key(self) -> Result<&'static str, ContractError> {
68
+ match self.descriptor().tool_names.naming.require("tool naming")? {
69
+ ToolNaming::StaticServer { key } => Ok(key),
70
+ ToolNaming::RuntimeBound => Err(ContractError::Invalid("runtime server-key receipt required")),
71
+ }
72
+ }
73
+
74
+ pub fn adapter(
75
+ self,
76
+ executable: PathBuf,
77
+ arguments: Vec<String>,
78
+ environment: BTreeMap<String, String>,
79
+ ) -> Result<NativeAdapter, ContractError> {
80
+ match self {
81
+ Self::Kiro => KiroAdapter::new(kiro::McpStdio {
82
+ executable, arguments, environment,
83
+ }).map(NativeAdapter::Kiro),
84
+ Self::Cursor => CursorAdapter::new(cursor::McpStdio {
85
+ executable, arguments, environment,
86
+ }).map(NativeAdapter::Cursor),
87
+ }
88
+ }
89
+ }
90
+
91
+ pub(super) enum NativeAdapter {
92
+ Kiro(KiroAdapter),
93
+ Cursor(CursorAdapter),
94
+ }
95
+
96
+ impl NativeAdapter {
97
+ pub fn with_launch_context(
98
+ self,
99
+ project: PathBuf,
100
+ runtime: Option<cursor::native::RuntimeInstallation>,
101
+ ) -> Result<Self, ContractError> {
102
+ match self {
103
+ Self::Kiro(adapter) if runtime.is_none() => Ok(Self::Kiro(adapter)),
104
+ Self::Kiro(_) => Err(ContractError::Mismatch("native runtime provider")),
105
+ Self::Cursor(adapter) => {
106
+ let adapter = adapter.with_project(project)?;
107
+ Ok(Self::Cursor(match runtime {
108
+ Some(runtime) => adapter.with_runtime(runtime),
109
+ None => adapter,
110
+ }))
111
+ }
112
+ }
113
+ }
114
+
115
+ pub fn hooks(&self) -> ProviderHooks<'_> {
116
+ match self {
117
+ Self::Kiro(adapter) => adapter.hooks(),
118
+ Self::Cursor(adapter) => adapter.hooks(),
119
+ }
120
+ }
121
+
122
+ pub fn plan(&self, request: &ResolvedLaunch) -> Result<LaunchPlan, ContractError> {
123
+ match self {
124
+ Self::Kiro(adapter) => adapter.plan(request),
125
+ Self::Cursor(adapter) => adapter.plan(request),
126
+ }
127
+ }
128
+ }
129
+
130
+ pub(super) fn provider(key: &str) -> Result<NativeProvider, ContractError> {
131
+ let descriptor = descriptor(key).ok_or(ContractError::Invalid("unregistered contract provider"))?;
132
+ match descriptor.identity.id {
133
+ "kiro" => Ok(NativeProvider::Kiro),
134
+ "cursor" => Ok(NativeProvider::Cursor),
135
+ _ => Err(ContractError::Invalid("contract provider dispatcher")),
136
+ }
137
+ }
7
138
 
8
139
  pub(crate) fn descriptors() -> &'static [&'static ProviderDescriptor] {
9
140
  DESCRIPTORS
@@ -28,3 +159,39 @@ pub fn recognizes(key: &str) -> bool {
28
159
  .any(|alias| alias.eq_ignore_ascii_case(key))
29
160
  })
30
161
  }
162
+
163
+ #[cfg(test)]
164
+ mod tests {
165
+ use super::*;
166
+
167
+ #[test]
168
+ fn canonical_cursor_does_not_reclassify_legacy_names() {
169
+ assert_eq!(provider("cursor").unwrap(), NativeProvider::Cursor);
170
+ assert_eq!(provider("kiro").unwrap(), NativeProvider::Kiro);
171
+ for legacy in ["cursor_agent", "agent", "pi", "codex"] {
172
+ assert!(descriptor(legacy).is_none());
173
+ assert!(!recognizes(legacy));
174
+ assert!(provider(legacy).is_err());
175
+ }
176
+ assert!(recognizes(" Cursor "));
177
+ assert!(descriptor("Cursor").is_none());
178
+ for descriptor in descriptors() {
179
+ assert_eq!(provider(descriptor.identity.id).unwrap().descriptor(), *descriptor);
180
+ }
181
+ }
182
+
183
+ #[test]
184
+ fn cursor_has_no_kiro_control_or_tool_inspector_fallback() {
185
+ assert!(NativeProvider::Cursor.controls().is_empty());
186
+ assert!(matches!(NativeProvider::Cursor.tool_panel(), Err(ContractError::Unverified { .. })));
187
+ assert_eq!(NativeProvider::Cursor.server_key().unwrap(), "team");
188
+ assert!(NativeProvider::Cursor.first_business_bootstrap());
189
+ assert!(NativeProvider::Cursor.uses_tool_roundtrips());
190
+ assert!(!NativeProvider::Kiro.first_business_bootstrap());
191
+ assert!(!NativeProvider::Kiro.uses_tool_roundtrips());
192
+ let kiro_panel = NativeProvider::Kiro.tool_panel().unwrap();
193
+ assert_eq!(kiro_panel.server_key, NativeProvider::Kiro.server_key().unwrap());
194
+ assert_eq!(kiro_panel.policy_sha256, kiro::interaction::POLICY);
195
+ assert!(!NativeProvider::Kiro.controls().is_empty());
196
+ }
197
+ }
@@ -0,0 +1,160 @@
1
+ //! Stable per-seat primary cwd. The project stays an additional directory;
2
+ //! private MCP files never overwrite a shared project or another seat's config.
3
+ use std::io::{Read, Write};
4
+ use std::os::unix::fs::{DirBuilderExt, MetadataExt, OpenOptionsExt};
5
+ use std::path::Path;
6
+
7
+ use serde::{Deserialize, Serialize};
8
+ use team_agent_contract::contract::{session::CwdIdentity, types::SeatId};
9
+ use team_agent_contract::host::process::resolve_cwd;
10
+
11
+ use super::backend::{root_for, BackendError, Binding};
12
+
13
+ #[derive(Serialize, Deserialize)]
14
+ #[serde(deny_unknown_fields)]
15
+ struct CwdRecord {
16
+ version: u32,
17
+ seat: SeatId,
18
+ cwd: CwdIdentity,
19
+ }
20
+
21
+ /// Requires an already owned framework root. Existing unrecorded directories
22
+ /// are refused, not adopted. A replaced inode or any symlink is a hard error.
23
+ pub(super) fn prepare(binding: &Binding, seat: &SeatId) -> Result<CwdIdentity, BackendError> {
24
+ let root = root_for(&binding.workspace.path, &binding.scope);
25
+ validate_directory(&root)?;
26
+ let parent = root.join("workspaces");
27
+ let path = parent.join(seat.as_str());
28
+ let record_path = root.join(format!("cwd-{}.json", seat.as_str()));
29
+ match std::fs::symlink_metadata(&record_path) {
30
+ Ok(metadata) => {
31
+ if !metadata.is_file() || metadata.file_type().is_symlink() {
32
+ return Err(BackendError::Metadata);
33
+ }
34
+ let mut file = std::fs::OpenOptions::new().read(true)
35
+ .custom_flags(libc::O_NOFOLLOW | libc::O_CLOEXEC).open(&record_path)?;
36
+ let opened = file.metadata()?;
37
+ if opened.dev() != metadata.dev() || opened.ino() != metadata.ino() {
38
+ return Err(BackendError::Metadata);
39
+ }
40
+ let mut bytes = Vec::new();
41
+ Read::by_ref(&mut file).take(65537).read_to_end(&mut bytes)?;
42
+ if bytes.len() > 65536 {
43
+ return Err(BackendError::Metadata);
44
+ }
45
+ let record: CwdRecord = serde_json::from_slice(&bytes).map_err(|_| BackendError::Metadata)?;
46
+ if record.version != 1 || record.seat != *seat || record.cwd.path != path {
47
+ return Err(BackendError::Metadata);
48
+ }
49
+ validate_directory(&parent)?;
50
+ validate_directory(&path)?;
51
+ if resolve_cwd(&path)? != record.cwd {
52
+ return Err(BackendError::Metadata);
53
+ }
54
+ return Ok(record.cwd);
55
+ }
56
+ Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
57
+ Err(error) => return Err(error.into()),
58
+ }
59
+ match std::fs::symlink_metadata(&parent) {
60
+ Ok(_) => validate_directory(&parent)?,
61
+ Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
62
+ std::fs::DirBuilder::new().mode(0o700).create(&parent)?;
63
+ }
64
+ Err(error) => return Err(error.into()),
65
+ }
66
+ // Exclusive create is deliberate: missing record + existing path is not
67
+ // enough evidence that this framework owns the previous incarnation.
68
+ std::fs::DirBuilder::new().mode(0o700).create(&path)?;
69
+ validate_directory(&path)?;
70
+ let cwd = resolve_cwd(&path)?;
71
+ let record = CwdRecord { version: 1, seat: seat.clone(), cwd: cwd.clone() };
72
+ let bytes = serde_json::to_vec(&record).map_err(|_| BackendError::Metadata)?;
73
+ let mut file = std::fs::OpenOptions::new().write(true).create_new(true)
74
+ .mode(0o600).custom_flags(libc::O_NOFOLLOW | libc::O_CLOEXEC).open(record_path)?;
75
+ file.write_all(&bytes)?;
76
+ file.sync_all()?;
77
+ Ok(cwd)
78
+ }
79
+
80
+ fn validate_directory(path: &Path) -> Result<(), BackendError> {
81
+ let metadata = std::fs::symlink_metadata(path)?;
82
+ if !metadata.is_dir() || metadata.file_type().is_symlink() || path.canonicalize()? != path
83
+ || metadata.uid() != unsafe { libc::geteuid() } || metadata.mode() & 0o077 != 0
84
+ {
85
+ return Err(BackendError::Metadata);
86
+ }
87
+ Ok(())
88
+ }
89
+
90
+ #[cfg(test)]
91
+ mod tests {
92
+ use super::*;
93
+ use std::path::PathBuf;
94
+ use std::sync::atomic::{AtomicU64, Ordering};
95
+ use team_agent_contract::contract::types::{Digest, ScopeId};
96
+
97
+ static SEQUENCE: AtomicU64 = AtomicU64::new(0);
98
+ struct Fixture { path: PathBuf, binding: Binding }
99
+ impl Fixture {
100
+ fn new() -> Self {
101
+ let path = std::env::temp_dir().canonicalize().unwrap().join(format!(
102
+ "contract-cwd-{}-{}-{}", std::process::id(),
103
+ std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos(),
104
+ SEQUENCE.fetch_add(1, Ordering::Relaxed),
105
+ ));
106
+ std::fs::DirBuilder::new().mode(0o700).create(&path).unwrap();
107
+ let binding = Binding {
108
+ version: 1, workspace: resolve_cwd(&path).unwrap(), team: "test".into(),
109
+ scope: ScopeId::new("cwd-test").unwrap(), endpoint: "framework:cwd-test".into(),
110
+ candidate: "/controlled/candidate".into(), candidate_sha256: Digest([1; 32]),
111
+ tmux: "/controlled/tmux".into(), transport_root: path.join("transport"),
112
+ };
113
+ let root = root_for(&path, &binding.scope);
114
+ std::fs::create_dir_all(root.parent().unwrap()).unwrap();
115
+ std::fs::DirBuilder::new().mode(0o700).create(&root).unwrap();
116
+ Self { path, binding }
117
+ }
118
+ }
119
+ impl Drop for Fixture {
120
+ fn drop(&mut self) { let _ = std::fs::remove_dir_all(&self.path); }
121
+ }
122
+
123
+ #[test]
124
+ fn stable_cwd_is_reused_and_distinct_seats_do_not_share_a_primary() {
125
+ let f = Fixture::new();
126
+ let a = SeatId::new("a").unwrap();
127
+ let b = SeatId::new("b").unwrap();
128
+ let first = prepare(&f.binding, &a).unwrap();
129
+ assert_eq!(prepare(&f.binding, &a).unwrap(), first);
130
+ let second = prepare(&f.binding, &b).unwrap();
131
+ assert_ne!(first.path, second.path);
132
+ assert_ne!(first.identity, second.identity);
133
+ assert!(!f.path.join(".cursor").exists());
134
+ }
135
+
136
+ #[test]
137
+ fn replaced_inode_or_symlink_is_not_adopted_on_resume() {
138
+ let f = Fixture::new();
139
+ let seat = SeatId::new("worker").unwrap();
140
+ let original = prepare(&f.binding, &seat).unwrap();
141
+ let retained = original.path.with_extension("old");
142
+ std::fs::rename(&original.path, &retained).unwrap();
143
+ std::fs::DirBuilder::new().mode(0o700).create(&original.path).unwrap();
144
+ assert!(prepare(&f.binding, &seat).is_err());
145
+ std::fs::remove_dir(&original.path).unwrap();
146
+ std::os::unix::fs::symlink(&retained, &original.path).unwrap();
147
+ assert!(prepare(&f.binding, &seat).is_err());
148
+ }
149
+
150
+ #[test]
151
+ fn unrecorded_existing_directory_is_not_claimed() {
152
+ let f = Fixture::new();
153
+ let root = root_for(&f.path, &f.binding.scope);
154
+ let parent = root.join("workspaces");
155
+ std::fs::DirBuilder::new().mode(0o700).create(&parent).unwrap();
156
+ std::fs::DirBuilder::new().mode(0o700).create(parent.join("worker")).unwrap();
157
+ assert!(prepare(&f.binding, &SeatId::new("worker").unwrap()).is_err());
158
+ assert!(!root.join("cwd-worker.json").exists());
159
+ }
160
+ }
@@ -2140,10 +2140,8 @@ fn persist_exec_provider_leader_binding(
2140
2140
  "active_team_key".to_string(),
2141
2141
  serde_json::json!(identity.team_id.as_str()),
2142
2142
  );
2143
- obj.insert(
2144
- "session_name".to_string(),
2145
- serde_json::json!(target.session.as_str()),
2146
- );
2143
+ // The ambient session identifies the leader receiver, not workers.
2144
+ // Leave any existing worker session untouched, including unknown state.
2147
2145
  obj.insert("tmux_endpoint".to_string(), serde_json::json!(socket));
2148
2146
  obj.insert("tmux_socket".to_string(), serde_json::json!(socket));
2149
2147
  obj.insert(
@@ -3062,7 +3060,8 @@ mod tests {
3062
3060
  execute_leader_plan_after_ambient_authority, format_launcher_failure,
3063
3061
  handle_exec_provider_startup_prompts, is_interactive_shell_basename,
3064
3062
  lock_down_window_rename, managed_client_argv, managed_leader_window_from_occupied,
3065
- persist_managed_leader_binding, push_bounded_stderr, shlex_quote,
3063
+ persist_exec_provider_leader_binding, persist_managed_leader_binding,
3064
+ push_bounded_stderr, shlex_quote,
3066
3065
  write_window_rename_lockdown_event, ManagedClientAttachMode, VerifiedAmbientPaneAuthority,
3067
3066
  LEADER_STDERR_LIMIT,
3068
3067
  };
@@ -3495,6 +3494,95 @@ mod tests {
3495
3494
  let _ = std::fs::remove_dir_all(&workspace);
3496
3495
  }
3497
3496
 
3497
+ fn exec_binding_fixture(workspace: &Path) -> (LeaderStartPlan, VerifiedAmbientPaneAuthority) {
3498
+ let identity = LeaderIdentity {
3499
+ leader_session_uuid: LeaderSessionUuid::derive(
3500
+ "fp", &workspace.to_string_lossy(), "tester", "current",
3501
+ ).unwrap(),
3502
+ leader_session_uuid_source: LeaderSessionUuidSource::Derived,
3503
+ machine_fingerprint: "fp".into(), workspace_abspath: workspace.into(),
3504
+ os_user: "tester".into(), team_id: TeamKey::new("current"),
3505
+ };
3506
+ let plan = LeaderStartPlan {
3507
+ mode: LeaderStartMode::ExecProvider, provider: Provider::Pi,
3508
+ workspace: workspace.into(), socket: LeaderLaunchSocket::Workspace,
3509
+ session_name: None, argv: vec![], provider_argv: vec!["pi".into()],
3510
+ leader_prompt: None, session_capture: None, leader_window: None,
3511
+ is_external_leader: false, leader_env: BTreeMap::new(),
3512
+ identity: Some(identity), detached: false,
3513
+ };
3514
+ let authority = VerifiedAmbientPaneAuthority {
3515
+ pane_id: PaneId::new("%77"),
3516
+ observed: PaneInfo {
3517
+ pane_id: PaneId::new("%77"), session: SessionName::new("cursor-gate-leader"),
3518
+ window_index: Some(0), window_name: Some(WindowName::new("pi")),
3519
+ pane_index: Some(0), tty: Some("/dev/ttys077".into()),
3520
+ current_command: Some("pi".into()), current_path: Some(workspace.into()),
3521
+ active: true, pane_pid: None, leader_env: BTreeMap::new(),
3522
+ },
3523
+ endpoint: "/private/tmp/fixture-exec-leader.sock".into(),
3524
+ tmux: "/private/tmp/fixture-exec-leader.sock,123,0".into(),
3525
+ };
3526
+ (plan, authority)
3527
+ }
3528
+
3529
+ fn exec_binding_workspace() -> std::path::PathBuf {
3530
+ let workspace = std::env::temp_dir().join(format!("ta-exec-bootstrap-{}-{}",
3531
+ std::process::id(), std::time::SystemTime::now()
3532
+ .duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos()));
3533
+ std::fs::create_dir(&workspace).unwrap();
3534
+ workspace
3535
+ }
3536
+
3537
+ #[test]
3538
+ fn exec_provider_non_prefixed_receiver_produces_fresh_quick_start_bootstrap() {
3539
+ let workspace = exec_binding_workspace();
3540
+ let (plan, authority) = exec_binding_fixture(&workspace);
3541
+ persist_exec_provider_leader_binding(&plan, &workspace, &authority, false).unwrap();
3542
+ let state = crate::state::persist::load_runtime_state(&workspace).unwrap();
3543
+ let before = state.clone();
3544
+ assert!(state["session_name"].is_null());
3545
+ assert!(state["teams"]["current"]["session_name"].is_null());
3546
+ assert_eq!(state["teams"]["current"]["leader_receiver"]["session_name"], "cursor-gate-leader");
3547
+ assert!(!crate::lifecycle::launch::quick_start_has_runtime_for_test(&state, "current"));
3548
+ assert_eq!(state, before);
3549
+ std::fs::remove_dir_all(workspace).unwrap();
3550
+ }
3551
+
3552
+ #[test]
3553
+ fn exec_provider_preserves_existing_workers_and_never_clears_runtime_markers() {
3554
+ for status in ["running", "stopped", "unknown"] {
3555
+ let workspace = exec_binding_workspace();
3556
+ let (plan, authority) = exec_binding_fixture(&workspace);
3557
+ let path = crate::state::persist::runtime_state_path(&workspace);
3558
+ std::fs::create_dir_all(path.parent().unwrap()).unwrap();
3559
+ std::fs::write(&path, serde_json::json!({
3560
+ "active_team_key":"current", "session_name":"team-existing-workers",
3561
+ "spec_path":"/fixture/spec.yml", "team_dir":"/fixture/roles",
3562
+ "agents":{"worker":{"agent_id":"worker","provider":"pi","status":status}},
3563
+ "tasks":[{"task_id":"existing-task"}],
3564
+ }).to_string()).unwrap();
3565
+ persist_exec_provider_leader_binding(&plan, &workspace, &authority, false).unwrap();
3566
+ let state = crate::state::persist::load_runtime_state(&workspace).unwrap();
3567
+ for row in [&state, &state["teams"]["current"]] {
3568
+ assert_eq!(row["session_name"], "team-existing-workers");
3569
+ assert_eq!(row["agents"]["worker"]["status"], status);
3570
+ assert_eq!(row["spec_path"], "/fixture/spec.yml");
3571
+ assert_eq!(row["team_dir"], "/fixture/roles");
3572
+ assert_eq!(row["tasks"][0]["task_id"], "existing-task");
3573
+ }
3574
+ // Ownership writers update only the canonical team record, not the
3575
+ // legacy root view that retains the existing worker runtime.
3576
+ assert_eq!(
3577
+ state["teams"]["current"]["leader_receiver"]["session_name"],
3578
+ "cursor-gate-leader"
3579
+ );
3580
+ assert!(state["leader_receiver"].is_null());
3581
+ assert!(crate::lifecycle::launch::quick_start_has_runtime_for_test(&state, "current"));
3582
+ std::fs::remove_dir_all(workspace).unwrap();
3583
+ }
3584
+ }
3585
+
3498
3586
  #[test]
3499
3587
  #[serial_test::serial(env)]
3500
3588
  fn default_exec_provider_persists_current_pane_binding_before_provider_exec() {
@@ -120,7 +120,12 @@ impl FreshQuickStartLeaderBindingOps for RuntimeFreshQuickStartLeaderBindingOps<
120
120
  }
121
121
 
122
122
  fn tmux_endpoint(&mut self) -> Option<String> {
123
- self.transport.tmux_endpoint()
123
+ // Bind the endpoint we use to observe the caller, not the worker server.
124
+ let caller_transport = caller_read_transport();
125
+ caller_transport
126
+ .as_deref()
127
+ .unwrap_or(self.transport)
128
+ .tmux_endpoint()
124
129
  }
125
130
 
126
131
  fn observe_command(&mut self, pane: &PaneId) -> Option<String> {
@@ -3128,6 +3133,99 @@ mod fresh_quick_start_leader_binding_tests {
3128
3133
  })
3129
3134
  }
3130
3135
 
3136
+ #[cfg(unix)]
3137
+ #[test]
3138
+ #[serial_test::serial(env)]
3139
+ fn runtime_binding_endpoint_matches_caller_socket_without_redirecting_workers() {
3140
+ let hermetic = HermeticTestEnv::enter("runtime-caller-endpoint");
3141
+ let workspace = hermetic.root();
3142
+ let caller_socket = workspace.join("leader.sock");
3143
+ // Select the real caller-read backend without depending on a host tmux
3144
+ // server. This marker is not evidence of pane liveness.
3145
+ std::fs::write(&caller_socket, "fixture endpoint; no tmux server").unwrap();
3146
+ let caller_endpoint = caller_socket.to_str().unwrap();
3147
+ let tmux = hermetic.with_env("TMUX", &format!("{caller_endpoint},1,0"));
3148
+ let _pane = hermetic.with_env("TMUX_PANE", "%0");
3149
+ let worker_endpoint = "ta-fixture-workers";
3150
+ let transport = OfflineTransport::new()
3151
+ .with_tmux_endpoint(worker_endpoint)
3152
+ .with_pane_current_command("%0", "pi");
3153
+ let mut ops = RuntimeFreshQuickStartLeaderBindingOps {
3154
+ transport: &transport,
3155
+ last_failure_reason: None,
3156
+ cas_conflict: false,
3157
+ applied_grant: None,
3158
+ registry_receipt: None,
3159
+ frozen_owner: None,
3160
+ frozen_receiver: None,
3161
+ nonce_writer: None,
3162
+ };
3163
+ let endpoint = ops.tmux_endpoint().unwrap();
3164
+ assert_eq!(endpoint, caller_endpoint);
3165
+ assert_eq!(
3166
+ caller_read_transport().unwrap().tmux_endpoint().as_deref(),
3167
+ Some(endpoint.as_str())
3168
+ );
3169
+ assert_eq!(transport.tmux_endpoint().as_deref(), Some(worker_endpoint));
3170
+ let state = native_persisted_binding(workspace, caller_endpoint);
3171
+ let before = state.clone();
3172
+ assert!(persisted_binding_matches_verified_pane(
3173
+ &state,
3174
+ workspace,
3175
+ &PaneId::new("%0"),
3176
+ Provider::Pi,
3177
+ Some(&endpoint),
3178
+ ));
3179
+ assert_eq!(state, before);
3180
+ drop(tmux);
3181
+ assert!(caller_read_transport().is_none());
3182
+ assert_eq!(ops.tmux_endpoint().as_deref(), Some(worker_endpoint));
3183
+ }
3184
+
3185
+ #[cfg(unix)]
3186
+ #[test]
3187
+ #[serial_test::serial(env)]
3188
+ fn runtime_binding_endpoint_rejects_foreign_socket_with_same_pane_identity() {
3189
+ let hermetic = HermeticTestEnv::enter("runtime-foreign-endpoint");
3190
+ let workspace = hermetic.root();
3191
+ let caller_socket = workspace.join("leader.sock");
3192
+ std::fs::write(&caller_socket, "fixture endpoint; no tmux server").unwrap();
3193
+ let caller_endpoint = caller_socket.to_str().unwrap();
3194
+ let _tmux = hermetic.with_env("TMUX", &format!("{caller_endpoint},1,0"));
3195
+ let _pane = hermetic.with_env("TMUX_PANE", "%0");
3196
+ let worker_endpoint = "ta-fixture-workers";
3197
+ let transport = OfflineTransport::new()
3198
+ .with_tmux_endpoint(worker_endpoint)
3199
+ .with_pane_current_command("%0", "pi");
3200
+ let mut ops = RuntimeFreshQuickStartLeaderBindingOps {
3201
+ transport: &transport,
3202
+ last_failure_reason: None,
3203
+ cas_conflict: false,
3204
+ applied_grant: None,
3205
+ registry_receipt: None,
3206
+ frozen_owner: None,
3207
+ frozen_receiver: None,
3208
+ nonce_writer: None,
3209
+ };
3210
+ let endpoint = ops.tmux_endpoint().unwrap();
3211
+ assert_eq!(endpoint, caller_endpoint);
3212
+ let foreign_socket = workspace.join("foreign").join("leader.sock");
3213
+ for socket in [worker_endpoint, foreign_socket.to_str().unwrap()] {
3214
+ let state = native_persisted_binding(workspace, socket);
3215
+ let before = state.clone();
3216
+ // Both servers may have %0; the shared owner/provider/UUID/epoch
3217
+ // cannot make a foreign endpoint authoritative.
3218
+ assert!(!persisted_binding_matches_verified_pane(
3219
+ &state,
3220
+ workspace,
3221
+ &PaneId::new("%0"),
3222
+ Provider::Pi,
3223
+ Some(&endpoint),
3224
+ ));
3225
+ assert_eq!(state, before);
3226
+ }
3227
+ }
3228
+
3131
3229
  #[cfg(unix)]
3132
3230
  #[test]
3133
3231
  #[serial_test::serial(env)]
@@ -120,15 +120,13 @@ pub(super) fn persist_spawn_agent_state(
120
120
  continue;
121
121
  };
122
122
  #[cfg(unix)]
123
- if agent
123
+ if let Some(descriptor) = agent
124
124
  .get("provider")
125
125
  .and_then(Value::as_str)
126
- .is_some_and(|provider| {
127
- crate::contract_runtime::registry::descriptor(provider).is_some()
128
- })
126
+ .and_then(crate::contract_runtime::registry::descriptor)
129
127
  {
130
128
  let projected = if agent_is_paused(agent) {
131
- serde_json::json!({"agent_id":id,"provider":"kiro","runtime_family":"contract","status":"paused","pane_id":null})
129
+ serde_json::json!({"agent_id":id,"provider":descriptor.identity.id,"runtime_family":"contract","status":"paused","paused":true,"contract_instance":null,"pane_id":null})
132
130
  } else {
133
131
  crate::contract_runtime::framework::project_seat(workspace, &team_id, id)
134
132
  .map_err(|error| LifecycleError::StatePersist(error.to_string()))?
@@ -383,6 +383,12 @@ pub use worker_env::{apply_cursor_subscription_proxy_env, CursorProxyPresence};
383
383
 
384
384
  mod identity;
385
385
  pub(super) use identity::*;
386
+
387
+ // Test-only seam for the real leader producer -> unchanged bootstrap classifier.
388
+ #[cfg(test)]
389
+ pub(crate) fn quick_start_has_runtime_for_test(state: &serde_json::Value, team: &str) -> bool {
390
+ identity::runtime_state_has_quick_start_team(state, team)
391
+ }
386
392
  pub(crate) use identity::{
387
393
  extend_worker_env_unset_for_effort, provider_effort_event_if_dropped,
388
394
  provider_effort_event_if_dropped_json, provider_effort_event_payload,
@@ -405,6 +405,14 @@ fn restart_with_selected_team_and_transport(
405
405
  let spec =
406
406
  rebuild_runtime_spec_from_roles(&selected.run_workspace, &selected.team_key, &state)?;
407
407
  phase_timer.emit(&selected.run_workspace, "restart.phase", LifecyclePhase::CompileSpec);
408
+ // All native H2/model/session/cwd gates run before any legacy teardown.
409
+ // Their private sockets and Node images must not enter legacy selection.
410
+ #[cfg(unix)]
411
+ let native_restart = if crate::contract_runtime::framework::has_contract(&state) {
412
+ crate::contract_runtime::backend::prepare_restart(
413
+ &selected.run_workspace, &selected.team_key, &selected.team_dir, &state,
414
+ ).map_err(|error| LifecycleError::Provider(error.to_string()))?
415
+ } else { Vec::new() };
408
416
  // 重建后 spec_workspace 恒为 runtime spec 的父目录(.team/runtime/<team_key>/)。
409
417
  let runtime_spec =
410
418
  crate::model::paths::runtime_spec_path(&selected.run_workspace, &selected.team_key);
@@ -1354,6 +1362,20 @@ fn restart_with_selected_team_and_transport(
1354
1362
  attach_commands,
1355
1363
  });
1356
1364
  }
1365
+ let legacy_successful_agents = successful_agents.clone();
1366
+ #[cfg(unix)]
1367
+ for result in crate::contract_runtime::commands::restart_prepared(
1368
+ &selected.run_workspace, &selected.team_key, &mut state, native_restart,
1369
+ ) {
1370
+ let decision = RestartedAgent {
1371
+ agent_id: AgentId::new(result.agent_id), restart_mode: StartMode::Resumed,
1372
+ decision: ResumeDecision::Resume, session_id: result.session_id.map(SessionId::new),
1373
+ };
1374
+ match result.outcome {
1375
+ Ok(()) => successful_agents.push(decision),
1376
+ Err(error) => failed_agents.push(restart_failed_agent(&decision, "native_resume", error)),
1377
+ }
1378
+ }
1357
1379
  drop(lifecycle_lock);
1358
1380
  let coordinator =
1359
1381
  start_coordinator_for_workspace(&selected.run_workspace, Some(&selected.team_key))?;
@@ -1368,7 +1390,7 @@ fn restart_with_selected_team_and_transport(
1368
1390
  &selected.run_workspace,
1369
1391
  &state,
1370
1392
  &session_name,
1371
- &successful_agents,
1393
+ &legacy_successful_agents,
1372
1394
  transport,
1373
1395
  restart_readiness_deadline(readiness_deadline_ms),
1374
1396
  restart_readiness_poll_interval(),