@team-agent/installer 0.5.114 → 0.5.116
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/Cargo.lock +1 -1
- package/Cargo.toml +1 -1
- package/crates/team-agent/src/cli/emit.rs +2 -2
- package/crates/team-agent/src/cli/mod.rs +155 -19
- package/crates/team-agent/src/cli/models.rs +52 -1
- package/crates/team-agent/src/cli/shutdown_socket.rs +288 -0
- package/crates/team-agent/src/cli/spec.rs +1 -1
- package/crates/team-agent/src/cli/tests/shutdown_kill_plan.rs +11 -4
- package/crates/team-agent/src/contract_runtime/backend.rs +490 -97
- package/crates/team-agent/src/contract_runtime/commands.rs +326 -0
- package/crates/team-agent/src/contract_runtime/config.rs +39 -8
- package/crates/team-agent/src/contract_runtime/config_tests.rs +56 -0
- package/crates/team-agent/src/contract_runtime/discovery.rs +218 -40
- package/crates/team-agent/src/contract_runtime/framework.rs +38 -5
- package/crates/team-agent/src/contract_runtime/mcp.rs +3 -0
- package/crates/team-agent/src/contract_runtime/mod.rs +2 -0
- package/crates/team-agent/src/contract_runtime/pump.rs +97 -33
- package/crates/team-agent/src/contract_runtime/registry.rs +248 -4
- package/crates/team-agent/src/contract_runtime/workspace.rs +228 -0
- package/crates/team-agent/src/leader/start.rs +93 -5
- package/crates/team-agent/src/lifecycle/launch/quick_start.rs +99 -1
- package/crates/team-agent/src/lifecycle/launch/state_projection.rs +3 -5
- package/crates/team-agent/src/lifecycle/launch.rs +6 -0
- package/crates/team-agent/src/lifecycle/restart/agent.rs +4 -0
- package/crates/team-agent/src/lifecycle/restart/rebuild.rs +109 -58
- package/crates/team-agent/src/lifecycle/restart/selection.rs +63 -7
- package/crates/team-agent/src/lifecycle/role_config.rs +17 -1
- package/crates/team-agent/src/provider/session/capture.rs +24 -1
- package/crates/team-agent/src/state/persist.rs +72 -2
- package/crates/team-agent/src/state/projection.rs +29 -0
- package/crates/team-agent/src/state/repository/tests.rs +70 -0
- package/crates/team-agent/src/state/repository.rs +5 -1
- package/package.json +4 -4
package/Cargo.lock
CHANGED
package/Cargo.toml
CHANGED
|
@@ -448,8 +448,8 @@ pub(super) fn command_help(command: Option<&str>) -> String {
|
|
|
448
448
|
"team-agent inbox leader -n 3\nteam-agent inbox worker --limit 5\nteam-agent inbox leader --workspace . --team help-demo --json", "Wait for a natural reply, then check team-agent status if needed. Do not inspect worker terminal content."),
|
|
449
449
|
"status" => ("Optional <agent> selects one agent. --summary/--detail retain compatibility and do not add fields.\nNine fields: name/provider/model/effort/runtime_status/activity/health/session_name/tmux_command.\nRuntime, activity and health are distinct. unknown means insufficient evidence; null means unset. No default model is guessed.",
|
|
450
450
|
"team-agent status\nteam-agent status worker\nteam-agent status --workspace . --team help-demo --json", "Read replies with team-agent inbox leader -n 3; diagnose problems with team-agent doctor --workspace ."),
|
|
451
|
-
"models" => ("--provider selects the provider, default pi. QUERY or --search performs keyword search; they are mutually exclusive.\nUse
|
|
452
|
-
"team-agent models --provider pi\nteam-agent models --provider pi --search luna\nteam-agent models --provider
|
|
451
|
+
"models" => ("--provider selects the provider, default pi. QUERY or --search performs keyword search; they are mutually exclusive.\nUse cursor for the contract-native Cursor catalog; cursor_agent retains the legacy entrypoint. Copilot has no model discovery entrypoint here. Results come from the native catalog, not guesses.",
|
|
452
|
+
"team-agent models --provider pi\nteam-agent models --provider pi --search luna\nteam-agent models --provider cursor --json", "Use the exact returned model name in a role file or add-agent --model before launch."),
|
|
453
453
|
"restart" => ("WORKSPACE defaults to the current project; saved sessions are preferred.\nUse --allow-fresh only after explicit authorization to lose saved context. --session-converge-deadline sets the wait in seconds.",
|
|
454
454
|
"team-agent restart .\nteam-agent restart . --team help-demo --json", "Check status after restart. Obtain authorization before creating a fresh session; takeover is not a substitute for restart."),
|
|
455
455
|
"shutdown" => ("Shuts down only the selected workspace/team. Logs are retained by default; --keep-logs remains compatible.\n--json reports scope, degradation and owned residuals. Success does not mean other teams were shut down.",
|
|
@@ -57,6 +57,8 @@ pub(crate) const STATUS_REMINDER: &str =
|
|
|
57
57
|
"Next: use team-agent inbox leader -n 3 for replies. Status is not proof of task completion; do not inspect worker terminal content.";
|
|
58
58
|
|
|
59
59
|
pub mod adapters;
|
|
60
|
+
#[cfg(unix)]
|
|
61
|
+
mod shutdown_socket;
|
|
60
62
|
pub mod attach_app_server_leader;
|
|
61
63
|
pub mod diagnose;
|
|
62
64
|
pub mod emit;
|
|
@@ -1193,13 +1195,17 @@ pub mod lifecycle_port {
|
|
|
1193
1195
|
}
|
|
1194
1196
|
let workspace_socket = crate::tmux_backend::socket_name_for_workspace(workspace);
|
|
1195
1197
|
let source = state.get("tmux_socket_source").and_then(Value::as_str);
|
|
1196
|
-
let owned = source == Some("workspace")
|
|
1197
|
-
|
|
1198
|
-
&& endpoint_matches_workspace_socket(&endpoint, workspace, &workspace_socket);
|
|
1198
|
+
let owned = (source == Some("workspace") || source.is_none())
|
|
1199
|
+
&& endpoint_matches_workspace_socket(&endpoint, workspace, &workspace_socket);
|
|
1199
1200
|
if !owned {
|
|
1200
1201
|
return None;
|
|
1201
1202
|
}
|
|
1202
1203
|
let socket_file = socket_file_for_endpoint(&endpoint);
|
|
1204
|
+
if socket_file.as_ref().and_then(|path| path.file_name())
|
|
1205
|
+
!= Some(std::ffi::OsStr::new(&workspace_socket))
|
|
1206
|
+
{
|
|
1207
|
+
return None;
|
|
1208
|
+
}
|
|
1203
1209
|
Some(OwnedShutdownEndpoint {
|
|
1204
1210
|
endpoint,
|
|
1205
1211
|
socket_file,
|
|
@@ -1234,9 +1240,18 @@ pub mod lifecycle_port {
|
|
|
1234
1240
|
event_log: &crate::event_log::EventLog,
|
|
1235
1241
|
) -> OwnedEndpointCleanup {
|
|
1236
1242
|
let mut cleanup = OwnedEndpointCleanup::default();
|
|
1237
|
-
|
|
1238
|
-
|
|
1239
|
-
|
|
1243
|
+
let targets = match transport.list_targets() {
|
|
1244
|
+
Ok(targets) => targets,
|
|
1245
|
+
Err(error) => {
|
|
1246
|
+
cleanup.error = Some(format!("owned endpoint session probe failed: {error}"));
|
|
1247
|
+
if let Some(path) = endpoint.socket_file.as_ref() {
|
|
1248
|
+
cleanup.skipped_files.push(path.display().to_string());
|
|
1249
|
+
cleanup.residual_files.push(path.display().to_string());
|
|
1250
|
+
}
|
|
1251
|
+
return cleanup;
|
|
1252
|
+
}
|
|
1253
|
+
};
|
|
1254
|
+
cleanup.remaining_sessions = targets
|
|
1240
1255
|
.into_iter()
|
|
1241
1256
|
.map(|pane| pane.session.as_str().to_string())
|
|
1242
1257
|
.collect::<std::collections::BTreeSet<_>>()
|
|
@@ -1257,20 +1272,136 @@ pub mod lifecycle_port {
|
|
|
1257
1272
|
);
|
|
1258
1273
|
return cleanup;
|
|
1259
1274
|
}
|
|
1260
|
-
//
|
|
1261
|
-
//
|
|
1262
|
-
//
|
|
1263
|
-
|
|
1275
|
+
// Empty enumeration is not server-death proof. Never kill-server:
|
|
1276
|
+
// remove only the exact workspace socket after an independent refused
|
|
1277
|
+
// nonblocking connection and fresh owned parent/socket inode fences.
|
|
1278
|
+
#[cfg(unix)]
|
|
1279
|
+
if let Some(path) = endpoint.socket_file.as_ref() {
|
|
1280
|
+
use super::shutdown_socket::{remove_dead_socket, SocketCleanup};
|
|
1281
|
+
match remove_dead_socket(path) {
|
|
1282
|
+
Ok(SocketCleanup::Removed) => {
|
|
1283
|
+
cleanup.removed_files.push(path.display().to_string());
|
|
1284
|
+
}
|
|
1285
|
+
Ok(SocketCleanup::Absent) => {}
|
|
1286
|
+
Ok(SocketCleanup::Preserved) => {
|
|
1287
|
+
cleanup.skipped_files.push(path.display().to_string());
|
|
1288
|
+
cleanup.residual_files.push(path.display().to_string());
|
|
1289
|
+
}
|
|
1290
|
+
Err(error) => {
|
|
1291
|
+
cleanup.skipped_files.push(path.display().to_string());
|
|
1292
|
+
cleanup.residual_files.push(path.display().to_string());
|
|
1293
|
+
cleanup.error = Some(format!("owned socket cleanup refused: {error}"));
|
|
1294
|
+
}
|
|
1295
|
+
}
|
|
1296
|
+
}
|
|
1264
1297
|
let _ = event_log.write(
|
|
1265
|
-
"shutdown.
|
|
1298
|
+
"shutdown.owned_endpoint_cleanup",
|
|
1266
1299
|
json!({
|
|
1267
1300
|
"endpoint": endpoint.endpoint,
|
|
1268
|
-
"
|
|
1301
|
+
"removed_files": cleanup.removed_files,
|
|
1302
|
+
"skipped_files": cleanup.skipped_files,
|
|
1303
|
+
"residual_files": cleanup.residual_files,
|
|
1304
|
+
"error": cleanup.error,
|
|
1269
1305
|
}),
|
|
1270
1306
|
);
|
|
1271
1307
|
cleanup
|
|
1272
1308
|
}
|
|
1273
1309
|
|
|
1310
|
+
#[cfg(all(test, unix))]
|
|
1311
|
+
#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
|
|
1312
|
+
mod shutdown_socket_tests {
|
|
1313
|
+
use super::*;
|
|
1314
|
+
use crate::transport::test_support::OfflineTransport;
|
|
1315
|
+
use crate::transport::{PaneId, PaneInfo};
|
|
1316
|
+
use std::os::unix::fs::PermissionsExt;
|
|
1317
|
+
use std::os::unix::net::UnixListener;
|
|
1318
|
+
|
|
1319
|
+
#[test]
|
|
1320
|
+
fn shutdown_socket_selection_rejects_shared_external_and_false_workspace_sources() {
|
|
1321
|
+
let workspace = Path::new("/tmp").join(format!("ta-shutdown-select-{}-{}",
|
|
1322
|
+
std::process::id(), chrono::Utc::now().timestamp_nanos_opt().unwrap()));
|
|
1323
|
+
std::fs::create_dir(&workspace).unwrap();
|
|
1324
|
+
let workspace = workspace.canonicalize().unwrap();
|
|
1325
|
+
let name = crate::tmux_backend::socket_name_for_workspace(&workspace);
|
|
1326
|
+
let transport = OfflineTransport::new();
|
|
1327
|
+
let selected = json!({"tmux_socket": name, "tmux_socket_source": "workspace"});
|
|
1328
|
+
assert!(owned_shutdown_endpoint(&workspace, &selected, &transport).is_some());
|
|
1329
|
+
for delta in [
|
|
1330
|
+
json!({"is_external_leader": true}),
|
|
1331
|
+
json!({"tmux_socket_source": "leader_env"}),
|
|
1332
|
+
json!({"tmux_socket": "default"}),
|
|
1333
|
+
json!({"tmux_socket": "/foreign/ta-not-this-workspace"}),
|
|
1334
|
+
json!({"tmux_socket_source": "shared"}),
|
|
1335
|
+
] {
|
|
1336
|
+
let mut state = selected.clone();
|
|
1337
|
+
state.as_object_mut().unwrap().extend(delta.as_object().unwrap().clone());
|
|
1338
|
+
assert!(owned_shutdown_endpoint(&workspace, &state, &transport).is_none());
|
|
1339
|
+
}
|
|
1340
|
+
std::fs::remove_dir(workspace).unwrap();
|
|
1341
|
+
}
|
|
1342
|
+
|
|
1343
|
+
#[test]
|
|
1344
|
+
fn shutdown_socket_cleanup_removes_only_dead_socket_and_reports_live_residual() {
|
|
1345
|
+
let root = Path::new("/tmp").join(format!("ta-root-shutdown-{}-{}",
|
|
1346
|
+
std::process::id(), chrono::Utc::now().timestamp_nanos_opt().unwrap()));
|
|
1347
|
+
std::fs::create_dir(&root).unwrap();
|
|
1348
|
+
std::fs::set_permissions(&root, std::fs::Permissions::from_mode(0o700)).unwrap();
|
|
1349
|
+
let root = root.canonicalize().unwrap();
|
|
1350
|
+
let path = root.join("owned.sock");
|
|
1351
|
+
let endpoint = OwnedShutdownEndpoint {
|
|
1352
|
+
endpoint: path.display().to_string(), socket_file: Some(path.clone()),
|
|
1353
|
+
};
|
|
1354
|
+
let log = crate::event_log::EventLog::new(&root);
|
|
1355
|
+
let transport = OfflineTransport::new();
|
|
1356
|
+
let listener = UnixListener::bind(&path).unwrap();
|
|
1357
|
+
let live = cleanup_owned_empty_endpoint(&transport, &endpoint, &log);
|
|
1358
|
+
assert_eq!(live.residual_files, vec![path.display().to_string()]);
|
|
1359
|
+
assert!(live.removed_files.is_empty());
|
|
1360
|
+
assert!(path.exists());
|
|
1361
|
+
drop(listener);
|
|
1362
|
+
let dead = cleanup_owned_empty_endpoint(&transport, &endpoint, &log);
|
|
1363
|
+
assert_eq!(dead.removed_files, vec![path.display().to_string()]);
|
|
1364
|
+
assert!(dead.residual_files.is_empty());
|
|
1365
|
+
assert!(dead.error.is_none());
|
|
1366
|
+
assert!(!path.exists());
|
|
1367
|
+
assert!(!transport.calls().contains(&"kill_server"));
|
|
1368
|
+
std::fs::remove_dir_all(root).unwrap();
|
|
1369
|
+
}
|
|
1370
|
+
|
|
1371
|
+
#[test]
|
|
1372
|
+
fn shutdown_socket_cleanup_preserves_sibling_and_unknown_session_probe() {
|
|
1373
|
+
let root = Path::new("/tmp").join(format!("ta-shutdown-sibling-{}-{}",
|
|
1374
|
+
std::process::id(), chrono::Utc::now().timestamp_nanos_opt().unwrap()));
|
|
1375
|
+
std::fs::create_dir(&root).unwrap();
|
|
1376
|
+
std::fs::set_permissions(&root, std::fs::Permissions::from_mode(0o700)).unwrap();
|
|
1377
|
+
let root = root.canonicalize().unwrap();
|
|
1378
|
+
let path = root.join("owned.sock");
|
|
1379
|
+
drop(UnixListener::bind(&path).unwrap());
|
|
1380
|
+
let endpoint = OwnedShutdownEndpoint {
|
|
1381
|
+
endpoint: path.display().to_string(), socket_file: Some(path.clone()),
|
|
1382
|
+
};
|
|
1383
|
+
let log = crate::event_log::EventLog::new(&root);
|
|
1384
|
+
let sibling = OfflineTransport::new().with_targets(vec![PaneInfo {
|
|
1385
|
+
pane_id: PaneId::new("%1"), session: crate::transport::SessionName::new("foreign"),
|
|
1386
|
+
window_index: None, window_name: None, pane_index: None, tty: None,
|
|
1387
|
+
current_command: None, current_path: None, active: true, pane_pid: None,
|
|
1388
|
+
leader_env: std::collections::BTreeMap::new(),
|
|
1389
|
+
}]);
|
|
1390
|
+
let kept = cleanup_owned_empty_endpoint(&sibling, &endpoint, &log);
|
|
1391
|
+
assert_eq!(kept.remaining_sessions, vec!["foreign".to_string()]);
|
|
1392
|
+
assert!(kept.removed_files.is_empty());
|
|
1393
|
+
assert!(path.exists());
|
|
1394
|
+
let failed = OfflineTransport::new().with_list_targets_error("unknown");
|
|
1395
|
+
let refused = cleanup_owned_empty_endpoint(&failed, &endpoint, &log);
|
|
1396
|
+
assert!(refused.error.is_some());
|
|
1397
|
+
assert_eq!(refused.residual_files, vec![path.display().to_string()]);
|
|
1398
|
+
assert!(path.exists());
|
|
1399
|
+
assert!(!sibling.calls().contains(&"kill_server"));
|
|
1400
|
+
assert!(!failed.calls().contains(&"kill_server"));
|
|
1401
|
+
std::fs::remove_dir_all(root).unwrap();
|
|
1402
|
+
}
|
|
1403
|
+
}
|
|
1404
|
+
|
|
1274
1405
|
pub fn shutdown_with_transport(
|
|
1275
1406
|
workspace: &Path,
|
|
1276
1407
|
keep_logs: bool,
|
|
@@ -3126,9 +3257,9 @@ pub mod lifecycle_port {
|
|
|
3126
3257
|
session_converge_deadline_ms: Option<u64>,
|
|
3127
3258
|
) -> Result<Value, CliError> {
|
|
3128
3259
|
#[cfg(unix)]
|
|
3129
|
-
crate::contract_runtime::
|
|
3130
|
-
|
|
3131
|
-
|
|
3260
|
+
if let Some(receipt) = crate::contract_runtime::commands::restart(workspace, team)? {
|
|
3261
|
+
return Ok(receipt);
|
|
3262
|
+
}
|
|
3132
3263
|
match crate::lifecycle::restart_with_session_convergence_deadline(
|
|
3133
3264
|
workspace,
|
|
3134
3265
|
allow_fresh,
|
|
@@ -3149,6 +3280,10 @@ pub mod lifecycle_port {
|
|
|
3149
3280
|
team: Option<&str>,
|
|
3150
3281
|
role_config: &crate::lifecycle::role_config::RoleConfigPatch,
|
|
3151
3282
|
) -> Result<Value, CliError> {
|
|
3283
|
+
#[cfg(unix)]
|
|
3284
|
+
if let Some(receipt) = crate::contract_runtime::commands::start_agent(workspace, team, agent, role_config)? {
|
|
3285
|
+
return Ok(receipt);
|
|
3286
|
+
}
|
|
3152
3287
|
#[cfg(unix)]
|
|
3153
3288
|
{
|
|
3154
3289
|
crate::contract_runtime::framework::require_legacy_lifecycle(
|
|
@@ -3228,13 +3363,14 @@ pub mod lifecycle_port {
|
|
|
3228
3363
|
agent: &str,
|
|
3229
3364
|
team: Option<&str>,
|
|
3230
3365
|
) -> Result<Value, CliError> {
|
|
3366
|
+
#[cfg(unix)]
|
|
3367
|
+
if let Some(receipt) = crate::contract_runtime::commands::stop_agent(workspace, team, agent)? {
|
|
3368
|
+
return Ok(receipt);
|
|
3369
|
+
}
|
|
3231
3370
|
let agent_id = crate::model::ids::AgentId::new(agent);
|
|
3232
3371
|
#[cfg(unix)]
|
|
3233
3372
|
crate::contract_runtime::framework::require_legacy_lifecycle(
|
|
3234
|
-
workspace,
|
|
3235
|
-
team,
|
|
3236
|
-
Some(agent),
|
|
3237
|
-
"stop-agent",
|
|
3373
|
+
workspace, team, Some(agent), "stop-agent",
|
|
3238
3374
|
)?;
|
|
3239
3375
|
match crate::lifecycle::stop_agent(workspace, &agent_id, team) {
|
|
3240
3376
|
Ok(report) => Ok(json!({"ok": true, "agent_id": agent, "stopped": report.stopped})),
|
|
@@ -438,6 +438,57 @@ mod tests {
|
|
|
438
438
|
assert_eq!(models[0]["default"], true);
|
|
439
439
|
}
|
|
440
440
|
|
|
441
|
+
#[cfg(unix)]
|
|
442
|
+
#[test]
|
|
443
|
+
fn cursor_contract_catalog_uses_public_launcher_metadata_then_exact_ids() {
|
|
444
|
+
let program = fixture_program("cursor-agent", r#"
|
|
445
|
+
test "$#" = 1 || exit 9
|
|
446
|
+
case "$1" in
|
|
447
|
+
--version) printf '%s\n' '2026.10.01-a1b2c3' ;;
|
|
448
|
+
--list-models) exec /bin/cat "$TEAM_AGENT_MODELS_FIXTURE" ;;
|
|
449
|
+
*) exit 9 ;;
|
|
450
|
+
esac"#);
|
|
451
|
+
let mut cursor = CliFixture::new(program);
|
|
452
|
+
let response = cursor.program.with_extension("stdout");
|
|
453
|
+
std::fs::write(&response, include_bytes!("../provider/testdata/cursor-list-models-native.stdout")).unwrap();
|
|
454
|
+
cursor.response = Some(response);
|
|
455
|
+
let output = run_public_models_cli(&cursor.program, cursor.response.as_deref(),
|
|
456
|
+
&["models", "--provider", "cursor", "--search", "gpt-5.6-luna-xhigh", "--json"]);
|
|
457
|
+
let text = public_cli_text(&output);
|
|
458
|
+
let calls = std::fs::read_to_string(cursor.program.with_extension("count")).unwrap();
|
|
459
|
+
assert!(output.status.success(), "{text}");
|
|
460
|
+
assert_eq!(calls, "1:--version\n1:--list-models\n");
|
|
461
|
+
let value: Value = serde_json::from_str(text.trim()).unwrap();
|
|
462
|
+
assert_eq!(value["provider"], "cursor");
|
|
463
|
+
assert_eq!(value["auth"], "unknown");
|
|
464
|
+
assert_eq!(value["source"]["schema"], "cursor-list-models-text-v1");
|
|
465
|
+
assert_eq!(value["source"]["identity_scope"], "catalog_only");
|
|
466
|
+
assert!(value["models"].as_array().unwrap().iter().any(|row| row["model_id"] == "gpt-5.6-luna-xhigh"));
|
|
467
|
+
assert!(value["models"].as_array().unwrap().iter().all(|row| row["role_model"] == row["model_id"]));
|
|
468
|
+
assert!(value["current_role_model"].is_null());
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
#[cfg(unix)]
|
|
472
|
+
#[test]
|
|
473
|
+
fn cursor_contract_bad_version_has_no_legacy_entrypoint_fallback() {
|
|
474
|
+
use std::os::unix::fs::PermissionsExt;
|
|
475
|
+
let cursor = CliFixture::new(fixture_program("cursor-agent", "printf '%s\\n' 'Cursor release unknown'"));
|
|
476
|
+
let legacy = cursor.program.parent().unwrap().join("agent");
|
|
477
|
+
std::fs::write(&legacy, "#!/bin/sh\nprintf 'called' > \"$0.called\"\nexit 9\n").unwrap();
|
|
478
|
+
std::fs::set_permissions(&legacy, std::fs::Permissions::from_mode(0o700)).unwrap();
|
|
479
|
+
let output = run_public_models_cli(&cursor.program, None,
|
|
480
|
+
&["models", "--provider", "cursor", "--json"]);
|
|
481
|
+
let text = public_cli_text(&output);
|
|
482
|
+
assert!(!output.status.success(), "{text}");
|
|
483
|
+
assert_eq!(std::fs::read_to_string(cursor.program.with_extension("count")).unwrap(), "1:--version\n");
|
|
484
|
+
assert!(!legacy.with_extension("called").exists());
|
|
485
|
+
let value: Value = serde_json::from_str(text.trim()).unwrap();
|
|
486
|
+
assert_eq!(value["provider"], "cursor");
|
|
487
|
+
assert_eq!(value["ok"], false);
|
|
488
|
+
assert_eq!(value["models"], json!([]));
|
|
489
|
+
assert!(value["error"].as_str().unwrap().contains("Cursor release unknown"));
|
|
490
|
+
}
|
|
491
|
+
|
|
441
492
|
#[cfg(unix)]
|
|
442
493
|
#[test]
|
|
443
494
|
fn grok_public_cli_uses_native_catalog_and_keeps_shared_projection() {
|
|
@@ -1322,7 +1373,7 @@ mod tests {
|
|
|
1322
1373
|
),
|
|
1323
1374
|
ExitCode::Error
|
|
1324
1375
|
);
|
|
1325
|
-
let usage = "team-agent models [--provider pi|cursor_agent|codex|claude|claude_code|grok|kiro] [QUERY|--search TEXT] [--json]";
|
|
1376
|
+
let usage = "team-agent models [--provider pi|cursor|cursor_agent|codex|claude|claude_code|grok|kiro] [QUERY|--search TEXT] [--json]";
|
|
1326
1377
|
assert_eq!(crate::cli::spec::command_spec("models").unwrap().usage, usage);
|
|
1327
1378
|
assert!(crate::cli::emit::command_help(Some("models")).contains(usage));
|
|
1328
1379
|
}
|
|
@@ -0,0 +1,288 @@
|
|
|
1
|
+
//! Remove only a positively dead, owned Unix socket; never stop its server.
|
|
2
|
+
use std::ffi::CString;
|
|
3
|
+
use std::fs::{File, Metadata, OpenOptions};
|
|
4
|
+
use std::io;
|
|
5
|
+
use std::os::fd::{AsRawFd, FromRawFd, OwnedFd};
|
|
6
|
+
use std::os::unix::ffi::OsStrExt;
|
|
7
|
+
use std::os::unix::fs::{FileTypeExt, MetadataExt, OpenOptionsExt};
|
|
8
|
+
use std::path::Path;
|
|
9
|
+
|
|
10
|
+
#[derive(Debug, PartialEq, Eq)]
|
|
11
|
+
pub(super) enum SocketCleanup {
|
|
12
|
+
Absent,
|
|
13
|
+
Removed,
|
|
14
|
+
Preserved,
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
pub(super) fn remove_dead_socket(path: &Path) -> io::Result<SocketCleanup> {
|
|
18
|
+
remove_with_probe(path, listener_refused)
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
fn identity(metadata: &Metadata) -> (u64, u64, u32, u32, u64) {
|
|
22
|
+
(
|
|
23
|
+
metadata.dev(),
|
|
24
|
+
metadata.ino(),
|
|
25
|
+
metadata.mode(),
|
|
26
|
+
metadata.uid(),
|
|
27
|
+
metadata.nlink(),
|
|
28
|
+
)
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
fn invalid(reason: &str) -> io::Error {
|
|
32
|
+
io::Error::new(io::ErrorKind::PermissionDenied, reason)
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
fn remove_with_probe(
|
|
36
|
+
path: &Path,
|
|
37
|
+
probe: impl FnOnce(&Path) -> io::Result<bool>,
|
|
38
|
+
) -> io::Result<SocketCleanup> {
|
|
39
|
+
let socket = match std::fs::symlink_metadata(path) {
|
|
40
|
+
Ok(metadata) => metadata,
|
|
41
|
+
Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(SocketCleanup::Absent),
|
|
42
|
+
Err(error) => return Err(error),
|
|
43
|
+
};
|
|
44
|
+
// SAFETY: geteuid has no pointer arguments or ownership effects.
|
|
45
|
+
let uid = unsafe { libc::geteuid() };
|
|
46
|
+
if !socket.file_type().is_socket() || socket.uid() != uid || socket.nlink() != 1 {
|
|
47
|
+
return Err(invalid("shutdown socket is not an owned single-link socket"));
|
|
48
|
+
}
|
|
49
|
+
let parent = path
|
|
50
|
+
.parent()
|
|
51
|
+
.ok_or_else(|| invalid("socket parent missing"))?;
|
|
52
|
+
let metadata = std::fs::symlink_metadata(parent)?;
|
|
53
|
+
if !metadata.is_dir()
|
|
54
|
+
|| metadata.uid() != uid
|
|
55
|
+
|| metadata.mode() & 0o077 != 0
|
|
56
|
+
|| parent.canonicalize()? != parent
|
|
57
|
+
{
|
|
58
|
+
return Err(invalid(
|
|
59
|
+
"shutdown socket parent is not a canonical private owned directory",
|
|
60
|
+
));
|
|
61
|
+
}
|
|
62
|
+
let directory: File = OpenOptions::new()
|
|
63
|
+
.read(true)
|
|
64
|
+
.custom_flags(libc::O_DIRECTORY | libc::O_NOFOLLOW)
|
|
65
|
+
.open(parent)?;
|
|
66
|
+
if identity(&directory.metadata()?) != identity(&metadata) {
|
|
67
|
+
return Err(invalid("shutdown socket parent changed"));
|
|
68
|
+
}
|
|
69
|
+
if !probe(path)? {
|
|
70
|
+
return Ok(SocketCleanup::Preserved);
|
|
71
|
+
}
|
|
72
|
+
if identity(&std::fs::symlink_metadata(parent)?) != identity(&metadata) {
|
|
73
|
+
return Err(invalid("shutdown socket parent changed after death probe"));
|
|
74
|
+
}
|
|
75
|
+
let name = CString::new(
|
|
76
|
+
path.file_name()
|
|
77
|
+
.ok_or_else(|| invalid("socket name missing"))?
|
|
78
|
+
.as_bytes(),
|
|
79
|
+
)
|
|
80
|
+
.map_err(|_| invalid("socket name contains NUL"))?;
|
|
81
|
+
// SAFETY: stat is an all-integer C record; directory/name are valid for the call.
|
|
82
|
+
let mut fresh: libc::stat = unsafe { std::mem::zeroed() };
|
|
83
|
+
let result = unsafe {
|
|
84
|
+
libc::fstatat(
|
|
85
|
+
directory.as_raw_fd(),
|
|
86
|
+
name.as_ptr(),
|
|
87
|
+
&mut fresh,
|
|
88
|
+
libc::AT_SYMLINK_NOFOLLOW,
|
|
89
|
+
)
|
|
90
|
+
};
|
|
91
|
+
if result != 0 {
|
|
92
|
+
let error = io::Error::last_os_error();
|
|
93
|
+
return if error.kind() == io::ErrorKind::NotFound {
|
|
94
|
+
Ok(SocketCleanup::Absent)
|
|
95
|
+
} else {
|
|
96
|
+
Err(error)
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
if fresh.st_dev as u64 != socket.dev()
|
|
100
|
+
|| fresh.st_ino as u64 != socket.ino()
|
|
101
|
+
|| fresh.st_mode as u32 != socket.mode()
|
|
102
|
+
|| fresh.st_uid != socket.uid()
|
|
103
|
+
|| fresh.st_nlink as u64 != socket.nlink()
|
|
104
|
+
{
|
|
105
|
+
return Err(invalid("shutdown socket identity changed after death probe"));
|
|
106
|
+
}
|
|
107
|
+
// SAFETY: unlink is relative to the pinned owned directory and freshly fenced socket.
|
|
108
|
+
if unsafe { libc::unlinkat(directory.as_raw_fd(), name.as_ptr(), 0) } != 0 {
|
|
109
|
+
return Err(io::Error::last_os_error());
|
|
110
|
+
}
|
|
111
|
+
Ok(SocketCleanup::Removed)
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
fn listener_refused(path: &Path) -> io::Result<bool> {
|
|
115
|
+
// SAFETY: socket has no borrowed pointer arguments; successful fd ownership moves below.
|
|
116
|
+
let fd = unsafe { libc::socket(libc::AF_UNIX, libc::SOCK_STREAM, 0) };
|
|
117
|
+
if fd < 0 {
|
|
118
|
+
return Err(io::Error::last_os_error());
|
|
119
|
+
}
|
|
120
|
+
// SAFETY: fd is newly created and exclusively owned here.
|
|
121
|
+
let fd = unsafe { OwnedFd::from_raw_fd(fd) };
|
|
122
|
+
// SAFETY: fcntl acts on our owned fd; no pointer arguments.
|
|
123
|
+
if unsafe { libc::fcntl(fd.as_raw_fd(), libc::F_SETFD, libc::FD_CLOEXEC) } < 0 {
|
|
124
|
+
return Err(io::Error::last_os_error());
|
|
125
|
+
}
|
|
126
|
+
let flags = unsafe { libc::fcntl(fd.as_raw_fd(), libc::F_GETFL) };
|
|
127
|
+
if flags < 0 || unsafe { libc::fcntl(fd.as_raw_fd(), libc::F_SETFL, flags | libc::O_NONBLOCK) } < 0 {
|
|
128
|
+
return Err(io::Error::last_os_error());
|
|
129
|
+
}
|
|
130
|
+
// SAFETY: sockaddr_un is a C integer/byte record; zero initializes all fields/padding.
|
|
131
|
+
let mut address: libc::sockaddr_un = unsafe { std::mem::zeroed() };
|
|
132
|
+
address.sun_family = libc::AF_UNIX as libc::sa_family_t;
|
|
133
|
+
let bytes = path.as_os_str().as_bytes();
|
|
134
|
+
if bytes.len() >= address.sun_path.len() || bytes.contains(&0) {
|
|
135
|
+
return Err(io::Error::new(
|
|
136
|
+
io::ErrorKind::InvalidInput,
|
|
137
|
+
"shutdown socket path exceeds Unix address bounds",
|
|
138
|
+
));
|
|
139
|
+
}
|
|
140
|
+
for (target, byte) in address.sun_path.iter_mut().zip(bytes) {
|
|
141
|
+
*target = *byte as libc::c_char;
|
|
142
|
+
}
|
|
143
|
+
#[cfg(any(
|
|
144
|
+
target_os = "macos",
|
|
145
|
+
target_os = "ios",
|
|
146
|
+
target_os = "freebsd",
|
|
147
|
+
target_os = "openbsd",
|
|
148
|
+
target_os = "netbsd",
|
|
149
|
+
target_os = "dragonfly"
|
|
150
|
+
))]
|
|
151
|
+
{
|
|
152
|
+
address.sun_len = std::mem::size_of_val(&address) as u8;
|
|
153
|
+
}
|
|
154
|
+
// SAFETY: address points to the fully initialized sockaddr_un of the supplied size.
|
|
155
|
+
let result = unsafe {
|
|
156
|
+
libc::connect(
|
|
157
|
+
fd.as_raw_fd(),
|
|
158
|
+
(&address as *const libc::sockaddr_un).cast(),
|
|
159
|
+
std::mem::size_of_val(&address) as libc::socklen_t,
|
|
160
|
+
)
|
|
161
|
+
};
|
|
162
|
+
if result == 0 {
|
|
163
|
+
return Ok(false);
|
|
164
|
+
}
|
|
165
|
+
match io::Error::last_os_error().raw_os_error() {
|
|
166
|
+
Some(libc::ECONNREFUSED) => Ok(true),
|
|
167
|
+
Some(libc::EINPROGRESS | libc::EAGAIN | libc::ENOENT) => Ok(false),
|
|
168
|
+
_ => Err(io::Error::last_os_error()),
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
#[cfg(test)]
|
|
173
|
+
#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
|
|
174
|
+
mod tests {
|
|
175
|
+
use super::*;
|
|
176
|
+
use std::os::unix::fs::{symlink, PermissionsExt};
|
|
177
|
+
use std::os::unix::net::UnixListener;
|
|
178
|
+
|
|
179
|
+
struct Fixture(std::path::PathBuf);
|
|
180
|
+
impl Fixture {
|
|
181
|
+
fn new() -> Self {
|
|
182
|
+
let path = Path::new("/tmp").join(format!(
|
|
183
|
+
"ta-shutdown-{}-{}",
|
|
184
|
+
std::process::id(),
|
|
185
|
+
std::time::SystemTime::now()
|
|
186
|
+
.duration_since(std::time::UNIX_EPOCH)
|
|
187
|
+
.unwrap()
|
|
188
|
+
.as_nanos()
|
|
189
|
+
));
|
|
190
|
+
std::fs::create_dir(&path).unwrap();
|
|
191
|
+
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o700)).unwrap();
|
|
192
|
+
Self(path.canonicalize().unwrap())
|
|
193
|
+
}
|
|
194
|
+
fn socket(&self) -> std::path::PathBuf {
|
|
195
|
+
self.0.join("owned.sock")
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
impl Drop for Fixture {
|
|
199
|
+
fn drop(&mut self) {
|
|
200
|
+
let _ = std::fs::remove_dir_all(&self.0);
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
#[test]
|
|
205
|
+
fn shutdown_socket_removes_dead_inode_and_repeated_cleanup_is_absent() {
|
|
206
|
+
let fixture = Fixture::new();
|
|
207
|
+
let path = fixture.socket();
|
|
208
|
+
drop(UnixListener::bind(&path).unwrap());
|
|
209
|
+
assert_eq!(remove_dead_socket(&path).unwrap(), SocketCleanup::Removed);
|
|
210
|
+
assert!(!path.exists());
|
|
211
|
+
assert_eq!(remove_dead_socket(&path).unwrap(), SocketCleanup::Absent);
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
#[test]
|
|
215
|
+
fn shutdown_socket_preserves_live_listener_and_unsafe_objects() {
|
|
216
|
+
let fixture = Fixture::new();
|
|
217
|
+
let path = fixture.socket();
|
|
218
|
+
let listener = UnixListener::bind(&path).unwrap();
|
|
219
|
+
assert_eq!(remove_dead_socket(&path).unwrap(), SocketCleanup::Preserved);
|
|
220
|
+
assert!(path.exists());
|
|
221
|
+
drop(listener);
|
|
222
|
+
std::fs::remove_file(&path).unwrap();
|
|
223
|
+
std::fs::write(&path, b"not a socket").unwrap();
|
|
224
|
+
assert!(remove_dead_socket(&path).is_err());
|
|
225
|
+
std::fs::remove_file(&path).unwrap();
|
|
226
|
+
std::fs::create_dir(&path).unwrap();
|
|
227
|
+
assert!(remove_dead_socket(&path).is_err());
|
|
228
|
+
std::fs::remove_dir(&path).unwrap();
|
|
229
|
+
let target = fixture.0.join("target.sock");
|
|
230
|
+
drop(UnixListener::bind(&target).unwrap());
|
|
231
|
+
symlink(&target, &path).unwrap();
|
|
232
|
+
assert!(remove_dead_socket(&path).is_err());
|
|
233
|
+
assert!(target.exists());
|
|
234
|
+
std::fs::remove_file(&path).unwrap();
|
|
235
|
+
std::fs::hard_link(&target, &path).unwrap();
|
|
236
|
+
assert!(remove_dead_socket(&path).is_err());
|
|
237
|
+
std::fs::remove_file(&path).unwrap();
|
|
238
|
+
std::fs::set_permissions(&fixture.0, std::fs::Permissions::from_mode(0o755)).unwrap();
|
|
239
|
+
assert!(remove_dead_socket(&target).is_err());
|
|
240
|
+
assert!(target.exists());
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
#[test]
|
|
244
|
+
fn shutdown_socket_refuses_failed_probe_and_inode_or_parent_replacement() {
|
|
245
|
+
let fixture = Fixture::new();
|
|
246
|
+
let path = fixture.socket();
|
|
247
|
+
drop(UnixListener::bind(&path).unwrap());
|
|
248
|
+
assert!(remove_with_probe(&path, |_| Err(io::Error::new(
|
|
249
|
+
io::ErrorKind::PermissionDenied,
|
|
250
|
+
"probe failed"
|
|
251
|
+
)))
|
|
252
|
+
.is_err());
|
|
253
|
+
assert!(path.exists());
|
|
254
|
+
let original = fixture.0.join("original.sock");
|
|
255
|
+
assert!(remove_with_probe(&path, |_| {
|
|
256
|
+
std::fs::rename(&path, &original)?;
|
|
257
|
+
drop(UnixListener::bind(&path)?);
|
|
258
|
+
Ok(true)
|
|
259
|
+
})
|
|
260
|
+
.is_err());
|
|
261
|
+
assert!(path.exists());
|
|
262
|
+
assert_ne!(
|
|
263
|
+
std::fs::symlink_metadata(&path).unwrap().ino(),
|
|
264
|
+
std::fs::symlink_metadata(&original).unwrap().ino()
|
|
265
|
+
);
|
|
266
|
+
std::fs::remove_file(&path).unwrap();
|
|
267
|
+
drop(UnixListener::bind(&path).unwrap());
|
|
268
|
+
assert!(remove_with_probe(&path, |_| {
|
|
269
|
+
std::fs::remove_file(&path)?;
|
|
270
|
+
std::fs::write(&path, b"replacement")?;
|
|
271
|
+
Ok(true)
|
|
272
|
+
})
|
|
273
|
+
.is_err());
|
|
274
|
+
assert_eq!(std::fs::read(&path).unwrap(), b"replacement");
|
|
275
|
+
std::fs::remove_file(&path).unwrap();
|
|
276
|
+
drop(UnixListener::bind(&path).unwrap());
|
|
277
|
+
let moved = fixture.0.with_extension("old");
|
|
278
|
+
assert!(remove_with_probe(&path, |_| {
|
|
279
|
+
std::fs::rename(&fixture.0, &moved)?;
|
|
280
|
+
std::fs::create_dir(&fixture.0)?;
|
|
281
|
+
std::fs::set_permissions(&fixture.0, std::fs::Permissions::from_mode(0o700))?;
|
|
282
|
+
Ok(true)
|
|
283
|
+
})
|
|
284
|
+
.is_err());
|
|
285
|
+
assert!(moved.join("owned.sock").exists());
|
|
286
|
+
std::fs::remove_dir_all(moved).unwrap();
|
|
287
|
+
}
|
|
288
|
+
}
|
|
@@ -113,7 +113,7 @@ pub(crate) const COMMAND_SPECS: &[CommandSpec] = &[
|
|
|
113
113
|
CommandSpec { name: "quick-start", tier: CommandTier::Core, category: CommandCategory::Start, kind: CommandKind::Dispatch(DispatchKind::QuickStart), summary: "Create a team from role files; use restart for an initialized team", usage: "team-agent quick-start [TEAMDIR] [--workspace WORKSPACE] [--team TEAM] [--name NAME] [--team-id TEAM] [--yes] [--backend tmux|conpty] [--json] [--detail]", default_help: true, command_help: true, suggestion_index: true, token_usage: TokenUsage::Conditional, sunset: None, action: None, governance: None },
|
|
114
114
|
CommandSpec { name: "send", tier: CommandTier::Core, category: CommandCategory::Start, kind: CommandKind::Dispatch(DispatchKind::Send), summary: "Send a task to a specified agent", usage: "team-agent send <agent> MESSAGE... [--workspace WORKSPACE] [--team TEAM] [--mailbox] [--json]", default_help: true, command_help: true, suggestion_index: true, token_usage: TokenUsage::Conditional, sunset: Some("a future compatibility release"), action: Some("Use team-agent send <agent> 'task message'; select scope with --workspace and --team"), governance: None },
|
|
115
115
|
CommandSpec { name: "status", tier: CommandTier::Core, category: CommandCategory::Start, kind: CommandKind::Dispatch(DispatchKind::Status), summary: "Check agent status without reading conversation content", usage: "team-agent status [<agent>] [--workspace WORKSPACE] [--team TEAM] [--json] [--summary|--detail]", default_help: true, command_help: true, suggestion_index: true, token_usage: TokenUsage::No, sunset: None, action: None, governance: None },
|
|
116
|
-
CommandSpec { name: "models", tier: CommandTier::Core, category: CommandCategory::Start, kind: CommandKind::Dispatch(DispatchKind::Models), summary: "Discover model names supported by the native provider", usage: "team-agent models [--provider pi|cursor_agent|codex|claude|claude_code|grok|kiro] [QUERY|--search TEXT] [--json]", default_help: true, command_help: true, suggestion_index: true, token_usage: TokenUsage::No, sunset: None, action: None, governance: None },
|
|
116
|
+
CommandSpec { name: "models", tier: CommandTier::Core, category: CommandCategory::Start, kind: CommandKind::Dispatch(DispatchKind::Models), summary: "Discover model names supported by the native provider", usage: "team-agent models [--provider pi|cursor|cursor_agent|codex|claude|claude_code|grok|kiro] [QUERY|--search TEXT] [--json]", default_help: true, command_help: true, suggestion_index: true, token_usage: TokenUsage::No, sunset: None, action: None, governance: None },
|
|
117
117
|
CommandSpec { name: "inbox", tier: CommandTier::Core, category: CommandCategory::Start, kind: CommandKind::Dispatch(DispatchKind::Inbox), summary: "Read agent replies", usage: "team-agent inbox <agent> [-n N|--limit N] [--workspace WORKSPACE] [--team TEAM] [--json]", default_help: true, command_help: true, suggestion_index: true, token_usage: TokenUsage::No, sunset: None, action: None, governance: None },
|
|
118
118
|
CommandSpec { name: "restart", tier: CommandTier::Core, category: CommandCategory::TeamLifecycle, kind: CommandKind::Dispatch(DispatchKind::Restart), summary: "Restart an existing team using saved sessions when available", usage: "team-agent restart [WORKSPACE] [--team TEAM] [--allow-fresh] [--session-converge-deadline SECONDS] [--json] [--detail]", default_help: true, command_help: true, suggestion_index: true, token_usage: TokenUsage::Conditional, sunset: None, action: None, governance: None },
|
|
119
119
|
CommandSpec { name: "shutdown", tier: CommandTier::Core, category: CommandCategory::TeamLifecycle, kind: CommandKind::Dispatch(DispatchKind::Shutdown), summary: "Shut down the selected team and report residual resources", usage: "team-agent shutdown [--workspace WORKSPACE] [--team TEAM] [--keep-logs] [--json]", default_help: true, command_help: true, suggestion_index: true, token_usage: TokenUsage::No, sunset: None, action: None, governance: None },
|
|
@@ -720,11 +720,18 @@ fn shutdown_invocation_diagnostic(
|
|
|
720
720
|
);
|
|
721
721
|
}
|
|
722
722
|
let terminal = &appended[terminals[0]];
|
|
723
|
-
|
|
724
|
-
|
|
723
|
+
// These diagnostics use deliberately unverified fixture paths, not the
|
|
724
|
+
// computed workspace endpoint. A source label cannot grant ownership.
|
|
725
|
+
let workspace_socket = crate::tmux_backend::socket_name_for_workspace(ws);
|
|
726
|
+
assert_ne!(
|
|
727
|
+
socket.file_name(),
|
|
728
|
+
Some(std::ffi::OsStr::new(&workspace_socket)),
|
|
729
|
+
"diagnostic fixture unexpectedly uses the owned workspace socket"
|
|
730
|
+
);
|
|
731
|
+
if !terminal["owned_endpoint"].is_null() {
|
|
725
732
|
panic!(
|
|
726
|
-
"shutdown diagnostic endpoint
|
|
727
|
-
{result_summary}; terminal={terminal}",
|
|
733
|
+
"shutdown diagnostic falsely claimed fixture endpoint ownership: \
|
|
734
|
+
iteration={iteration} socket={} {result_summary}; terminal={terminal}",
|
|
728
735
|
socket.display()
|
|
729
736
|
);
|
|
730
737
|
}
|