@team-agent/installer 0.5.114 → 0.5.116

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.
Files changed (33) hide show
  1. package/Cargo.lock +1 -1
  2. package/Cargo.toml +1 -1
  3. package/crates/team-agent/src/cli/emit.rs +2 -2
  4. package/crates/team-agent/src/cli/mod.rs +155 -19
  5. package/crates/team-agent/src/cli/models.rs +52 -1
  6. package/crates/team-agent/src/cli/shutdown_socket.rs +288 -0
  7. package/crates/team-agent/src/cli/spec.rs +1 -1
  8. package/crates/team-agent/src/cli/tests/shutdown_kill_plan.rs +11 -4
  9. package/crates/team-agent/src/contract_runtime/backend.rs +490 -97
  10. package/crates/team-agent/src/contract_runtime/commands.rs +326 -0
  11. package/crates/team-agent/src/contract_runtime/config.rs +39 -8
  12. package/crates/team-agent/src/contract_runtime/config_tests.rs +56 -0
  13. package/crates/team-agent/src/contract_runtime/discovery.rs +218 -40
  14. package/crates/team-agent/src/contract_runtime/framework.rs +38 -5
  15. package/crates/team-agent/src/contract_runtime/mcp.rs +3 -0
  16. package/crates/team-agent/src/contract_runtime/mod.rs +2 -0
  17. package/crates/team-agent/src/contract_runtime/pump.rs +97 -33
  18. package/crates/team-agent/src/contract_runtime/registry.rs +248 -4
  19. package/crates/team-agent/src/contract_runtime/workspace.rs +228 -0
  20. package/crates/team-agent/src/leader/start.rs +93 -5
  21. package/crates/team-agent/src/lifecycle/launch/quick_start.rs +99 -1
  22. package/crates/team-agent/src/lifecycle/launch/state_projection.rs +3 -5
  23. package/crates/team-agent/src/lifecycle/launch.rs +6 -0
  24. package/crates/team-agent/src/lifecycle/restart/agent.rs +4 -0
  25. package/crates/team-agent/src/lifecycle/restart/rebuild.rs +109 -58
  26. package/crates/team-agent/src/lifecycle/restart/selection.rs +63 -7
  27. package/crates/team-agent/src/lifecycle/role_config.rs +17 -1
  28. package/crates/team-agent/src/provider/session/capture.rs +24 -1
  29. package/crates/team-agent/src/state/persist.rs +72 -2
  30. package/crates/team-agent/src/state/projection.rs +29 -0
  31. package/crates/team-agent/src/state/repository/tests.rs +70 -0
  32. package/crates/team-agent/src/state/repository.rs +5 -1
  33. package/package.json +4 -4
