@team-agent/installer 0.5.73 → 0.5.75

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 (38) hide show
  1. package/Cargo.lock +1 -1
  2. package/Cargo.toml +1 -1
  3. package/crates/team-agent/src/cli/adapters.rs +19 -13
  4. package/crates/team-agent/src/cli/emit.rs +6 -3
  5. package/crates/team-agent/src/cli/mod.rs +151 -0
  6. package/crates/team-agent/src/cli/tests/leader_watch.rs +28 -0
  7. package/crates/team-agent/src/cli/tests/run_delegation.rs +3 -0
  8. package/crates/team-agent/src/cli/types.rs +6 -0
  9. package/crates/team-agent/src/compiler.rs +14 -1
  10. package/crates/team-agent/src/layout/worker_env.rs +366 -0
  11. package/crates/team-agent/src/leader/lease.rs +334 -53
  12. package/crates/team-agent/src/leader/registry.rs +177 -108
  13. package/crates/team-agent/src/lifecycle/launch/pi_mcp.rs +12 -1
  14. package/crates/team-agent/src/lifecycle/launch/quick_start.rs +2272 -128
  15. package/crates/team-agent/src/lifecycle/launch/quick_start_transport.rs +16 -6
  16. package/crates/team-agent/src/lifecycle/launch/spawn.rs +4 -0
  17. package/crates/team-agent/src/lifecycle/launch/spec_state.rs +138 -13
  18. package/crates/team-agent/src/lifecycle/launch/state_projection.rs +5 -3
  19. package/crates/team-agent/src/lifecycle/restart/common.rs +3 -11
  20. package/crates/team-agent/src/lifecycle/tests/launch_spawn.rs +60 -0
  21. package/crates/team-agent/src/lifecycle/tests/pi_compiler_red.rs +31 -0
  22. package/crates/team-agent/src/lifecycle/tests/pi_dual_entry_red.rs +25 -23
  23. package/crates/team-agent/src/lifecycle/tests/worker_spawn_env_red.rs +110 -31
  24. package/crates/team-agent/src/messaging/delivery.rs +226 -37
  25. package/crates/team-agent/src/messaging/leader_channel.rs +12 -3
  26. package/crates/team-agent/src/messaging/tests/runtime.rs +234 -0
  27. package/crates/team-agent/src/model/spec.rs +81 -7
  28. package/crates/team-agent/src/provider/adapters/pi.rs +14 -0
  29. package/crates/team-agent/src/state/persist.rs +433 -4
  30. package/crates/team-agent/src/state/repository.rs +45 -2
  31. package/crates/team-agent/src/tmux_backend/tests.rs +414 -7
  32. package/crates/team-agent/src/tmux_backend.rs +274 -26
  33. package/crates/team-agent/src/transport/test_support.rs +18 -3
  34. package/crates/team-agent/src/transport.rs +99 -0
  35. package/package.json +4 -4
  36. package/skills/team-agent/SKILL.md +3 -0
  37. package/skills/team-agent/command-coverage.json +21 -0
  38. package/skills/team-agent/references/team-agent-operator.md +9 -2
@@ -54,14 +54,14 @@ fn worker_spawn_inherits_parent_process_env_for_proxy_and_ca() {
54
54
  &launch_transport,
55
55
  )
56
56
  .expect("launch fixture should spawn worker");
57
- let launch_command_line = launch_transport.single_spawn_command_line();
57
+ let launch_spawn = launch_transport.single_spawn();
58
58
 
59
59
  let restart_team = compiled_team_dir("restart", &[("worker_a", "Restart Worker")]);
60
60
  seed_restart_state(&restart_team, "worker_a");
61
61
  let restart_transport = RecordingTransport::new().with_session_present(false);
62
62
  restart_with_transport(&restart_team, true, None, &restart_transport)
63
63
  .expect("restart fixture should spawn worker");
64
- let restart_command_line = restart_transport.single_spawn_command_line();
64
+ let restart_spawn = restart_transport.single_spawn();
65
65
 
66
66
  let add_team = compiled_team_dir("add-agent", &[("worker_a", "Existing Worker")]);
