@team-agent/installer 0.3.4 → 0.3.6

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 (56) hide show
  1. package/Cargo.lock +1 -1
  2. package/Cargo.toml +1 -1
  3. package/crates/team-agent/src/cli/adapters.rs +8 -0
  4. package/crates/team-agent/src/cli/diagnose.rs +51 -10
  5. package/crates/team-agent/src/cli/emit.rs +2 -1
  6. package/crates/team-agent/src/cli/mod.rs +217 -80
  7. package/crates/team-agent/src/cli/send.rs +1 -0
  8. package/crates/team-agent/src/cli/status_port.rs +135 -7
  9. package/crates/team-agent/src/cli/tests/missing_subcommands.rs +8 -1
  10. package/crates/team-agent/src/cli/tests/mod.rs +1 -0
  11. package/crates/team-agent/src/cli/tests/shutdown_kill_plan.rs +39 -0
  12. package/crates/team-agent/src/cli/types.rs +5 -1
  13. package/crates/team-agent/src/coordinator/backoff.rs +57 -9
  14. package/crates/team-agent/src/coordinator/health.rs +65 -2
  15. package/crates/team-agent/src/coordinator/runtime_detectors.rs +28 -16
  16. package/crates/team-agent/src/coordinator/tests/a0_lostupdate.rs +87 -0
  17. package/crates/team-agent/src/coordinator/tests/mod.rs +1 -0
  18. package/crates/team-agent/src/coordinator/tick.rs +195 -43
  19. package/crates/team-agent/src/leader/helpers.rs +2 -0
  20. package/crates/team-agent/src/leader/rediscover.rs +1 -0
  21. package/crates/team-agent/src/leader/start.rs +9 -1
  22. package/crates/team-agent/src/leader/takeover.rs +18 -1
  23. package/crates/team-agent/src/lifecycle/launch.rs +434 -29
  24. package/crates/team-agent/src/lifecycle/profile_launch.rs +110 -4
  25. package/crates/team-agent/src/lifecycle/profile_smoke.rs +4 -1
  26. package/crates/team-agent/src/lifecycle/restart/common.rs +19 -2
  27. package/crates/team-agent/src/lifecycle/tests/agent_ops.rs +2 -2
  28. package/crates/team-agent/src/lifecycle/tests/core.rs +1 -1
  29. package/crates/team-agent/src/lifecycle/tests/lane_ops.rs +4 -4
  30. package/crates/team-agent/src/lifecycle/tests/launch_spawn.rs +3 -1
  31. package/crates/team-agent/src/lifecycle/worker_command_context.rs +44 -9
  32. package/crates/team-agent/src/mcp_server/lifecycle_tools/agent_ops.rs +2 -1
  33. package/crates/team-agent/src/mcp_server/tests/scoped.rs +14 -1
  34. package/crates/team-agent/src/mcp_server/tests/send.rs +15 -1
  35. package/crates/team-agent/src/mcp_server/tools.rs +65 -9
  36. package/crates/team-agent/src/mcp_server/wire.rs +2 -1
  37. package/crates/team-agent/src/message_store.rs +80 -0
  38. package/crates/team-agent/src/messaging/results.rs +76 -5
  39. package/crates/team-agent/src/messaging/send.rs +3 -1
  40. package/crates/team-agent/src/messaging/types.rs +15 -1
  41. package/crates/team-agent/src/messaging/watchers.rs +68 -30
  42. package/crates/team-agent/src/model/enums.rs +7 -1
  43. package/crates/team-agent/src/model/permissions.rs +7 -0
  44. package/crates/team-agent/src/model/spec.rs +3 -1
  45. package/crates/team-agent/src/provider/adapter.rs +472 -7
  46. package/crates/team-agent/src/provider/classify.rs +6 -2
  47. package/crates/team-agent/src/provider/faults.rs +3 -2
  48. package/crates/team-agent/src/provider/startup_prompt.rs +25 -7
  49. package/crates/team-agent/src/provider/types.rs +11 -0
  50. package/crates/team-agent/src/session_capture.rs +1 -0
  51. package/crates/team-agent/src/state/persist.rs +95 -19
  52. package/crates/team-agent/src/tmux_backend/tests.rs +8 -7
  53. package/crates/team-agent/src/tmux_backend.rs +80 -6
  54. package/crates/team-agent/src/transport.rs +32 -0
  55. package/npm/install.mjs +21 -0
  56. package/package.json +4 -4
@@ -385,6 +385,31 @@ fn provider_env_exports(
385
385
  exports.insert("GEMINI_API_KEY".to_string(), value.to_string());
386
386
  }
387
387
  }