@@ -0,0 +1,228 @@
1
+ //! Stable per-seat primary cwd outside project Git ancestry. Provider-specific
2
+ //! project access remains explicit; private MCP files never overwrite a shared
3
+ //! project or another seat's config, and old cwd incarnations are not migrated.
4
+ use std::io::{Read, Write};
5
+ use std::os::unix::fs::{DirBuilderExt, MetadataExt, OpenOptionsExt};
6
+ use std::path::Path;
7
+
8
+ use serde::{Deserialize, Serialize};
9
+ use team_agent_contract::contract::{session::CwdIdentity, types::SeatId};
10
+ use team_agent_contract::host::process::resolve_cwd;
11
+
12
+ use super::backend::{root_for, BackendError, Binding};
13
+
14
+ #[derive(Serialize, Deserialize)]
15
+ #[serde(deny_unknown_fields)]
16
+ struct CwdRecord {
17
+ version: u32,
18
+ seat: SeatId,
19
+ cwd: CwdIdentity,
20
+ }
21
+
22
+ /// Requires an already owned framework root. Existing unrecorded directories
23
+ /// are refused, not adopted. A replaced inode or any symlink is a hard error.
24
+ pub(super) fn prepare(binding: &Binding, seat: &SeatId) -> Result<CwdIdentity, BackendError> {
25
+ let root = root_for(&binding.workspace.path, &binding.scope);
26
+ validate_directory(&root)?;
27
+ let parent = binding.transport_root.join("workspaces");
28
+ let path = parent.join(seat.as_str());
29
+ let record_path = root.join(format!("cwd-{}.json", seat.as_str()));
30
+ match std::fs::symlink_metadata(&record_path) {
31
+ Ok(metadata) => {
32
+ if !metadata.is_file() || metadata.file_type().is_symlink() {
33
+ return Err(BackendError::Metadata);
34
+ }
35
+ let mut file = std::fs::OpenOptions::new().read(true)
36
+ .custom_flags(libc::O_NOFOLLOW | libc::O_CLOEXEC).open(&record_path)?;
37
+ let opened = file.metadata()?;
38
+ if opened.dev() != metadata.dev() || opened.ino() != metadata.ino() {
39
+ return Err(BackendError::Metadata);
40
+ }
41
+ let mut bytes = Vec::new();
42
+ Read::by_ref(&mut file).take(65537).read_to_end(&mut bytes)?;
43
+ if bytes.len() > 65536 {
44
+ return Err(BackendError::Metadata);
45
+ }
46
+ let record: CwdRecord = serde_json::from_slice(&bytes).map_err(|_| BackendError::Metadata)?;
47
+ // Old sessions stay pinned to their original project-local cwd.
48
+ // Only new allocations move outside Git; never relocate a resume.
49
+ let expected = match record.version {
50
+ 1 => root.join("workspaces").join(seat.as_str()),
51
+ 2 => {
52
+ validate_directory(&binding.transport_root)?;
53
+ reject_git_ancestors(&binding.transport_root)?;
54
+ path.clone()
55
+ }
56
+ _ => return Err(BackendError::Metadata),
57
+ };
58
+ if record.seat != *seat || record.cwd.path != expected {
59
+ return Err(BackendError::Metadata);
60
+ }
61
+ validate_directory(expected.parent().ok_or(BackendError::Metadata)?)?;
62
+ validate_directory(&expected)?;
63
+ if resolve_cwd(&expected)? != record.cwd {
64
+ return Err(BackendError::Metadata);
65
+ }
66
+ return Ok(record.cwd);
67
+ }
68
+ Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
69
+ Err(error) => return Err(error.into()),
70
+ }
71
+ validate_directory(&binding.transport_root)?;
72
+ reject_git_ancestors(&binding.transport_root)?;
73
+ match std::fs::symlink_metadata(&parent) {
74
+ Ok(_) => validate_directory(&parent)?,
75
+ Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
76
+ std::fs::DirBuilder::new().mode(0o700).create(&parent)?;
77
+ }
78
+ Err(error) => return Err(error.into()),
79
+ }
80
+ // Exclusive create is deliberate: missing record + existing path is not
81
+ // enough evidence that this framework owns the previous incarnation.
82
+ std::fs::DirBuilder::new().mode(0o700).create(&path)?;
83
+ validate_directory(&path)?;
84
+ let cwd = resolve_cwd(&path)?;
85
+ let record = CwdRecord { version: 2, seat: seat.clone(), cwd: cwd.clone() };
86
+ let bytes = serde_json::to_vec(&record).map_err(|_| BackendError::Metadata)?;
87
+ let mut file = std::fs::OpenOptions::new().write(true).create_new(true)
88
+ .mode(0o600).custom_flags(libc::O_NOFOLLOW | libc::O_CLOEXEC).open(record_path)?;
89
+ file.write_all(&bytes)?;
90
+ file.sync_all()?;
91
+ Ok(cwd)
92
+ }
93
+
94
+ // Metadata only: handle both ordinary .git directories and worktree .git files;
95
+ // never read Git config, credentials, execute hooks, or assume /tmp is non-Git.
96
+ fn reject_git_ancestors(path: &Path) -> Result<(), BackendError> {
97
+ for ancestor in path.ancestors() {
98
+ match std::fs::symlink_metadata(ancestor.join(".git")) {
99
+ Ok(_) => return Err(BackendError::Metadata),
100
+ Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
101
+ Err(error) => return Err(error.into()),
102
+ }
103
+ }
104
+ Ok(())
105
+ }
106
+
107
+ fn validate_directory(path: &Path) -> Result<(), BackendError> {
108
+ let metadata = std::fs::symlink_metadata(path)?;
109
+ if !metadata.is_dir() || metadata.file_type().is_symlink() || path.canonicalize()? != path
110
+ || metadata.uid() != unsafe { libc::geteuid() } || metadata.mode() & 0o077 != 0
111
+ {
112
+ return Err(BackendError::Metadata);
113
+ }
114
+ Ok(())
115
+ }
116
+
117
+ #[cfg(test)]
118
+ mod tests {
119
+ use super::*;
120
+ use std::path::PathBuf;
121
+ use std::sync::atomic::{AtomicU64, Ordering};
122
+ use team_agent_contract::contract::types::{Digest, ScopeId};
123
+
124
+ static SEQUENCE: AtomicU64 = AtomicU64::new(0);
125
+ struct Fixture { path: PathBuf, binding: Binding }
126
+ impl Fixture {
127
+ fn new() -> Self {
128
+ let path = std::env::temp_dir().canonicalize().unwrap().join(format!(
129
+ "contract-cwd-{}-{}-{}", std::process::id(),
130
+ std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos(),
131
+ SEQUENCE.fetch_add(1, Ordering::Relaxed),
132
+ ));
133
+ std::fs::DirBuilder::new().mode(0o700).create(&path).unwrap();
134
+ let binding = Binding {
135
+ version: 1, workspace: resolve_cwd(&path).unwrap(), team: "test".into(),
136
+ scope: ScopeId::new("cwd-test").unwrap(), endpoint: "framework:cwd-test".into(),
137
+ candidate: "/controlled/candidate".into(), candidate_sha256: Digest([1; 32]),
138
+ tmux: "/controlled/tmux".into(), transport_root: path.with_extension("transport"),
139
+ };
140
+ std::fs::DirBuilder::new().mode(0o700).create(&binding.transport_root).unwrap();
141
+ let root = root_for(&path, &binding.scope);
142
+ std::fs::create_dir_all(root.parent().unwrap()).unwrap();
143
+ std::fs::DirBuilder::new().mode(0o700).create(&root).unwrap();
144
+ Self { path, binding }
145
+ }
146
+ }
147
+ impl Drop for Fixture {
148
+ fn drop(&mut self) {
149
+ let _ = std::fs::remove_dir_all(&self.path);
150
+ let _ = std::fs::remove_dir_all(&self.binding.transport_root);
151
+ }
152
+ }
153
+
154
+ #[test]
155
+ fn stable_cwd_is_reused_and_distinct_seats_do_not_share_a_primary() {
156
+ let f = Fixture::new();
157
+ let a = SeatId::new("a").unwrap();
158
+ let b = SeatId::new("b").unwrap();
159
+ let first = prepare(&f.binding, &a).unwrap();
160
+ assert_eq!(prepare(&f.binding, &a).unwrap(), first);
161
+ let second = prepare(&f.binding, &b).unwrap();
162
+ assert_ne!(first.path, second.path);
163
+ assert_ne!(first.identity, second.identity);
164
+ assert!(!f.path.join(".cursor").exists());
165
+ }
166
+
167
+ #[test]
168
+ fn replaced_inode_or_symlink_is_not_adopted_on_resume() {
169
+ let f = Fixture::new();
170
+ let seat = SeatId::new("worker").unwrap();
171
+ let original = prepare(&f.binding, &seat).unwrap();
172
+ let retained = original.path.with_extension("old");
173
+ std::fs::rename(&original.path, &retained).unwrap();
174
+ std::fs::DirBuilder::new().mode(0o700).create(&original.path).unwrap();
175
+ assert!(prepare(&f.binding, &seat).is_err());
176
+ std::fs::remove_dir(&original.path).unwrap();
177
+ std::os::unix::fs::symlink(&retained, &original.path).unwrap();
178
+ assert!(prepare(&f.binding, &seat).is_err());
179
+ }
180
+
181
+ #[test]
182
+ fn unrecorded_existing_directory_is_not_claimed() {
183
+ let f = Fixture::new();
184
+ let root = root_for(&f.path, &f.binding.scope);
185
+ let parent = f.binding.transport_root.join("workspaces");
186
+ std::fs::DirBuilder::new().mode(0o700).create(&parent).unwrap();
187
+ std::fs::DirBuilder::new().mode(0o700).create(parent.join("worker")).unwrap();
188
+ assert!(prepare(&f.binding, &SeatId::new("worker").unwrap()).is_err());
189
+ assert!(!root.join("cwd-worker.json").exists());
190
+ }
191
+
192
+ #[test]
193
+ fn new_primary_is_outside_project_git_ancestors() {
194
+ let f = Fixture::new();
195
+ std::fs::create_dir(f.path.join(".git")).unwrap();
196
+ let cwd = prepare(&f.binding, &SeatId::new("worker").unwrap()).unwrap();
197
+ assert!(cwd.path.starts_with(&f.binding.transport_root));
198
+ assert!(!cwd.path.starts_with(&f.path));
199
+ reject_git_ancestors(&cwd.path).unwrap();
200
+ }
201
+
202
+ #[test]
203
+ fn existing_version_one_cwd_is_not_migrated() {
204
+ let f = Fixture::new();
205
+ let seat = SeatId::new("worker").unwrap();
206
+ let root = root_for(&f.path, &f.binding.scope);
207
+ let parent = root.join("workspaces");
208
+ let path = parent.join(seat.as_str());
209
+ std::fs::DirBuilder::new().mode(0o700).create(&parent).unwrap();
210
+ std::fs::DirBuilder::new().mode(0o700).create(&path).unwrap();
211
+ let cwd = resolve_cwd(&path).unwrap();
212
+ let record = CwdRecord { version: 1, seat: seat.clone(), cwd: cwd.clone() };
213
+ std::fs::write(root.join("cwd-worker.json"), serde_json::to_vec(&record).unwrap()).unwrap();
214
+ assert_eq!(prepare(&f.binding, &seat).unwrap(), cwd);
215
+ assert!(!f.binding.transport_root.join("workspaces").exists());
216
+ }
217
+
218
+ #[test]
219
+ fn git_directory_or_worktree_file_on_transport_ancestor_is_refused() {
220
+ let f = Fixture::new();
221
+ let marker = f.binding.transport_root.join(".git");
222
+ std::fs::write(&marker, b"gitdir: controlled-marker").unwrap();
223
+ assert!(prepare(&f.binding, &SeatId::new("worker").unwrap()).is_err());
224
+ std::fs::remove_file(&marker).unwrap();
225
+ std::fs::create_dir(&marker).unwrap();
226
+ assert!(prepare(&f.binding, &SeatId::new("worker").unwrap()).is_err());
227
+ }
228
+ }
@@ -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,
@@ -307,6 +307,7 @@ pub(crate) fn start_agent_at_paths(
307
307
  &window,
308
308
  noop_pane.as_ref(),
309
309
  )?;