67
67
  seed_running_add_agent_state(&add_team, "worker_a");
@@ -77,17 +77,15 @@ fn worker_spawn_inherits_parent_process_env_for_proxy_and_ca() {
77
77
  &add_transport,
78
78
  )
79
79
  .expect("add-agent fixture should spawn worker");
80
- let add_command_line = add_transport.single_spawn_command_line();
80
+ let add_spawn = add_transport.single_spawn();
81
81
 
82
82
  let failures = [
83
- ("quick-start launch", launch_command_line, "worker_a"),
84
- ("restart", restart_command_line, "worker_a"),
85
- ("add-agent", add_command_line, "worker_b"),
83
+ ("quick-start launch", launch_spawn, "worker_a"),
84
+ ("restart", restart_spawn, "worker_a"),
85
+ ("add-agent", add_spawn, "worker_b"),
86
86
  ]
87
87
  .into_iter()
88
- .flat_map(|(surface, command_line, agent_id)| {
89
- spawn_env_contract_failures(surface, &command_line, agent_id)
90
- })
88
+ .flat_map(|(surface, spawn, agent_id)| spawn_env_contract_failures(surface, &spawn, agent_id))
91
89
  .collect::<Vec<_>>();
92
90
 
93
91
  assert!(
@@ -97,6 +95,61 @@ fn worker_spawn_inherits_parent_process_env_for_proxy_and_ca() {
97
95
  );
98
96
  }
99
97
 
98
+ #[test]
99
+ fn tmux_control_filter_uses_env_keys_not_substring_in_inherited_values() {
100
+ let mut env = BTreeMap::from([
101
+ ("TA_SPAWN_ENV_CANARY".to_string(), "FOO".to_string()),
102
+ (
103
+ "HTTP_PROXY".to_string(),
104
+ "http://canary-proxy:1".to_string(),
105
+ ),
106
+ (
107
+ "NODE_EXTRA_CA_CERTS".to_string(),
108
+ "/canary/ca.pem".to_string(),
109
+ ),
110
+ ("TEAM_AGENT_WORKSPACE".to_string(), "/ws".to_string()),
111
+ (
112
+ "TEAM_AGENT_OWNER_TEAM_ID".to_string(),
113
+ "teamdir".to_string(),
114
+ ),
115
+ ("TEAM_AGENT_AGENT_ID".to_string(), "worker_a".to_string()),
116
+ (
117
+ "HARNESS_PROBE".to_string(),
118
+ "printf 'LS_USR_BIN_TMUX=%s\\n'\n".to_string(),
119
+ ),
120
+ ]);
121
+ let spawn = RecordedSpawn {
122
+ argv: vec!["codex".to_string()],
123
+ env: env.clone(),
124
+ env_unset: Vec::new(),
125
+ session: SessionName::new("s"),
126
+ window: WindowName::new("w"),
127
+ pane_id: PaneId::new("%1"),
128
+ };
129
+ assert!(
130
+ spawn_env_contract_failures("harness-value", &spawn, "worker_a").is_empty(),
131
+ "inherited values may contain the letters TMUX= without leaking TMUX/TMUX_PANE keys"
132
+ );
133
+
134
+ env.insert(
135
+ "TMUX".to_string(),
136
+ "/tmp/tmux-canary/default,1".to_string(),
137
+ );
138
+ let leaked = RecordedSpawn {
139
+ env,
140
+ ..spawn
141
+ };
142
+ let failures = spawn_env_contract_failures("key-leak", &leaked, "worker_a");
143
+ assert!(
144
+ failures.iter().any(|row| row.contains("present_keys=") && row.contains("TMUX")),
145
+ "actual TMUX key must still fail closed; failures={failures:?}"
146
+ );
147
+ assert!(
148
+ failures.iter().all(|row| !row.contains("command_line=")),
149
+ "failures must not dump command_line; failures={failures:?}"
150
+ );
151
+ }
152
+
100
153
  #[test]
101
154
  #[serial(env)]