388
+ // C-A-4 cr verdict v2 — copilot BYOK(== compatible_api 档,help-providers 原文
389
+ // "GitHub authentication is not required" when COPILOT_PROVIDER_BASE_URL set):
390
+ // profile 值经 env_overlay 落 COPILOT_PROVIDER_BASE_URL/TYPE/API_KEY/WIRE_API
391
+ // + COPILOT_MODEL。BYOK 必须含 MODEL("A model is required for BYOK")—
392
+ // 这一硬性校验在 launch 路径处理(profile compile 时无法判别 model 来源)。
393
+ // Subscription/OfficialApi 不导出 COPILOT_PROVIDER_*(避免误改 auth 通道)。
394
+ Provider::Copilot => {
395
+ if auth_mode == AuthMode::CompatibleApi {
396
+ if let Some(value) = value_or_alternate(values, "COPILOT_PROVIDER_BASE_URL", "BASE_URL") {
397
+ exports.insert("COPILOT_PROVIDER_BASE_URL".to_string(), value.to_string());
398
+ }
399
+ if let Some(value) = value_or_alternate(values, "COPILOT_PROVIDER_TYPE", "PROVIDER_TYPE") {
400
+ exports.insert("COPILOT_PROVIDER_TYPE".to_string(), value.to_string());
401
+ }
402
+ if let Some(value) = value_or_alternate(values, "COPILOT_PROVIDER_API_KEY", "API_KEY") {
403
+ exports.insert("COPILOT_PROVIDER_API_KEY".to_string(), value.to_string());
404
+ }
405
+ if let Some(value) = value_or_alternate(values, "COPILOT_PROVIDER_WIRE_API", "WIRE_API") {
406
+ exports.insert("COPILOT_PROVIDER_WIRE_API".to_string(), value.to_string());
407
+ }
408
+ if let Some(value) = value_or_alternate(values, "COPILOT_MODEL", "MODEL") {
409
+ exports.insert("COPILOT_MODEL".to_string(), value.to_string());
410
+ }
411
+ }
412
+ }
388
413
  Provider::Fake => {}
389
414
  }
390
415
  exports
@@ -412,6 +437,9 @@ fn provider_env_unsets(provider: Provider, auth_mode: AuthMode) -> BTreeSet<Stri
412
437
  unsets.insert("GEMINI_API_KEY".to_string());
413
438
  }
414
439
  }
440
+ // C-7-1 cr verdict: 一期 subscription-only(已登录态),exports/unsets 空;
441
+ // BYOK(COPILOT_PROVIDER_*)二期立项时单独 cr verdict 再开。
442
+ Provider::Copilot => {}
415
443
  Provider::Fake => {}
416
444
  }
417
445
  unsets
@@ -429,7 +457,48 @@ fn provider_command_overrides(
429
457
  _ if agent.model.is_some() => agent.model.clone(),
430
458
  _ => profile_model(values).map(str::to_string),
431
459
  };
432
- ProviderCommandOverrides { model }
460
+ let mut codex_profile = None;
461
+ let mut codex_config = Vec::new();
462
+ // Python provider_env.py:62-79 (_provider_command_overrides codex branch).
463
+ match agent.provider {
464
+ Provider::Codex => {
465
+ codex_profile = value_or_alternate(values, "CODEX_PROFILE", "NATIVE_PROFILE")
466
+ .map(str::to_string);
467
+ let model_provider = values.get("MODEL_PROVIDER").filter(|v| !v.is_empty());
468
+ let base_url = values.get("BASE_URL").filter(|v| !v.is_empty());
469
+ if agent.auth_mode == AuthMode::CompatibleApi {
470
+ if let (Some(model_provider), Some(base_url)) = (model_provider, base_url) {
471
+ if safe_codex_provider_id(model_provider) {
472
+ codex_config.push(format!("model_provider=\"{model_provider}\""));
473
+ let prefix = format!("model_providers.{model_provider}");
474
+ codex_config.push(format!("{prefix}.base_url=\"{base_url}\""));
475
+ codex_config
476
+ .push(format!("{prefix}.env_key=\"TEAM_AGENT_PROVIDER_API_KEY\""));
477
+ if let Some(wire_api) = values.get("WIRE_API").filter(|v| !v.is_empty()) {
478
+ codex_config.push(format!("{prefix}.wire_api=\"{wire_api}\""));
479
+ }
480
+ if let Some(name) = values.get("PROVIDER_NAME").filter(|v| !v.is_empty()) {
481
+ codex_config.push(format!("{prefix}.name=\"{name}\""));
482
+ }
483
+ }
484
+ }
485
+ }
486
+ }
487
+ _ => {}
488
+ }
489
+ ProviderCommandOverrides {
490
+ model,
491
+ codex_profile,
492
+ codex_config,
493
+ }
494
+ }
495
+
496
+ /// Python helpers.py:33-34 `_safe_codex_provider_id`: `[A-Za-z0-9_-]+`.
497
+ fn safe_codex_provider_id(value: &str) -> bool {
498
+ !value.is_empty()
499
+ && value
500
+ .chars()
501
+ .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-')
433
502
  }
434
503
 
