@team-agent/installer 0.3.4 → 0.3.5
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.
- package/Cargo.lock +1 -1
- package/Cargo.toml +1 -1
- package/crates/team-agent/src/cli/adapters.rs +8 -0
- package/crates/team-agent/src/cli/diagnose.rs +51 -10
- package/crates/team-agent/src/cli/emit.rs +2 -1
- package/crates/team-agent/src/cli/mod.rs +217 -80
- package/crates/team-agent/src/cli/send.rs +1 -0
- package/crates/team-agent/src/cli/status_port.rs +135 -7
- package/crates/team-agent/src/cli/tests/missing_subcommands.rs +8 -1
- package/crates/team-agent/src/cli/tests/mod.rs +1 -0
- package/crates/team-agent/src/cli/tests/shutdown_kill_plan.rs +39 -0
- package/crates/team-agent/src/cli/types.rs +5 -1
- package/crates/team-agent/src/coordinator/backoff.rs +57 -9
- package/crates/team-agent/src/coordinator/health.rs +65 -2
- package/crates/team-agent/src/coordinator/runtime_detectors.rs +28 -16
- package/crates/team-agent/src/coordinator/tests/a0_lostupdate.rs +87 -0
- package/crates/team-agent/src/coordinator/tests/mod.rs +1 -0
- package/crates/team-agent/src/coordinator/tick.rs +195 -43
- package/crates/team-agent/src/leader/helpers.rs +2 -0
- package/crates/team-agent/src/leader/rediscover.rs +1 -0
- package/crates/team-agent/src/leader/start.rs +9 -1
- package/crates/team-agent/src/leader/takeover.rs +18 -1
- package/crates/team-agent/src/lifecycle/launch.rs +434 -29
- package/crates/team-agent/src/lifecycle/profile_launch.rs +110 -4
- package/crates/team-agent/src/lifecycle/profile_smoke.rs +4 -1
- package/crates/team-agent/src/lifecycle/restart/common.rs +19 -2
- package/crates/team-agent/src/lifecycle/tests/agent_ops.rs +2 -2
- package/crates/team-agent/src/lifecycle/tests/core.rs +1 -1
- package/crates/team-agent/src/lifecycle/tests/lane_ops.rs +4 -4
- package/crates/team-agent/src/lifecycle/tests/launch_spawn.rs +3 -1
- package/crates/team-agent/src/lifecycle/worker_command_context.rs +44 -9
- package/crates/team-agent/src/mcp_server/lifecycle_tools/agent_ops.rs +2 -1
- package/crates/team-agent/src/mcp_server/tests/scoped.rs +14 -1
- package/crates/team-agent/src/mcp_server/tests/send.rs +15 -1
- package/crates/team-agent/src/mcp_server/tools.rs +65 -9
- package/crates/team-agent/src/mcp_server/wire.rs +2 -1
- package/crates/team-agent/src/message_store.rs +80 -0
- package/crates/team-agent/src/messaging/results.rs +76 -5
- package/crates/team-agent/src/messaging/send.rs +3 -1
- package/crates/team-agent/src/messaging/types.rs +15 -1
- package/crates/team-agent/src/messaging/watchers.rs +68 -30
- package/crates/team-agent/src/model/enums.rs +7 -1
- package/crates/team-agent/src/model/permissions.rs +7 -0
- package/crates/team-agent/src/model/spec.rs +3 -1
- package/crates/team-agent/src/provider/adapter.rs +472 -7
- package/crates/team-agent/src/provider/classify.rs +6 -2
- package/crates/team-agent/src/provider/faults.rs +3 -2
- package/crates/team-agent/src/provider/startup_prompt.rs +25 -7
- package/crates/team-agent/src/provider/types.rs +11 -0
- package/crates/team-agent/src/session_capture.rs +1 -0
- package/crates/team-agent/src/state/persist.rs +95 -19
- package/crates/team-agent/src/tmux_backend/tests.rs +8 -7
- package/crates/team-agent/src/tmux_backend.rs +80 -6
- package/crates/team-agent/src/transport.rs +32 -0
- 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
|
-
|
|
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
|
|
643
|
-
|
|
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(
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
let
|
|
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
|
-
|
|
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
|
|
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!(
|
|
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
|
-
&
|
|
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
|
-
&
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(|
|
|
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
|
-
|
|
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(|
|
|
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
|
-
|
|
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
|
}
|