310
+ state["agents"][agent_id.as_str()]["explicit_stop"] = serde_json::Value::Bool(false);
310
311
  let team_key = restart_projection_team_key(&state, team);
311
312
  save_restart_projected_state(workspace, &mut state, &team_key, &[agent_id.as_str()])?;
312
313
  if let Ok(spec) = load_team_spec(spec_workspace) {
@@ -1258,6 +1259,7 @@ pub(super) fn stop_agent_at_paths(
1258
1259
  let _ = write_stop_drain_event(workspace, agent_id, &target_str, &drain_evidence);
1259
1260
  }
1260
1261
  mark_agent_stopped(&mut state, agent_id, agent, &window)?;
1262
+ state["agents"][agent_id.as_str()]["explicit_stop"] = serde_json::Value::Bool(true);
1261
1263
  let team_key = restart_projection_team_key(&state, team);
1262
1264
  // golden operations.py:95: save_team_scoped_state (team projection) — NOT a raw save, so a
1263
1265
  // multi-team workspace keeps the other teams' persisted runtime state instead of being clobbered.
@@ -1380,6 +1382,7 @@ pub(super) fn mark_agent_started(
1380
1382
  }
1381
1383
  }
1382
1384
  agent.insert("status".to_string(), serde_json::json!("running"));
1385
+ agent.insert("explicit_stop".to_string(), serde_json::Value::Bool(false));
1383
1386
  agent.insert("agent_id".to_string(), serde_json::json!(agent_id.as_str()));
1384
1387
  agent.insert("window".to_string(), serde_json::json!(window));
1385
1388
  agent.insert(
@@ -1741,6 +1744,7 @@ fn reset_agent_at_paths(
1741
1744
  .and_then(serde_json::Value::as_object_mut)
1742
1745
  {
1743
1746
  obj.insert("spawn_epoch".to_string(), serde_json::json!(new_epoch));
1747
+ obj.insert("explicit_stop".to_string(), serde_json::Value::Bool(false));
1744
1748
  }
1745
1749
  let team_key = restart_projection_team_key(&state, team);
1746
1750
  sync_restart_team_projections(&mut state, &team_key);