435
504
  fn write_runtime_env_file(
@@ -636,15 +705,48 @@ fn mcp_server_names(mcp_servers: &serde_json::Value) -> Vec<String> {
636
705
  .unwrap_or_default()
637
706
  }
638
707
 
708
+ /// swallow batch 4 C1-C4 (MUST-NOT-9/13): a CORRUPT provider-config JSON fails
709
+ /// EXPLICITLY — the old `unwrap_or_default` turned a parse failure into an empty map
710
+ /// that the subsequent write flattened back over the user's file (silent destructive
711
+ /// rewrite). A missing file stays Ok(empty) — only an existing-but-unparseable file
712
+ /// errors, naming the path + parse detail + next action; the file is never touched.
639
713
  fn read_json_object(
640
714
  path: &Path,
641
715
  ) -> Result<serde_json::Map<String, serde_json::Value>, LifecycleError> {
642
- let Ok(text) = std::fs::read_to_string(path) else {
643
- return Ok(serde_json::Map::new());
716
+ let text = match std::fs::read_to_string(path) {
717
+ Ok(text) => text,
718
+ Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
719
+ return Ok(serde_json::Map::new());
720
+ }
721
+ Err(error) => {
722
+ return Err(LifecycleError::RequirementUnmet(format!(
723
+ "profile_invalid_json: {} could not be read: {error}; fix or remove the file (or restore a backup) and relaunch",
724
+ path.display()
725
+ )));
726
+ }
644
727
  };
645
728
  match serde_json::from_str::<serde_json::Value>(&text) {
646
729
  Ok(serde_json::Value::Object(map)) => Ok(map),
647
- Ok(_) | Err(_) => Ok(serde_json::Map::new()),
730
+ Ok(other) => Err(LifecycleError::RequirementUnmet(format!(
731
+ "profile_invalid_json: {} is not a JSON object (found {}); fix or remove the file (or restore a backup) and relaunch",
732
+ path.display(),
733
+ json_kind(&other)
734
+ ))),
735
+ Err(error) => Err(LifecycleError::RequirementUnmet(format!(
736
+ "profile_invalid_json: {} could not be parsed: {error}; fix or remove the file (or restore a backup) and relaunch",
737
+ path.display()
738
+ ))),
739
+ }
740
+ }
741
+
742
+ fn json_kind(value: &serde_json::Value) -> &'static str {
743
+ match value {
744
+ serde_json::Value::Null => "null",
745
+ serde_json::Value::Bool(_) => "a boolean",
746
+ serde_json::Value::Number(_) => "a number",
747
+ serde_json::Value::String(_) => "a string",
748
+ serde_json::Value::Array(_) => "an array",
749
+ serde_json::Value::Object(_) => "an object",
648
750
  }
649
751
  }
650
752
 
@@ -732,6 +834,9 @@ fn required_profile_keys(provider: Provider, auth_mode: AuthMode) -> &'static [&
732
834
  AuthMode::Subscription => &[],
733
835
  },
734
836
  Provider::GeminiCli => &["API_KEY"],
837
+ // C-7-1 cr verdict: copilot 一期 subscription-only,无 BYOK required key 集合;
838
+ // 二期 BYOK 立项时填(向后兼容字段保留)。
839
+ Provider::Copilot => &[],
735
840
  Provider::Fake => &[],
736
841
  }
737
842
  }
@@ -794,6 +899,7 @@ pub(crate) fn parse_provider(raw: &str) -> Option<Provider> {
794
899
  "claude" => Some(Provider::Claude),
795
900
  "claude_code" => Some(Provider::ClaudeCode),
796
901
  "codex" => Some(Provider::Codex),
902
+ "copilot" => Some(Provider::Copilot),
797
903
  "gemini_cli" => Some(Provider::GeminiCli),
798
904
  "fake" => Some(Provider::Fake),
799
905
  _ => None,
@@ -184,7 +184,9 @@ impl SmokeTarget {
184
184
  kind: SmokeKind::OpenAi,
185
185
  })
186
186
  }
187
- Provider::GeminiCli | Provider::Fake => Err("unsupported_provider_smoke_skipped"),
187
+ // C-7-1 cr verdict: copilot 一期 subscription-only,无 BYOK HTTP smoke
188
+ // 入口;同 GeminiCli/Fake 走 unsupported_provider_smoke_skipped。
189
+ Provider::Copilot | Provider::GeminiCli | Provider::Fake => Err("unsupported_provider_smoke_skipped"),
188
190
  }
189
191
  }
190
192
 
@@ -508,6 +510,7 @@ fn provider_wire(provider: Provider) -> &'static str {
508
510
  Provider::Claude => "claude",
509
511
  Provider::ClaudeCode => "claude_code",
510
512
  Provider::Codex => "codex",
513
+ Provider::Copilot => "copilot",
511
514
  Provider::GeminiCli => "gemini_cli",
512
515
  Provider::Fake => "fake",
513
516
  }
@@ -131,10 +131,25 @@ pub(super) fn spawn_agent_window(
131
131
  .map(Path::new)
132
132
  })
133
133
  .unwrap_or(workspace);
134
+ let env_unset: Vec<String> = profile_launch.env_unset.iter().cloned().collect();
134
135
  let result = if into_existing_session {
135
- transport.spawn_into(session_name, &window, &plan.argv, spawn_cwd, &env)
136
+ transport.spawn_into_with_env_unset(
137
+ session_name,
138
+ &window,
139
+ &plan.argv,
140
+ spawn_cwd,
141
+ &env,
142
+ &env_unset,
143
+ )
136
144
  } else {
137
- transport.spawn_first(session_name, &window, &plan.argv, spawn_cwd, &env)
145
+ transport.spawn_first_with_env_unset(
146
+ session_name,
147
+ &window,
148
+ &plan.argv,
149
+ spawn_cwd,
150
+ &env,
151
+ &env_unset,
152
+ )
138
153
  };