102
155
  fn worker_spawn_scrubs_cross_provider_process_identity_but_keeps_config_env() {
@@ -234,42 +287,67 @@ fn worker_spawn_stays_out_of_coordinator_tick_and_daemon_preserves_parent_env()
234
287
  );
235
288
  }
236
289
 
237
- fn spawn_env_contract_failures(surface: &str, command_line: &str, agent_id: &str) -> Vec<String> {
290
+ fn spawn_env_has_tmux_control_assignment(spawn: &RecordedSpawn) -> bool {
291
+ spawn.env.contains_key("TMUX")
292
+ || spawn.env.contains_key("TMUX_PANE")
293
+ || spawn.argv.iter().any(|part| {
294
+ part.split_whitespace().any(|tok| {
295
+ tok == "TMUX" || tok.starts_with("TMUX=") || tok.starts_with("TMUX_PANE=")
296
+ })
297
+ })
298
+ }
299
+
300
+ fn spawn_env_contract_failures(
301
+ surface: &str,
302
+ spawn: &RecordedSpawn,
303
+ agent_id: &str,
304
+ ) -> Vec<String> {
238
305
  let mut failures = Vec::new();
239
- for expected in [
240
- "TA_SPAWN_ENV_CANARY=FOO",
241
- "HTTP_PROXY=http://canary-proxy:1",
242
- "NODE_EXTRA_CA_CERTS=/canary/ca.pem",
243
- "TEAM_AGENT_WORKSPACE=",
244
- "TEAM_AGENT_OWNER_TEAM_ID=teamdir",
306
+ for (key, expected) in [
307
+ ("TA_SPAWN_ENV_CANARY", Some("FOO")),
308
+ ("HTTP_PROXY", Some("http://canary-proxy:1")),
309
+ ("NODE_EXTRA_CA_CERTS", Some("/canary/ca.pem")),
310
+ ("TEAM_AGENT_OWNER_TEAM_ID", Some("teamdir")),
245
311
  ] {
246
- if !command_line.contains(expected) {
247
- failures.push(format!(
248
- "{surface}: missing `{expected}`; worker spawn must inherit parent env and overlay Team Agent identity; command_line={command_line:?}"
249
- ));
312
+ match (spawn.env.get(key).map(String::as_str), expected) {
313
+ (Some(actual), Some(wanted)) if actual == wanted => {}
314
+ (Some(_), Some(wanted)) => failures.push(format!(
315
+ "{surface}: `{key}` present but not `{wanted}`"
316
+ )),
317
+ _ => failures.push(format!(
318
+ "{surface}: missing `{key}` overlay/inherit"
319
+ )),
250
320
  }
251
321
  }
252
- if !command_line.contains(&format!("TEAM_AGENT_AGENT_ID={agent_id}")) {
322
+ if spawn
323
+ .env
324
+ .get("TEAM_AGENT_WORKSPACE")
325
+ .is_none_or(|value| value.is_empty())
326
+ {
327
+ failures.push(format!("{surface}: missing `TEAM_AGENT_WORKSPACE` overlay"));
328
+ }
329
+ if spawn.env.get("TEAM_AGENT_AGENT_ID").map(String::as_str) != Some(agent_id) {
253
330
  failures.push(format!(
254
- "{surface}: missing `TEAM_AGENT_AGENT_ID={agent_id}` overlay; command_line={command_line:?}"
331
+ "{surface}: missing `TEAM_AGENT_AGENT_ID={agent_id}` overlay"
255
332
  ));
256
333
  }
257
- if command_line.contains("TMUX=") || command_line.contains("TMUX_PANE=") {
334
+ if spawn_env_has_tmux_control_assignment(spawn) {
335
+ let present: Vec<&str> = ["TMUX", "TMUX_PANE"]
336
+ .into_iter()
337
+ .filter(|key| spawn.env.contains_key(*key))
338
+ .collect();
258
339
  failures.push(format!(
259
- "{surface}: worker spawn must filter tmux control env from inherited parent env; command_line={command_line:?}"
340
+ "{surface}: worker spawn must filter tmux control env from inherited parent env; present_keys={present:?}"
260
341
  ));
261
342
  }
262
- if !command_line.split_whitespace().any(|part| part == "codex") {
343
+ if !spawn.argv.iter().any(|part| part == "codex") {
263
344
  failures.push(format!(
264
- "{surface}: provider executable must remain the command name `codex`; command_line={command_line:?}"
345
+ "{surface}: provider executable must remain the command name `codex`"
265
346
  ));
266
347
  }
267
- if command_line
268
- .split_whitespace()
269
- .any(|part| part.ends_with("/codex"))
270
- {
348
+ if spawn.argv.iter().any(|part| part.ends_with("/codex")) {
271
349
  failures.push(format!(
272
- "{surface}: provider executable must not be hard-coded as an absolute codex path; command_line={command_line:?}"
350
+ "{surface}: provider executable must not be hard-coded as an absolute codex path"
273
351
  ));
274
352
  }
275
353
  failures
@@ -580,6 +658,7 @@ impl RecordingTransport {
580
658
  self
581
659
  }
582
660
 
661
+ #[allow(dead_code)]
583
662
  fn single_spawn_command_line(&self) -> String {
584
663
  self.single_spawn().command_line()
585
664
  }
@@ -31,9 +31,9 @@ use crate::provider::wire::{
31
31
  is_claude_family, parse_canonical_provider, parse_provider, provider_wire,
32
32
  };
33
33
  use crate::transport::{
34
- submit_verification_wire, turn_verification_wire, CaptureRange, InjectPayload, InjectReport,
35
- InjectVerification, Key, PaneId, PaneInfo, SessionName, SubmitObserver, SubmitVerification,
36
- Target, Transport, WindowName,
34
+ submit_verification_wire, turn_verification_wire, CaptureRange, CaptureSampleOutcome,
35
+ InjectPayload, InjectReport, InjectVerification, InputSurfaceProbe, Key, PaneId, PaneInfo,
36
+ QueryOutcome, SessionName, SubmitObserver, SubmitVerification, Target, Transport, WindowName,
37
37
  };
38
38
 
39
39
  use super::helpers::{message_exists, MessageStatusShadow};
@@ -749,16 +749,20 @@ pub fn deliver_pending_message(
749
749
  // are preserved byte-for-byte for grep compatibility; new keys are
750
750
  // ADDITIONAL.
751
751
  let submit_attempts_detail = render_submit_diagnostics(&inject_report);
752
- event_log.write(
753
- "send.unverified",
754
- serde_json::json!({
755
- "message_id": message_id,
756
- "recipient": message.recipient,
757
- "reason": reason,
758
- "attempts": inject_report.attempts,
759
- "submit_attempts_detail": submit_attempts_detail,
760
- }),
761
- )?;
752
+ let mut unverified = serde_json::json!({
753
+ "message_id": message_id,
754
+ "recipient": message.recipient,
755
+ "reason": reason,
756
+ "attempts": inject_report.attempts,
757
+ "submit_attempts_detail": submit_attempts_detail,
758
+ });
759
+ merge_inject_readback_fields(
760
+ &mut unverified,
761
+ &inject_report,
762
+ Some(&target),
763
+ resolved.metadata.as_ref(),
764
+ );
765
+ event_log.write("send.unverified", unverified)?;
762
766
  if inject_report.attempts >= u32::from(SEND_RETRY_MAX_ATTEMPTS) {
763
767
  store.mark(message_id, "failed", Some("send_unverified_exhausted"))?;
764
768
  emit_send_failed_exhausted(
@@ -1070,7 +1074,7 @@ fn persist_submit_verification(workspace: &Path, message_id: &str, report: &Inje
1070
1074
  if std::fs::create_dir_all(&dir).is_err() {
1071
1075
  return;
1072
1076
  }
1073
- let body = serde_json::json!({
1077
+ let mut body = serde_json::json!({
1074
1078
  "message_id": message_id,
1075
1079
  "submit_verification": format!("{:?}", report.submit_verification),
1076
1080
  "inject_verification": format!("{:?}", report.inject_verification),
@@ -1078,6 +1082,7 @@ fn persist_submit_verification(workspace: &Path, message_id: &str, report: &Inje
1078
1082
  "submit_verified": inject_submit_verified(report),
1079
1083
  "attempts": report.attempts,
1080
1084
  });
1085
+ merge_inject_readback_fields(&mut body, report, None, None);
1081
1086
  let _ = std::fs::write(dir.join("submit-verification.json"), format!("{body}\n"));
1082
1087
  }
1083
1088
 
@@ -1914,6 +1919,116 @@ fn render_submit_diagnostics(report: &InjectReport) -> serde_json::Value {
1914
1919
  )
1915
1920
  }
1916
1921
 
1922
+ fn json_opt_bool(value: Option<bool>) -> serde_json::Value {
1923
+ match value {
1924
+ Some(flag) => serde_json::Value::Bool(flag),
1925
+ None => serde_json::Value::Null,
1926
+ }
1927
+ }
1928
+
1929
+ fn inject_pane_id(target: &Target) -> Option<&str> {
1930
+ match target {
1931
+ Target::Pane(pane) => Some(pane.as_str()),
1932
+ Target::SessionWindow { .. } => None,
1933
+ }
1934
+ }
1935
+
1936
+ fn merge_inject_readback_fields(
1937
+ event: &mut serde_json::Value,
1938
+ report: &InjectReport,
1939
+ target: Option<&Target>,
1940
+ metadata: Option<&TargetMetadata>,
1941
+ ) {
1942
+ if let Some(metadata) = metadata {
1943
+ append_target_metadata(event, metadata);
1944
+ }
1945
+ let Some(obj) = event.as_object_mut() else {
1946
+ return;
1947
+ };
1948
+ let inject_pane = target.and_then(inject_pane_id);
1949
+ if let Some(pane) = inject_pane {
1950
+ obj.insert(
1951
+ "inject_target_pane_id".to_string(),
1952
+ serde_json::json!(pane),
1953
+ );
1954
+ }
1955
+ let resolved_pane = metadata.map(|meta| meta.target_pane_id.as_str());
1956
+ obj.insert(
1957
+ "target_pane_matched".to_string(),
1958
+ json_opt_bool(match (inject_pane, resolved_pane) {
1959
+ (Some(left), Some(right)) => Some(left == right),
1960
+ _ => None,
1961
+ }),
1962
+ );
1963
+ let inject_endpoint = metadata.and_then(|meta| meta.tmux_endpoint.as_deref());
1964
+ // metadata.tmux_endpoint is the transport used to resolve; compare to
1965
+ // itself is tautological. Equality vs inject target id is the pane check
1966
+ // above. Socket equality is recorded when both sides exist on metadata.
1967
+ obj.insert(
1968
+ "resolved_tmux_endpoint_present".to_string(),
1969
+ serde_json::Value::Bool(inject_endpoint.is_some()),
1970
+ );
1971
+ let diag = report.submit_diagnostics.as_ref();
1972
+ obj.insert(
1973
+ "token_seen_before_paste".to_string(),
1974
+ json_opt_bool(diag.and_then(|d| d.token_seen_before_paste)),
1975
+ );
1976
+ obj.insert(
1977
+ "token_seen_after_paste".to_string(),
1978
+ json_opt_bool(diag.and_then(|d| d.token_seen_after_paste)),
1979
+ );
1980
+ obj.insert(
1981
+ "token_seen_after_enter".to_string(),
1982
+ json_opt_bool(diag.and_then(|d| d.token_seen_after_enter)),
1983
+ );
1984
+ obj.insert(
1985
+ "capture_ok_count".to_string(),
1986
+ serde_json::json!(diag.map(|d| d.capture_ok_count).unwrap_or(0)),
1987
+ );
1988
+ obj.insert(
1989
+ "capture_err_count".to_string(),
1990
+ serde_json::json!(diag.map(|d| d.capture_err_count).unwrap_or(0)),
1991
+ );
1992
+ obj.insert(
1993
+ "capture_sample_count".to_string(),
1994
+ serde_json::json!(diag.map(|d| d.capture_sample_count).unwrap_or(0)),
1995
+ );
1996
+ obj.insert(
1997
+ "last_capture_outcome".to_string(),
1998
+ serde_json::json!(diag
1999
+ .map(|d| d.last_capture_outcome.as_str())
2000
+ .unwrap_or(CaptureSampleOutcome::NotAttempted.as_str())),
2001
+ );
2002
+ obj.insert(
2003
+ "pane_command_basename".to_string(),
2004
+ diag.and_then(|d| d.pane_command_basename.as_deref())
2005
+ .map(|name| serde_json::json!(name))
2006
+ .unwrap_or(serde_json::Value::Null),
2007
+ );
2008
+ obj.insert(
2009
+ "pane_command_query".to_string(),
2010
+ serde_json::json!(diag
2011
+ .map(|d| d.pane_command_query.as_str())
2012
+ .unwrap_or(QueryOutcome::Unavailable.as_str())),
2013
+ );
2014
+ obj.insert(
2015
+ "input_surface".to_string(),
2016
+ serde_json::json!(diag
2017
+ .map(|d| d.input_surface.as_str())
2018
+ .unwrap_or(InputSurfaceProbe::Unavailable.as_str())),
2019
+ );
2020
+ obj.insert(
2021
+ "diag_target_pane_id".to_string(),
2022
+ diag.and_then(|d| d.target_pane_id.as_deref())
2023
+ .map(|pane| serde_json::json!(pane))
2024
+ .unwrap_or(serde_json::Value::Null),
2025
+ );
2026
+ obj.insert(
2027
+ "diag_target_pane_query_matched".to_string(),
2028
+ json_opt_bool(diag.and_then(|d| d.target_pane_query_matched)),
2029
+ );
2030
+ }
2031
+
1917
2032
  #[allow(clippy::too_many_arguments)]
1918
2033
  fn emit_send_failed_exhausted(
1919
2034
  workspace: &Path,
@@ -1944,21 +2059,22 @@ fn emit_send_failed_exhausted(
1944
2059
  .and_then(|r| r.submit_diagnostics.as_ref())
1945
2060
  .and_then(|d| d.attempts_detail.last())
1946
2061
  .map(|a| a.pane_tail_excerpt.clone());
1947
- event_log.write(
1948
- "send.failed",
1949
- serde_json::json!({
1950
- "message_id": message_id,
1951
- "recipient": recipient,
1952
- "attempts": attempts,
1953
- "max_attempts": SEND_RETRY_MAX_ATTEMPTS,
1954
- "reason": failure_reason,
1955
- "verification": verification,
1956
- "submit_attempts_detail": submit_attempts_detail,
1957
- "total_elapsed_ms": total_elapsed_ms,
1958
- "last_matched_literal": last_matched_literal,
1959
- "last_pane_tail_excerpt": last_pane_tail_excerpt,
1960
- }),
1961
- )?;
2062
+ let mut failed = serde_json::json!({
2063
+ "message_id": message_id,
2064
+ "recipient": recipient,
2065
+ "attempts": attempts,
2066
+ "max_attempts": SEND_RETRY_MAX_ATTEMPTS,
2067
+ "reason": failure_reason,
2068
+ "verification": verification,
2069
+ "submit_attempts_detail": submit_attempts_detail,
2070
+ "total_elapsed_ms": total_elapsed_ms,
2071
+ "last_matched_literal": last_matched_literal,
2072
+ "last_pane_tail_excerpt": last_pane_tail_excerpt,
2073
+ });
2074
+ if let Some(report) = inject_report {
2075
+ merge_inject_readback_fields(&mut failed, report, None, None);
2076
+ }
2077
+ event_log.write("send.failed", failed)?;
1962
2078
  let content = format!(
1963
2079
  "send.failed\nerror: send to {recipient} failed with {failure_reason} after {attempts}/{SEND_RETRY_MAX_ATTEMPTS} attempts\naction: inspect the target pane and retry the send\nlog: .team/logs/events.jsonl"
1964
2080
  );
@@ -2866,7 +2982,9 @@ fn project_state_for_owner_team(
2866
2982
  return Ok(OwnerTeamProjection::Refused(outcome));
2867
2983
  }
2868
2984
  };
2869
- if top_level_state_matches_owner_team(fallback, &canonical_team) {
2985
+ if top_level_state_matches_owner_team(fallback, &canonical_team)
2986
+ && state_has_no_team_entries(fallback)
2987
+ {
2870
2988
  let mut state = fallback.clone();
2871
2989
  carry_top_level_leader_binding(&mut state, &raw);
2872
2990
  return Ok(OwnerTeamProjection::Projected {
@@ -2874,7 +2992,8 @@ fn project_state_for_owner_team(
2874
2992
  canonical_team,
2875
2993
  });
2876
2994
  }
2877
- if top_level_state_matches_owner_team(&raw, &canonical_team) {
2995
+ if top_level_state_matches_owner_team(&raw, &canonical_team) && state_has_no_team_entries(&raw)
2996
+ {
2878
2997
  return Ok(OwnerTeamProjection::Projected {
2879
2998
  state: raw,
2880
2999
  canonical_team,
@@ -2999,16 +3118,31 @@ fn project_state_for_owner_team_value(
2999
3118
  None
3000
3119
  }
3001
3120
 
3002
- fn top_level_state_matches_owner_team(state: &serde_json::Value, team: &str) -> bool {
3003
- state
3121
+ pub(crate) fn top_level_state_matches_owner_team(state: &serde_json::Value, team: &str) -> bool {
3122
+ if !state.is_object() {
3123
+ return false;
3124
+ }
3125
+ if state
3004
3126
  .get("active_team_key")
3005
3127
  .and_then(serde_json::Value::as_str)
3006
3128
  .is_some_and(|value| value == team)
3007
- || crate::state::projection::team_state_key(state) == team
3008
- || state
3009
- .get("session_name")
3129
+ {
3130
+ return true;
3131
+ }
3132
+ if state
3133
+ .get("session_name")
3134
+ .and_then(serde_json::Value::as_str)
3135
+ .is_some_and(|session| session == team || session.strip_prefix("team-") == Some(team))
3136
+ {
3137
+ return true;
3138
+ }
3139
+ let has_explicit_key = ["team_key", "team_dir", "spec_path"].iter().any(|field| {
3140
+ state
3141
+ .get(*field)
3010
3142
  .and_then(serde_json::Value::as_str)
3011
- .is_some_and(|session| session == team || session.strip_prefix("team-") == Some(team))
3143
+ .is_some_and(|value| !value.is_empty())
3144
+ });
3145
+ has_explicit_key && crate::state::projection::team_state_key(state) == team
3012
3146
  }
3013
3147
 
3014
3148
  /// `retry_injection_after_trust_auto_answer` (`trust_auto_answer.py`):leader 路径 trust 应答
@@ -3248,4 +3382,59 @@ mod paste_floor_tests {
3248
3382
  Duration::ZERO
3249
3383
  );
3250
3384
  }
3385
+
3386
+ fn empty_report() -> InjectReport {
3387
+ InjectReport {
3388
+ stage_reached: crate::transport::InjectStage::Submit,
3389
+ inject_verification: InjectVerification::CaptureMissingToken,
3390
+ submit_verification: SubmitVerification::SubmitConsumptionUnverified,
3391
+ turn_verification: crate::transport::TurnVerification::NotYetObserved,
3392
+ attempts: 1,
3393
+ submit_diagnostics: Some(crate::transport::SubmitDiagnostics {
3394
+ last_capture_outcome: CaptureSampleOutcome::Failed,
3395
+ capture_err_count: 1,
3396
+ capture_sample_count: 1,
3397
+ pane_command_basename: Some("grok".to_string()),
3398
+ pane_command_query: QueryOutcome::Observed,
3399
+ input_surface: InputSurfaceProbe::Input,
3400
+ target_pane_id: Some("%9".to_string()),
3401
+ target_pane_query_matched: Some(true),
3402
+ token_seen_after_paste: None,
3403
+ ..crate::transport::SubmitDiagnostics::default()
3404
+ }),
3405
+ }
3406
+ }
3407
+
3408
+ #[test]
3409
+ fn inject_readback_fields_are_safe_scalars_and_keep_attempts_array() {
3410
+ let report = empty_report();
3411
+ let mut event = serde_json::json!({
3412
+ "message_id": "msg_x",
3413
+ "submit_attempts_detail": [{"matched": false}],
3414
+ });
3415
+ merge_inject_readback_fields(
3416
+ &mut event,
3417
+ &report,
3418
+ Some(&Target::Pane(PaneId::new("%9"))),
3419
+ None,
3420
+ );
3421
+ assert_eq!(event["last_capture_outcome"], serde_json::json!("failed"));
3422
+ assert_eq!(event["token_seen_after_paste"], serde_json::Value::Null);
3423
+ assert_eq!(event["pane_command_basename"], serde_json::json!("grok"));
3424
+ assert_eq!(event["input_surface"], serde_json::json!("input"));
3425
+ assert_eq!(event["inject_target_pane_id"], serde_json::json!("%9"));
3426
+ assert!(event["submit_attempts_detail"].is_array());
3427
+ assert!(event.get("pane_tail_excerpt").is_none());
3428
+ let dump = event.to_string();
3429
+ assert!(
3430
+ !dump.contains("--help") && !dump.contains("argv"),
3431
+ "readback fields must not leak argv: {dump}"
3432
+ );
3433
+ assert_eq!(
3434
+ report.inject_verification,
3435
+ InjectVerification::CaptureMissingToken
3436
+ );
3437
+ assert!(!inject_submit_verified(&report));
3438
+ assert!(!pane_readback_verified(&report));
3439
+ }
3251
3440
  }
@@ -7,6 +7,11 @@ use crate::codex_app_server::AppServerBinding;
7
7
  use crate::model::pane_authority_refusal::PaneWorkspaceMismatchFacts;
8
8
  use crate::transport::{PaneInfo, Transport};
9
9
 
10
+ /// Scope authority written by a fresh quick-start caller binding. It is distinct
11
+ /// from an operator-initiated explicit claim, but uses the same pane-instance
12
+ /// nonce and exact workspace checks at delivery.
13
+ pub(crate) const FRESH_CALLER_SCOPE_AUTHORITY: &str = "fresh_caller";
14
+
10
15
  #[derive(Debug, Clone, PartialEq, Eq)]
11
16
  pub enum LiveLeaderChannel {
12
17
  DirectTmux(DirectTmuxLeaderChannel),
@@ -117,7 +122,7 @@ pub fn resolve_live_leader_channel(
117
122
  };
118
123
  if let Some(observed_pane_workspace) = observed.current_path.clone() {
119
124
  if !path_is_in_workspace(&observed_pane_workspace, workspace)
120
- && !explicit_claim_authority_matches(workspace, receiver, &observed)
125
+ && !scoped_authority_matches(workspace, receiver, &observed)
121
126
  {
122
127
  return LeaderChannelResolution::Unbound(LeaderChannelUnbound::PaneWorkspaceMismatch(
123
128
  PaneWorkspaceMismatchFacts {
@@ -138,12 +143,16 @@ pub fn resolve_live_leader_channel(
138
143
  }))
139
144
  }
140
145
 
141
- fn explicit_claim_authority_matches(
146
+ fn scoped_authority_matches(
142
147
  workspace: &Path,
143
148
  receiver: &Value,
144
149
  observed: &PaneInfo,
145
150
  ) -> bool {
146
- if string_field(receiver, "scope_authority") != Some("explicit_claim") {
151
+ let authority = string_field(receiver, "scope_authority");
152
+ if !matches!(
153
+ authority,
154
+ Some("explicit_claim") | Some(FRESH_CALLER_SCOPE_AUTHORITY)
155
+ ) {
147
156
  return false;
148
157
  }
149
158
  let Some(authorized_workspace) = string_field(receiver, "authorized_team_workspace") else {