139
154
  let spawn = result.map_err(|e| LifecycleError::Transport(e.to_string()))?;
140
155
  let _ = adapter.handle_startup_prompts(
@@ -344,6 +359,7 @@ pub(super) fn parse_provider(raw: &str) -> Option<Provider> {
344
359
  "claude" => Some(Provider::Claude),
345
360
  "claude_code" => Some(Provider::ClaudeCode),
346
361
  "codex" => Some(Provider::Codex),
362
+ "copilot" => Some(Provider::Copilot),
347
363
  "gemini_cli" => Some(Provider::GeminiCli),
348
364
  "fake" => Some(Provider::Fake),
349
365
  _ => None,
@@ -355,6 +371,7 @@ pub(super) fn provider_wire(provider: Provider) -> &'static str {
355
371
  Provider::Claude => "claude",
356
372
  Provider::ClaudeCode => "claude_code",
357
373
  Provider::Codex => "codex",
374
+ Provider::Copilot => "copilot",
358
375
  Provider::GeminiCli => "gemini_cli",
359
376
  Provider::Fake => "fake",
360
377
  }
@@ -90,7 +90,7 @@ fn lanea_stop_agent_unknown_agent_is_unknown_worker_not_owner_refused() {
90
90
  #[test]
91
91
  fn lanea_fork_agent_unknown_source_is_unknown_worker_before_session_check() {
92
92
  let ws = lanea_team_ws("stopped");
93
- let text = format!("{:?}", fork_agent(&ws, &aid("ghost"), &aid("newfork"), false, None));
93
+ let text = format!("{:?}", fork_agent(&ws, &aid("ghost"), &aid("newfork"), None, false, None));
94
94
  assert!(
95
95
  text.contains("unknown worker"),
96
96
  "fork_agent must reject an UNKNOWN source as 'unknown worker agent id: ghost' BEFORE the session-id \
@@ -105,7 +105,7 @@ fn lanea_fork_agent_unknown_source_is_unknown_worker_before_session_check() {
105
105
  fn lanea_fork_agent_duplicate_target_is_already_exists_before_session_check() {
106
106
  let ws = lanea_team_ws("stopped");
107
107
  // target 'bravo' already exists in the spec -> duplicate; source 'alpha' exists (its session_id is irrelevant).
108
- let text = format!("{:?}", fork_agent(&ws, &aid("alpha"), &aid("bravo"), false, None));
108
+ let text = format!("{:?}", fork_agent(&ws, &aid("alpha"), &aid("bravo"), None, false, None));
109
109
  assert!(
110
110
  text.contains("already exists"),
111
111
  "fork_agent must reject a DUPLICATE target 'bravo' as 'agent id already exists' BEFORE the session-id \
@@ -841,7 +841,7 @@ fn close_team_display_empty_workspace_closes_nothing_not_error() {
841
841
  #[test]
842
842
  fn fork_agent_on_unowned_workspace_does_not_silently_fork() {
843
843
  let ws = temp_ws();
844
- match fork_agent(&ws, &aid("src"), &aid("dst"), false, None) {
844
+ match fork_agent(&ws, &aid("src"), &aid("dst"), None, false, None) {
845
845
  // 允许:owner 门 / team 选择 / 缺 spec(provider 命令构造前的上游门)。
846
846
  Err(LifecycleError::OwnerRefused(_))
847
847
  | Err(LifecycleError::TeamSelect(_))
@@ -464,7 +464,7 @@ fn lanea_remove_rollback_restores_via_spec_state_file_path() {
464
464
  fn lanea_fork_dup_target_leader_id_is_already_exists() {
465
465
  let ws = fork_ws(DELEG_ROLE_ALPHA);
466
466
  let tx = LaneTransport::new("team-laneateam", &[]);
467
- let text = format!("{:?}", fork_agent_with_transport(&ws, &aid("alpha"), &aid("leader"), false, None, &tx));
467
+ let text = format!("{:?}", fork_agent_with_transport(&ws, &aid("alpha"), &aid("leader"), None, false, None, &tx));
468
468
  assert!(
469
469
  text.contains("already exists"),
470
470
  "golden operations.py:301-302 (_find_agent matches the leader): forking ONTO the leader id must raise \
@@ -481,7 +481,7 @@ fn lanea_fork_dup_target_leader_id_is_already_exists() {
481
481
  fn lanea_fork_window_already_exists_guard_before_spec_mutation() {
482
482
  let ws = fork_ws(DELEG_ROLE_ALPHA);
483
483
  let tx = LaneTransport::new("team-laneateam", &["newfork"]); // the target window already exists
484
- let text = format!("{:?}", fork_agent_with_transport(&ws, &aid("alpha"), &aid("newfork"), false, None, &tx));
484
+ let text = format!("{:?}", fork_agent_with_transport(&ws, &aid("alpha"), &aid("newfork"), None, false, None, &tx));
485
485
  assert!(
486
486
  text.contains("tmux window already exists for fork target: team-laneateam:newfork"),
487
487
  "golden operations.py:310-312: a pre-existing target window must raise 'tmux window already exists for \
@@ -505,7 +505,7 @@ fn lanea_fork_window_already_exists_guard_before_spec_mutation() {
505
505
  fn lanea_fork_gate_error_text_and_spec_rollback_on_adapter_arm() {
506
506
  let ws = fork_ws(DELEG_ROLE_ALPHA_COMPAT); // source alpha auth_mode=compatible_api -> native fork unsupported
507
507
  let tx = LaneTransport::new("team-laneateam", &[]);
508
- let result = fork_agent_with_transport(&ws, &aid("alpha"), &aid("newfork"), false, None, &tx);
508
+ let result = fork_agent_with_transport(&ws, &aid("alpha"), &aid("newfork"), None, false, None, &tx);
509
509
  let text = format!("{result:?}");
510
510
  assert!(
511
511
  text.contains("codex does not support native session fork"),
@@ -530,7 +530,7 @@ fn lanea_fork_gate_error_text_and_spec_rollback_on_adapter_arm() {
530
530
  fn lanea_fork_report_session_id_is_not_pane_id() {
531
531
  let ws = fork_ws(DELEG_ROLE_ALPHA); // codex+subscription -> native fork supported -> full success path
532
532
  let tx = LaneTransport::new("team-laneateam", &[]);
533
- let report = fork_agent_with_transport(&ws, &aid("alpha"), &aid("newfork"), false, None, &tx).expect("fork ok (codex subscription supports fork)");
533
+ let report = fork_agent_with_transport(&ws, &aid("alpha"), &aid("newfork"), None, false, None, &tx).expect("fork ok (codex subscription supports fork)");
534
534
  assert_ne!(
535
535
  report.session_id,
536
536
  Some(crate::provider::SessionId::new("%newfork")),
@@ -226,6 +226,7 @@ fn fork_agent_proceeds_past_owner_gate_for_unowned_workspace() {
226
226
  &ws,
227
227
  &AgentId::new("w1"),
228
228
  &AgentId::new("w1-fork"),
229
+ None,
229
230
  false,
230
231
  None,
231
232
  &transport,
@@ -960,7 +961,8 @@ fn quick_start_running_agent_state_shape_after_spawn_is_golden() {
960
961
  assert!(agent["captured_at"].is_null());
961
962
  assert!(agent["captured_via"].is_null());
962
963
  assert!(agent["attribution_confidence"].is_null());
963
- assert_eq!(agent["spawn_cwd"], json!(team.to_string_lossy()));
964
+ // D5 (#264) / Python launch/core.py:253 — fresh launch persists spawn_cwd=workspace.
965
+ assert_eq!(agent["spawn_cwd"], json!(workspace.to_string_lossy()));
964
966
  assert_eq!(agent["spawned_at"], json!(FIXED_SPAWNED_AT));
965
967
  assert_eq!(
966
968
  agent["pane_id"],
@@ -144,9 +144,13 @@ impl WorkerCommandAgent {
144
144
  pub(crate) fn compile_worker_system_prompt(
145
145
  agent: &WorkerCommandAgent,
146
146
  ) -> Result<String, LifecycleError> {
147
+ // Python prompt.py:39 — chunks = [identity, TEAMMATE_SYSTEM_PROMPT, ...]: the worker
148
+ // identity line anchors the very first section (live Python worker argv confirms).
149
+ // C-1 cr verdict / B2 灵魂件 — identity 必须 FIRST(MUST-4 行为层守:空白上下文问
150
+ // "你是谁"必须先答 Team Agent worker 身份)。runtime contract 跟后。
147
151
  let mut chunks = vec![
148
- runtime_contract_section(),
149
152
  identity_section(agent),
153
+ runtime_contract_section(),
150
154
  role_body(agent)?,
151
155
  ];
152
156
  if let Some(contract) = output_contract(agent) {
@@ -225,23 +229,42 @@ fn output_contract(agent: &WorkerCommandAgent) -> Option<String> {
225
229
 
226
230
  fn permission_notes(agent: &WorkerCommandAgent) -> Result<Option<String>, LifecycleError> {
227
231
  let permissions = resolve_agent_permissions(agent, agent.provider)?;
228
- if !permissions.has_prompt_only {
229
- return Ok(None);
230
- }
231
- let mut prompt_only: Vec<String> = permissions
232
+ // C-2-1/C-2-2 cr verdict — Copilot 一期 framework 不替决 fs_read/fs_list/git_diff/
233
+ // provider_builtin(provider prompt 控);为诚实(MUST-NOT-13)在 system prompt 内
234
+ // 总是声明这些 provider-level prompt_only 工具,即便角色未显式声明。
235
+ let provider_prompt_only_extras = provider_default_prompt_only_tools(agent.provider);
236
+ let mut prompt_only: std::collections::BTreeSet<String> = permissions
232
237
  .resolved_tools
233
238
  .iter()
234
239
  .filter(|tool| tool.enforcement == Enforcement::PromptOnly)
235
240
  .filter_map(|tool| serde_json::to_value(tool.tool).ok())
236
241
  .filter_map(|value| value.as_str().map(str::to_string))
237
242
  .collect();
238
- prompt_only.sort();
243
+ for tool in provider_prompt_only_extras {
244
+ prompt_only.insert((*tool).to_string());
245
+ }
246
+ if prompt_only.is_empty() {
247
+ return Ok(None);
248
+ }
249
+ let prompt_only: Vec<String> = prompt_only.into_iter().collect();
239
250
  Ok(Some(format!(
240
251
  "Permission note: these tools are prompt-only for this provider and not hard-enforced: {}",
241
252
  prompt_only.join(", ")
242
253
  )))
243
254
  }
244
255
 
256
+ /// C-2-1 cr verdict — provider-level prompt_only tools that the framework cannot
257
+ /// hard-enforce. The system prompt declares them so the worker is honest about
258
+ /// where consent gates actually live (provider prompt vs framework).
259
+ fn provider_default_prompt_only_tools(provider: Provider) -> &'static [&'static str] {
260
+ match provider {
261
+ // C-2-1: fs_read / fs_list / git_diff / provider_builtin 由 provider prompt
262
+ // 控制,framework 不替决(prompt_only 诚实声明)。
263
+ Provider::Copilot => &["fs_list", "fs_read", "git_diff", "provider_builtin"],
264
+ _ => &[],
265
+ }
266
+ }
267
+
245
268
  #[cfg(test)]
246
269
  mod tests {
247
270
  use super::*;
@@ -298,7 +321,14 @@ mod tests {
298
321
  }
299
322
 
300
323
  #[test]
301
- fn system_prompt_uses_runtime_contract_identity_role_output_permissions_order() {
324
+ fn system_prompt_uses_identity_then_runtime_contract_python_order() {
325
+ // #264 D6: Python truth source (prompt.py:39, live ps confirmed) builds
326
+ // chunks = [identity, TEAMMATE_SYSTEM_PROMPT, role_body, output, permissions].
327
+ // The previous assertion locked the inverted contract-first order with no
328
+ // Python evidence; this is the corrected golden.
329
+ // 0.3.5 union (copilot v2 C-1 / B2 MUST-4 行为层守): identity 必须 FIRST —
330
+ // 空白上下文问"你是谁"的第一行答案必须先答 Team Agent worker 身份;
331
+ // Copilot 适配的 C-1-3 行为层要求与其它 provider 同步。
302
332
  let agent = WorkerCommandAgent {
303
333
  id: Some("coder".to_string()),
304
334
  provider: Provider::Codex,
@@ -309,16 +339,21 @@ mod tests {
309
339
  output_contract_format: Some("result_envelope_v1".to_string()),
310
340
  };
311
341
  let prompt = compile_worker_system_prompt(&agent).unwrap();
342
+ assert!(
343
+ prompt.starts_with("You are Team Agent worker `coder` with role `Runtime Developer`."),
344
+ "compiled prompt must start with the identity section (Python prompt.py:39); head={:?}",
345
+ prompt.chars().take(120).collect::<String>()
346
+ );
347
+ let identity = prompt.find("worker `coder`").unwrap();
312
348
  let runtime = prompt
313
349
  .find(RUNTIME_CONTRACT_SECTION.lines().next().unwrap_or(""))
314
350
  .unwrap();
315
- let identity = prompt.find("worker `coder`").unwrap();
316
351
  let role = prompt.find("Implement the assigned slice.").unwrap();
317
352
  let output = prompt
318
353
  .find("Final completion must call team_orchestrator.report_result exactly once")
319
354
  .unwrap();
320
355
  let permissions = prompt.find("Permission note:").unwrap();
321
- assert!(runtime < identity && identity < role && role < output && output < permissions);
356
+ assert!(identity < runtime && runtime < role && role < output && output < permissions);
322
357
  let slowdown_phrase = format!("500/{}", 500 + 29);
323
358
  assert!(prompt.contains(&slowdown_phrase));
324
359
  assert!(prompt.contains("Runtime Developer"));
@@ -88,12 +88,13 @@ pub(crate) fn fork_agent(
88
88
  as_agent_id: &str,
89
89
  label: Option<&str>,
90
90
  ) -> ToolResult {
91
- let _ = label;
92
91
  let lifecycle_workspace = lifecycle_workspace(workspace, owner_team, false)?;
92
+ // operations.py:315 — the label becomes the forked agent's role.
93
93
  let report = crate::lifecycle::launch::fork_agent(
94
94
  &lifecycle_workspace,
95
95
  &AgentId::new(source_agent_id),
96
96
  &AgentId::new(as_agent_id),
97
+ label,
97
98
  false,
98
99
  owner_team.map(TeamKey::as_str),
99
100
  )
@@ -1,7 +1,20 @@
1
1
  #[test]
2
2
  fn dispatch_send_message_worker_accepted_returned_verbatim() {
3
+ // A-7: accepted requires a REAL stored message_id (no fabricated ids), so the
4
+ // workspace seeds a running worker-1 the delivery layer can actually queue for.
5
+ let ws = unique_ws("dispatch-accepted");
6
+ crate::state::persist::save_runtime_state(
7
+ &ws,
8
+ &serde_json::json!({
9
+ "session_name": "team-x",
10
+ "agents": {
11
+ "worker-1": {"status": "running", "agent_id": "worker-1", "window": "worker-1"},
12
+ },
13
+ }),
14
+ )
15
+ .unwrap();
3
16
  let tools = TeamOrchestratorTools::with_identity(
4
- &unique_ws("dispatch-accepted"),
17
+ &ws,
5
18
  Some(AgentId::new("leader")), // legacy single-team bypasses cross-team refusal
6
19
  None,
7
20
  );
@@ -96,8 +96,22 @@
96
96
  // refusal first (worker-1 not in visible peers) -> PeerNotInScope. owner_team_id=None
97
97
  // (legacy single-team) bypasses that, isolating the worker-recipient accepted path.
98
98
  // The cross-team refusal has its own tests (refuse_cross_team_peer_* / send_message_cross_team_*).
99
+ // A-7: the accepted path needs a REAL stored message_id (tools.py:175-181 —
100
+ // fabricated ids are gone), so the workspace seeds a running worker-1 the
101
+ // delivery layer can actually queue for.
102
+ let ws = unique_ws("send-worker");
103
+ crate::state::persist::save_runtime_state(
104
+ &ws,
105
+ &serde_json::json!({
106
+ "session_name": "team-x",
107
+ "agents": {
108
+ "worker-1": {"status": "running", "agent_id": "worker-1", "window": "worker-1"},
109
+ },
110
+ }),
111
+ )
112
+ .unwrap();
99
113
  let tools = TeamOrchestratorTools::with_identity(
100
- &unique_ws("send-worker"),
114
+ &ws,
101
115
  Some(AgentId::new("leader")),
102
116
  None,
103
117
  );
@@ -189,11 +189,12 @@ impl TeamOrchestratorTools {
189
189
  };
190
190
  if is_worker_recipient(to) {
191
191
  let out = messaging::send_message(&self.workspace, to, content, &opts).map_err(tool_runtime_error)?;
192
+ // tools.py:175-181 — accepted+poll_via ONLY for a REAL message_id; any other
193
+ // outcome falls back to the compacted direct result. Never invent an
194
+ // `mcp_<timestamp>` id: it does not exist in the store and makes the
195
+ // advertised `team-agent inbox <id>` poll a dead end.
192
196
  let message_id = match out.message_id {
193
197
  Some(message_id) if out.ok => message_id,
194
- None if self.owner_team_id.is_none() => {
195
- format!("mcp_{}", chrono::Utc::now().timestamp_micros())
196
- }
197
198
  _ => {
198
199
  let value = delivery_outcome_value(&out);
199
200
  let ok = compact_tool_result(&value)?;
@@ -229,7 +230,13 @@ impl TeamOrchestratorTools {
229
230
  let team_widens = match (owner_team.as_ref(), requested_team.as_deref()) {
230
231
  (_, None) => false,
231
232
  (Some(owner), Some(requested)) => {
232
- let state = load_runtime_state(&self.workspace).unwrap_or(serde_json::json!({}));
233
+ // swallow batch 4 C6: an unreadable state cannot verify the requested
234
+ // team — fail closed (structured transient refusal), never compare
235
+ // against an empty substitute.
236
+ let state = match load_runtime_state(&self.workspace) {
237
+ Ok(state) => state,
238
+ Err(error) => return Err(self.scope_unverifiable(&error.to_string())),
239
+ };
233
240
  let requested_canonical = crate::state::projection::resolve_owner_team_id(&state, requested)
234
241
  .canonical_key()
235
242
  .unwrap_or(requested)
@@ -463,7 +470,18 @@ impl TeamOrchestratorTools {
463
470
  "idle_fallback" => Some(messaging::AlertType::IdleFallback),
464
471
  "cross_worker_deadlock" => Some(messaging::AlertType::CrossWorkerDeadlock),
465
472
  "all" => None,
466
- _ => None,
473
+ // scheduler.py:268-273 — an unknown alert_type refuses with the Python
474
+ // literal instead of silently widening to suppressing ALL alert families.
475
+ _ => {
476
+ return Ok(ToolOk {
477
+ fields: object_fields(serde_json::json!({
478
+ "ok": false,
479
+ "status": "refused",
480
+ "reason": "invalid_alert_type",
481
+ "alert_type": alert_type,
482
+ })),
483
+ });
484
+ }
467
485
  };
468
486
  let suppressed_by = self.agent_id.as_ref().map(AgentId::as_str).unwrap_or("leader");
469
487
  messaging::stuck_cancel(&self.workspace, agent_id, alert, suppressed_by)
@@ -571,7 +589,12 @@ impl TeamOrchestratorTools {
571
589
  // Unresolved/ambiguous owner scope emits mcp.scope_refused; never fallback
572
590
  // to active/top-level/sibling teams in a multi-team state.
573
591
  let Some(owner_team_id) = &self.owner_team_id else {
574
- let state = load_runtime_state(&self.workspace).unwrap_or(serde_json::json!({}));
592
+ // swallow batch 4 C5-C8 (MUST-16 fail-closed): the runtime state is the
593
+ // scope truth source — when it cannot be read, the gate REFUSES with a
594
+ // structured transient scope_unverifiable instead of silently treating
595
+ // the workspace as legacy single-team (silent privilege widening).
596
+ let state = load_runtime_state(&self.workspace)
597
+ .map_err(|error| self.scope_unverifiable(&error.to_string()))?;
575
598
  if state
576
599
  .get("teams")
577
600
  .and_then(Value::as_object)
@@ -582,7 +605,7 @@ impl TeamOrchestratorTools {
582
605
  return Ok(None);
583
606
  };
584
607
  let state = load_runtime_state(&self.workspace)
585
- .map_err(|_| self.scope_refused("owner team could not be resolved"))?;
608
+ .map_err(|error| self.scope_unverifiable(&error.to_string()))?;
586
609
  match canonicalize_owner_team_id(&state, owner_team_id.as_str()) {
587
610
  Some(team) => Ok(Some(TeamKey::new(team))),
588
611
  None => Err(self.scope_refused("owner team could not be resolved")),
@@ -591,7 +614,12 @@ impl TeamOrchestratorTools {
591
614
 
592
615
  fn canonical_owner_team_key_for_mcp(&self) -> Result<Option<TeamKey>, ToolError> {
593
616
  let Some(owner_team_id) = &self.owner_team_id else {
594
- let state = load_runtime_state(&self.workspace).unwrap_or(serde_json::json!({}));
617
+ // swallow batch 4 C5-C8 (MUST-16 fail-closed): the runtime state is the
618
+ // scope truth source — when it cannot be read, the gate REFUSES with a
619
+ // structured transient scope_unverifiable instead of silently treating
620
+ // the workspace as legacy single-team (silent privilege widening).
621
+ let state = load_runtime_state(&self.workspace)
622
+ .map_err(|error| self.scope_unverifiable(&error.to_string()))?;
595
623
  if state
596
624
  .get("teams")
597
625
  .and_then(Value::as_object)
@@ -602,13 +630,41 @@ impl TeamOrchestratorTools {
602
630
  return Ok(None);
603
631
  };
604
632
  let state = load_runtime_state(&self.workspace)
605
- .map_err(|_| self.scope_refused("owner team could not be resolved"))?;
633
+ .map_err(|error| self.scope_unverifiable(&error.to_string()))?;
606
634
  match canonicalize_owner_team_id(&state, owner_team_id.as_str()) {
607
635
  Some(team) => Ok(Some(TeamKey::new(team))),
608
636
  None => Err(self.scope_refused("owner team could not be resolved")),
609
637
  }
610
638
  }
611
639
 
640
+ /// swallow batch 4 C5/C6/N38: the structured FAIL-CLOSED refusal for "the scope
641
+ /// could not be verified" (state read failed). Transient by design — the same call
642
+ /// passes once the state is readable again; the caller's self-reported scope is
643
+ /// never trusted as a fallback (MUST-16 ceiling).
644
+ fn scope_unverifiable(&self, io_error: &str) -> ToolError {
645
+ let _ = EventLog::new(&self.workspace).write(
646
+ "mcp.scope_state_read_failed",
647
+ serde_json::json!({
648
+ "reason": "scope_unverifiable",
649
+ "requested_owner_team_id": self.owner_team_id.as_ref().map(TeamKey::as_str),
650
+ "error": io_error,
651
+ }),
652
+ );
653
+ let mut extra = serde_json::Map::new();
654
+ extra.insert("status".to_string(), Value::String("refused".to_string()));
655
+ extra.insert("kind".to_string(), Value::String("scope_unverifiable".to_string()));
656
+ extra.insert(
657
+ "next_action".to_string(),
658
+ Value::String("retry shortly or check the runtime state path".to_string()),
659
+ );
660
+ ToolError {
661
+ reason: ToolErrorReason::McpScopeRefused,
662
+ exc_type: "McpScopeUnverifiable".to_string(),
663
+ message: format!("scope_unverifiable: state read failed: {io_error}"),
664
+ extra,
665
+ }
666
+ }
667
+
612
668
  fn scope_refused(&self, message: &str) -> ToolError {
613
669
  let canonical_owner_team_id = self.canonical_owner_team_key_for_event();
614
670
  let _ = EventLog::new(&self.workspace).write(
@@ -444,7 +444,8 @@ pub(crate) fn dispatch_tool(tools: &TeamOrchestratorTools, tool: McpTool, args:
444
444
  McpTool::StuckList => tools.stuck_list(),
445
445
  McpTool::StuckCancel => tools.stuck_cancel(
446
446
  args.get("agent_id").and_then(Value::as_str).unwrap_or(""),
447
- args.get("alert_type").and_then(Value::as_str).unwrap_or("all"),
447
+ // tools.py:351 — the MCP default alert_type is "stuck", not "all".
448
+ args.get("alert_type").and_then(Value::as_str).unwrap_or("stuck"),
448
449
  ),
449
450
  }
450
451
  }