@team-agent/installer 0.5.114 → 0.5.116

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (33) hide show
  1. package/Cargo.lock +1 -1
  2. package/Cargo.toml +1 -1
  3. package/crates/team-agent/src/cli/emit.rs +2 -2
  4. package/crates/team-agent/src/cli/mod.rs +155 -19
  5. package/crates/team-agent/src/cli/models.rs +52 -1
  6. package/crates/team-agent/src/cli/shutdown_socket.rs +288 -0
  7. package/crates/team-agent/src/cli/spec.rs +1 -1
  8. package/crates/team-agent/src/cli/tests/shutdown_kill_plan.rs +11 -4
  9. package/crates/team-agent/src/contract_runtime/backend.rs +490 -97
  10. package/crates/team-agent/src/contract_runtime/commands.rs +326 -0
  11. package/crates/team-agent/src/contract_runtime/config.rs +39 -8
  12. package/crates/team-agent/src/contract_runtime/config_tests.rs +56 -0
  13. package/crates/team-agent/src/contract_runtime/discovery.rs +218 -40
  14. package/crates/team-agent/src/contract_runtime/framework.rs +38 -5
  15. package/crates/team-agent/src/contract_runtime/mcp.rs +3 -0
  16. package/crates/team-agent/src/contract_runtime/mod.rs +2 -0
  17. package/crates/team-agent/src/contract_runtime/pump.rs +97 -33
  18. package/crates/team-agent/src/contract_runtime/registry.rs +248 -4
  19. package/crates/team-agent/src/contract_runtime/workspace.rs +228 -0
  20. package/crates/team-agent/src/leader/start.rs +93 -5
  21. package/crates/team-agent/src/lifecycle/launch/quick_start.rs +99 -1
  22. package/crates/team-agent/src/lifecycle/launch/state_projection.rs +3 -5
  23. package/crates/team-agent/src/lifecycle/launch.rs +6 -0
  24. package/crates/team-agent/src/lifecycle/restart/agent.rs +4 -0
  25. package/crates/team-agent/src/lifecycle/restart/rebuild.rs +109 -58
  26. package/crates/team-agent/src/lifecycle/restart/selection.rs +63 -7
  27. package/crates/team-agent/src/lifecycle/role_config.rs +17 -1
  28. package/crates/team-agent/src/provider/session/capture.rs +24 -1
  29. package/crates/team-agent/src/state/persist.rs +72 -2
  30. package/crates/team-agent/src/state/projection.rs +29 -0
  31. package/crates/team-agent/src/state/repository/tests.rs +70 -0
  32. package/crates/team-agent/src/state/repository.rs +5 -1
  33. package/package.json +4 -4
@@ -21,7 +21,6 @@ use team_agent_contract::host::{
21
21
  process::{fingerprint_file, resolve_cwd},
22
22
  tmux::HostLimits,
23
23
  };
24
- use team_agent_contract::kiro::{KiroAdapter, McpStdio, KIRO_DESCRIPTOR};
25
24
  use team_agent_contract::orchestration::{lifecycle::*, physical::*, store::*, Error};
26
25
  use thiserror::Error as ThisError;
27
26
 
@@ -29,10 +28,13 @@ use super::{
29
28
  config::{MemberRoute, RoleConfig, RuntimeFamily},
30
29
  discovery::{self, Discovery, DiscoveryError},
31
30
  forward::FrameworkContext,
31
+ registry::{self, NativeAdapter},
32
32
  };
33
33
 
34
34
  #[derive(Debug, ThisError)]
35
35
  pub enum BackendError {
36
+ #[error("native configuration: {0}")]
37
+ Configuration(#[from] super::config::ConfigError),
36
38
  #[error("native discovery: {0}")]
37
39
  Discovery(#[from] DiscoveryError),
38
40
  #[error("contract runtime: {0}")]
@@ -203,7 +205,7 @@ impl Backend {
203
205
  }
204
206
  }
205
207
 
206
- pub(super) fn settings(binding: &Binding) -> PhysicalSettings {
208
+ pub(super) fn settings(binding: &Binding, provider: registry::NativeProvider) -> PhysicalSettings {
207
209
  PhysicalSettings {
208
210
  tmux: binding.tmux.clone(),
209
211
  limits: HostLimits {
@@ -220,7 +222,7 @@ pub(super) fn settings(binding: &Binding) -> PhysicalSettings {
220
222
  columns: 120,
221
223
  rows: 40,
222
224
  },
223
- action_budget: Duration::from_secs(30),
225
+ action_budget: provider.action_budget(),
224
226
  freshness: Duration::from_secs(2),
225
227
  journal_bytes: 2 * 1024 * 1024,
226
228
  }
@@ -228,10 +230,11 @@ pub(super) fn settings(binding: &Binding) -> PhysicalSettings {
228
230
  pub(super) fn adapter(
229
231
  binding: &Binding,
230
232
  identity: &InstanceIdentity,
231
- ) -> Result<KiroAdapter, BackendError> {
232
- Ok(KiroAdapter::new(McpStdio {
233
- executable: binding.candidate.clone(),
234
- arguments: vec![
233
+ provider: &ProviderId,
234
+ ) -> Result<NativeAdapter, BackendError> {
235
+ Ok(registry::provider(provider.as_str())?.adapter(
236
+ binding.candidate.clone(),
237
+ vec![
235
238
  "contract-mcp".into(),
236
239
  "--workspace".into(),
237
240
  binding
@@ -249,8 +252,18 @@ pub(super) fn adapter(
249
252
  "--generation".into(),
250
253
  identity.generation.0.to_string(),
251
254
  ],
252
- environment: BTreeMap::new(),
253
- })?)
255
+ BTreeMap::new(),
256
+ )?.with_launch_context(binding.workspace.path.clone(), None)?)
257
+ }
258
+ fn launch_adapter(
259
+ binding: &Binding,
260
+ identity: &InstanceIdentity,
261
+ provider: &ProviderId,
262
+ discovered: &Discovery,
263
+ ) -> Result<NativeAdapter, BackendError> {
264
+ Ok(adapter(binding, identity, provider)?.with_launch_context(
265
+ binding.workspace.path.clone(), discovered.runtime.clone(),
266
+ )?)
254
267
  }
255
268
  fn request(
256
269
  binding: &Binding,
@@ -258,10 +271,15 @@ fn request(
258
271
  identity: InstanceIdentity,
259
272
  discovered: &Discovery,
260
273
  ) -> Result<LaunchRequest, BackendError> {
261
- let profiles = KIRO_DESCRIPTOR
262
- .input
263
- .profiles
264
- .require("Kiro input profiles")?;
274
+ let descriptor = registry::provider(role.provider.as_str())?.descriptor();
275
+ if discovered.catalog.provider != role.provider
276
+ || discovered.catalog.native != discovered.native
277
+ {
278
+ return Err(ContractError::Mismatch("role native catalog").into());
279
+ }
280
+ // Metadata-only discovery cannot authorize a native-subscription worker.
281
+ descriptor.auth.subscription.require("native subscription launch")?;
282
+ let profiles = descriptor.input.profiles.require("native input profiles")?;
265
283
  let mut matches = profiles.iter().filter(|profile| {
266
284
  profile.matches_native(&discovered.native)
267
285
  && profile.channel == Channel::Tmux
@@ -271,6 +289,7 @@ fn request(
271
289
  if matches.next().is_some() {
272
290
  return Err(ContractError::AmbiguousProfile.into());
273
291
  }
292
+ profile.policy.require("native submit policy")?;
274
293
  Ok(LaunchRequest {
275
294
  provider: role.provider.as_str().into(),
276
295
  operation: Operation::Fresh,
@@ -298,11 +317,31 @@ fn request(
298
317
  })
299
318
  }
300
319
 
320
+ fn preflight_request(
321
+ binding: &Binding,
322
+ role: &RoleConfig,
323
+ identity: InstanceIdentity,
324
+ discovered: &Discovery,
325
+ ) -> Result<LaunchRequest, BackendError> {
326
+ let mut request = request(binding, role, identity, discovered)?;
327
+ if registry::provider(role.provider.as_str())? == registry::NativeProvider::GrokBuild {
328
+ // Pure H2 only: this is a proposed private path, not a filesystem receipt.
329
+ request.paths.cwd = team_agent_contract::contract::session::CwdIdentity {
330
+ path: binding.transport_root.join("workspaces").join(role.id.as_str()),
331
+ identity: digest(b"effect-free-planned-private-cwd"),
332
+ };
333
+ }
334
+ Ok(request)
335
+ }
336
+
301
337
  /// Team-wide native discovery/admission before the shared launcher creates any
302
338
  /// seats (including legacy peers). Bounded read-only native commands run; no
303
339
  /// runtime store, worker config, private socket, or worker process is created.
304
340
  pub fn preflight(team: &super::config::TeamConfig) -> Result<(), BackendError> {
305
- let discovered = discovery::discover(&team.workspace.path)?;
341
+ if team.roles.iter().all(|role| role.paused) {
342
+ return Ok(());
343
+ }
344
+ let mut discoveries = BTreeMap::new();
306
345
  let candidate = std::env::current_exe()?.canonicalize()?;
307
346
  let candidate_sha256 =
308
347
  fingerprint_file(&candidate, 1024 * 1024 * 1024, Duration::from_secs(10))?;
@@ -317,36 +356,46 @@ pub fn preflight(team: &super::config::TeamConfig) -> Result<(), BackendError> {
317
356
  tmux: discovery::executable("tmux")?,
318
357
  transport_root: "/tmp/preflight-no-write".into(),
319
358
  };
320
- for role in &team.roles {
359
+ for role in team.roles.iter().filter(|role| !role.paused) {
360
+ let selected = registry::provider(role.provider.as_str())?;
361
+ if !discoveries.contains_key(&role.provider) {
362
+ discoveries.insert(
363
+ role.provider.clone(),
364
+ discovery::discover_runtime(&role.provider, &team.workspace.path)?,
365
+ );
366
+ }
367
+ let discovered = discoveries.get(&role.provider).ok_or(BackendError::Metadata)?;
321
368
  let identity = InstanceIdentity {
322
369
  scope: team.scope.clone(),
323
370
  seat: role.id.clone(),
324
371
  instance: InstanceId::new("preflight")?,
325
372
  generation: Generation(1),
326
373
  };
327
- let adapter = adapter(&binding, &identity)?;
374
+ let adapter = launch_adapter(&binding, &identity, &role.provider, discovered)?;
328
375
  let hooks = adapter.hooks();
329
- let request = request(&binding, role, identity, &discovered)?;
330
- let resolved = resolve_launch(
331
- &KIRO_DESCRIPTOR,
376
+ let request = preflight_request(&binding, role, identity, discovered)?;
377
+ let resolved = resolve_launch_preflight(
378
+ selected.descriptor(),
332
379
  &hooks,
333
380
  &request,
334
381
  Some(&discovered.catalog),
335
382
  )?;
336
- validate_launch_plan(&KIRO_DESCRIPTOR, &resolved, &adapter.plan(&resolved)?)?;
383
+ validate_launch_plan(selected.descriptor(), &resolved, &adapter.plan(&resolved)?)?;
337
384
  }
338
385
  Ok(())
339
386
  }
340
387
 
341
- /// This first wiring is fresh-only. Resume/fork must use their typed contracts;
342
- /// missing evidence cannot be bypassed by silently launching another fresh seat.
388
+ /// Fresh startup is distinct from resume. An existing generation is never
389
+ /// silently replaced with a new native session.
343
390
  pub fn start(
344
391
  workspace: &Path,
345
392
  team: &str,
346
393
  role: &RoleConfig,
347
394
  members: &[MemberRoute],
348
395
  ) -> Result<(Binding, OperationRecord), BackendError> {
349
- let discovered = discovery::discover(workspace)?;
396
+ let selected = registry::provider(role.provider.as_str())?;
397
+ let descriptor = selected.descriptor();
398
+ let discovered = discovery::discover_runtime(&role.provider, workspace)?;
350
399
  let cwd = resolve_cwd(workspace)?;
351
400
  let scope = scope_for(&cwd, team)?;
352
401
  let candidate = std::env::current_exe()?.canonicalize()?;
@@ -370,16 +419,17 @@ pub fn start(
370
419
  instance: InstanceId::new("preflight")?,
371
420
  generation: Generation(1),
372
421
  };
373
- let probe_adapter = adapter(&provisional, &provisional_identity)?;
422
+ let probe_adapter = launch_adapter(&provisional, &provisional_identity, &role.provider, &discovered)?;
374
423
  let probe_hooks = probe_adapter.hooks();
375
- let preflight = request(&provisional, role, provisional_identity, &discovered)?;
376
- let preflight = resolve_launch(
377
- &KIRO_DESCRIPTOR,
424
+ let preflight = preflight_request(&provisional, role, provisional_identity, &discovered)?;
425
+ let preflight = resolve_launch_preflight(
426
+ descriptor,
378
427
  &probe_hooks,
379
428
  &preflight,
380
429
  Some(&discovered.catalog),
381
430
  )?;
382
- probe_adapter.plan(&preflight)?;
431
+ let preflight_plan = probe_adapter.plan(&preflight)?;
432
+ validate_launch_plan(descriptor, &preflight, &preflight_plan)?;
383
433
  let mut backend = if root_for(&cwd.path, &scope).exists() {
384
434
  Backend::open(&cwd.path, team)?
385
435
  } else {
@@ -393,20 +443,6 @@ pub fn start(
393
443
  if backend.store.seat(&role.id)?.is_some() {
394
444
  return Err(Error::Conflict.into());
395
445
  }
396
- // A private immutable source snapshot binds model/effort and all four exact
397
- // instruction layers to this startup. Never print its prompt body.
398
- use std::os::unix::fs::OpenOptionsExt;
399
- let snapshot = serde_json::to_vec(role).map_err(|_| BackendError::Metadata)?;
400
- let mut file = std::fs::OpenOptions::new()
401
- .write(true)
402
- .create_new(true)
403
- .mode(0o600)
404
- .open(
405
- root_for(&backend.binding.workspace.path, &backend.binding.scope)
406
- .join(format!("role-{}.json", role.id.as_str())),
407
- )?;
408
- file.write_all(&snapshot)?;
409
- file.sync_all()?;
410
446
  let route = backend.binding.framework().route()?;
411
447
  let mut peers: Vec<_> = members
412
448
  .iter()
@@ -426,11 +462,29 @@ pub fn start(
426
462
  });
427
463
  backend.store.set_framework_routes(&peers, Some(&route))?;
428
464
  let identity = backend.store.propose_identity(&role.id)?;
429
- let adapter = adapter(&backend.binding, &identity)?;
465
+ let server_key = selected.server_key_for(&identity)?;
466
+ write_role(&backend.binding, &identity, role)?;
467
+ let adapter = launch_adapter(&backend.binding, &identity, &role.provider, &discovered)?;
430
468
  let hooks = adapter.hooks();
431
- let request = request(&backend.binding, role, identity, &discovered)?;
469
+ let mut request = request(&backend.binding, role, identity, &discovered)?;
470
+ if !descriptor.workspace.shared_cwd {
471
+ request.paths.cwd = super::workspace::prepare(&backend.binding, &role.id)?;
472
+ }
473
+ if selected == registry::NativeProvider::GrokBuild {
474
+ team_agent_contract::providers::grok_build::native::require_private_primary(&request.paths.cwd)?;
475
+ }
476
+ if let Some(registry::NativeRuntime::Cursor(runtime)) = &discovered.runtime {
477
+ request.preassigned_session = Some(
478
+ team_agent_contract::providers::cursor::native::allocate_chat(
479
+ runtime, &request.paths.cwd, &mut RealCommandRunner::default(),
480
+ team_agent_contract::contract::hooks::ReadBounds {
481
+ deadline: Duration::from_secs(90), max_output_bytes: 1024 * 1024,
482
+ },
483
+ ).map_err(|error| BackendError::Discovery(DiscoveryError::Catalog(error)))?,
484
+ );
485
+ }
432
486
  let resolved = resolve_launch(
433
- &KIRO_DESCRIPTOR,
487
+ descriptor,
434
488
  &hooks,
435
489
  &request,
436
490
  Some(&discovered.catalog),
@@ -438,7 +492,7 @@ pub fn start(
438
492
  let plan = adapter.plan(&resolved)?;
439
493
  let operation = OperationId::new(format!("start-{}", request.identity.instance.as_str()))?;
440
494
  let mut io = ScopedMaterializer::new(
441
- &KIRO_DESCRIPTOR,
495
+ descriptor,
442
496
  &resolved,
443
497
  &plan,
444
498
  operation.clone(),
@@ -447,15 +501,17 @@ pub fn start(
447
501
  let clock = RealClock::new();
448
502
  let mut physical = PhysicalRuntime::new(
449
503
  Registration {
450
- descriptor: &KIRO_DESCRIPTOR,
504
+ descriptor,
451
505
  hooks: &hooks,
452
- controls: team_agent_contract::kiro::interaction::CONTROLS,
506
+ controls: selected.controls(),
507
+ startup: selected.startup(),
508
+ session_panel: selected.session_panel(),
453
509
  },
454
- settings(&backend.binding),
510
+ settings(&backend.binding, selected),
455
511
  operation.clone(),
456
512
  &clock,
457
513
  )?;
458
- physical.evidence = policy_evidence(&request.native, candidate_sha256);
514
+ physical.evidence = policy_evidence(&role.provider, &request.native, candidate_sha256)?;
459
515
  let record = Lifecycle {
460
516
  store: &mut backend.store,
461
517
  host: &mut physical,
@@ -463,7 +519,7 @@ pub fn start(
463
519
  }
464
520
  .startup(
465
521
  &Adapter {
466
- descriptor: &KIRO_DESCRIPTOR,
522
+ descriptor,
467
523
  hooks: &hooks,
468
524
  catalog: Some(&discovered.catalog),
469
525
  },
@@ -471,30 +527,201 @@ pub fn start(
471
527
  Routing {
472
528
  pane: "unpublished".into(),
473
529
  binding_key: format!("binding-{}", request.identity.instance.as_str()),
474
- server_key: "team".into(),
530
+ server_key: server_key.into(),
475
531
  },
476
532
  operation,
477
533
  )?;
478
534
  Ok((backend.binding, record))
479
535
  }
480
536
 
481
- pub(super) fn read_role(binding: &Binding, id: &SeatId) -> Result<RoleConfig, BackendError> {
482
- let path = root_for(&binding.workspace.path, &binding.scope)
483
- .join(format!("role-{}.json", id.as_str()));
537
+ /// A fully admitted resume proposal. It is still effect-free: no parent stop,
538
+ /// session allocation, snapshot write or config replacement has occurred.
539
+ pub struct PreparedResume {
540
+ binding: Binding,
541
+ role: RoleConfig,
542
+ discovered: Discovery,
543
+ request: LaunchRequest,
544
+ }
545
+
546
+ impl PreparedResume {
547
+ pub(crate) fn seat(&self) -> &SeatId { &self.request.identity.seat }
548
+ pub(crate) fn session(&self) -> Option<&str> {
549
+ self.request.resume.as_ref().map(|resume| resume.binding.native_session.as_str())
550
+ }
551
+ }
552
+
553
+ pub(crate) fn prepare_restart(
554
+ workspace: &Path,
555
+ team: &str,
556
+ directory: &Path,
557
+ state: &serde_json::Value,
558
+ ) -> Result<Vec<PreparedResume>, BackendError> {
559
+ let config = super::framework::team_config(workspace, directory, team)?.ok_or(BackendError::Metadata)?;
560
+ let mut prepared = Vec::new();
561
+ let agents = state.get("agents").and_then(serde_json::Value::as_object).ok_or(BackendError::Metadata)?;
562
+ for (id, _) in agents.iter().filter(|(_, row)| {
563
+ super::framework::is_contract(row)
564
+ && !crate::state::projection::agent_stopped_for_restart(row)
565
+ }) {
566
+ let Some(role) = config.roles.iter().find(|role| role.id.as_str() == id) else {
567
+ // Removed roles are not silently re-created from an old snapshot.
568
+ continue;
569
+ };
570
+ if role.paused { continue; }
571
+ // The native store is authoritative even if its framework mirror is
572
+ // stale. Only explicit start-agent may resume a stopped generation.
573
+ let backend = Backend::open_readonly(workspace, team)?;
574
+ let seat = backend.store.seat(&role.id)?.ok_or(BackendError::Metadata)?;
575
+ if seat.status == SeatStatus::Stopped { continue; }
576
+ prepared.push(prepare_resume(workspace, team, &role.id, Some(role))?);
577
+ }
578
+ Ok(prepared)
579
+ }
580
+
581
+ pub fn prepare_resume(
582
+ workspace: &Path,
583
+ team: &str,
584
+ seat: &SeatId,
585
+ role: Option<&RoleConfig>,
586
+ ) -> Result<PreparedResume, BackendError> {
587
+ let backend = Backend::open_readonly(workspace, team)?;
588
+ let parent = backend.store.seat(seat)?.ok_or(BackendError::Metadata)?;
589
+ if parent.status == SeatStatus::Unknown {
590
+ return Err(Error::Fence.into());
591
+ }
592
+ let role = match role {
593
+ Some(role) => role.clone(),
594
+ None => read_role(&backend.binding, &parent.identity)?,
595
+ };
596
+ if role.id != parent.identity.seat || role.provider != parent.provider {
597
+ return Err(ContractError::Mismatch("resume role provider").into());
598
+ }
599
+ let source = parent.session.clone().ok_or(ContractError::Invalid("native resume binding required"))?;
600
+ if resolve_cwd(&source.cwd.path)? != source.cwd {
601
+ return Err(ContractError::Mismatch("resume cwd incarnation").into());
602
+ }
603
+ let candidate = std::env::current_exe()?.canonicalize()?;
604
+ let candidate_sha256 = fingerprint_file(&candidate, 1024 * 1024 * 1024, Duration::from_secs(10))?;
605
+ if backend.binding.candidate != candidate || backend.binding.candidate_sha256 != candidate_sha256 {
606
+ return Err(Error::Fence.into());
607
+ }
608
+ let discovered = discovery::discover_runtime(&role.provider, workspace)?;
609
+ let identity = backend.store.propose_identity(seat)?;
610
+ let adapter = launch_adapter(&backend.binding, &identity, &role.provider, &discovered)?;
611
+ let hooks = adapter.hooks();
612
+ let mut request = request(&backend.binding, &role, identity, &discovered)?;
613
+ request.operation = Operation::Resume;
614
+ request.paths.cwd = source.cwd.clone();
615
+ request.resume = Some(ResumeRequest {
616
+ binding: source, expected_source: parent.identity,
617
+ });
618
+ let selected = registry::provider(role.provider.as_str())?;
619
+ if selected == registry::NativeProvider::GrokBuild {
620
+ team_agent_contract::providers::grok_build::native::require_private_primary(&request.paths.cwd)?;
621
+ }
622
+ let resolved = resolve_launch(selected.descriptor(), &hooks, &request, Some(&discovered.catalog))?;
623
+ validate_launch_plan(selected.descriptor(), &resolved, &adapter.plan(&resolved)?)?;
624
+ Ok(PreparedResume { binding: backend.binding, role, discovered, request })
625
+ }
626
+
627
+ pub fn resume(prepared: PreparedResume) -> Result<OperationRecord, BackendError> {
628
+ let mut backend = Backend::open(&prepared.binding.workspace.path, &prepared.binding.team)?;
629
+ if serde_json::to_vec(&backend.binding).map_err(|_| BackendError::Metadata)?
630
+ != serde_json::to_vec(&prepared.binding).map_err(|_| BackendError::Metadata)?
631
+ {
632
+ return Err(Error::Fence.into());
633
+ }
634
+ let source = prepared.request.resume.as_ref().ok_or(BackendError::Metadata)?;
635
+ let parent = backend.store.assert_current(&source.expected_source)?;
636
+ if parent.session.as_ref() != Some(&source.binding) {
637
+ return Err(Error::Fence.into());
638
+ }
639
+ let selected = registry::provider(prepared.role.provider.as_str())?;
640
+ let descriptor = selected.descriptor();
641
+ let adapter = launch_adapter(&backend.binding, &prepared.request.identity, &prepared.role.provider, &prepared.discovered)?;
642
+ let hooks = adapter.hooks();
643
+ // Revalidate the exact plan before publishing the generation snapshot.
644
+ let resolved = resolve_launch(descriptor, &hooks, &prepared.request, Some(&prepared.discovered.catalog))?;
645
+ let plan = adapter.plan(&resolved)?;
646
+ validate_launch_plan(descriptor, &resolved, &plan)?;
647
+ write_role(&backend.binding, &prepared.request.identity, &prepared.role)?;
648
+ let operation = OperationId::new(format!("resume-{}", prepared.request.identity.instance.as_str()))?;
649
+ let mut io = ScopedMaterializer::new(descriptor, &resolved, &plan, operation.clone(), RealCommandRunner::default())?;
650
+ let clock = RealClock::new();
651
+ let mut physical = PhysicalRuntime::new(
652
+ Registration {
653
+ descriptor, hooks: &hooks, controls: selected.controls(),
654
+ startup: selected.startup(), session_panel: selected.session_panel(),
655
+ },
656
+ settings(&backend.binding, selected), operation.clone(), &clock,
657
+ )?;
658
+ physical.evidence = policy_evidence(&prepared.role.provider, &prepared.request.native, backend.binding.candidate_sha256)?;
659
+ let record = Lifecycle { store: &mut backend.store, host: &mut physical, io: &mut io }.startup(
660
+ &Adapter { descriptor, hooks: &hooks, catalog: Some(&prepared.discovered.catalog) },
661
+ &prepared.request,
662
+ Routing {
663
+ pane: "unpublished".into(),
664
+ binding_key: format!("binding-{}", prepared.request.identity.instance.as_str()),
665
+ server_key: selected.server_key_for(&prepared.request.identity)?,
666
+ },
667
+ operation,
668
+ )?;
669
+ Ok(record)
670
+ }
671
+
672
+ fn role_path(binding: &Binding, identity: &InstanceIdentity) -> PathBuf {
673
+ root_for(&binding.workspace.path, &binding.scope).join(format!(
674
+ "role-{}-{}.json", identity.seat.as_str(), identity.instance.as_str(),
675
+ ))
676
+ }
677
+ fn write_role(binding: &Binding, identity: &InstanceIdentity, role: &RoleConfig) -> Result<(), BackendError> {
678
+ use std::os::unix::fs::OpenOptionsExt;
679
+ if identity.scope != binding.scope || identity.seat != role.id {
680
+ return Err(BackendError::Metadata);
681
+ }
682
+ // Each generation has an immutable source; an old stopped generation's
683
+ // model/instructions never change when a new role is admitted.
684
+ let snapshot = serde_json::to_vec(role).map_err(|_| BackendError::Metadata)?;
685
+ let mut file = std::fs::OpenOptions::new().write(true).create_new(true)
686
+ .mode(0o600).custom_flags(libc::O_NOFOLLOW | libc::O_CLOEXEC)
687
+ .open(role_path(binding, identity))?;
688
+ file.write_all(&snapshot)?;
689
+ file.sync_all()?;
690
+ Ok(())
691
+ }
692
+ pub(super) fn read_role(binding: &Binding, identity: &InstanceIdentity) -> Result<RoleConfig, BackendError> {
693
+ let mut path = role_path(binding, identity);
694
+ // Existing generation-one deployments retain their immutable source.
695
+ if identity.generation == Generation(1) {
696
+ match std::fs::symlink_metadata(&path) {
697
+ Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
698
+ path = root_for(&binding.workspace.path, &binding.scope)
699
+ .join(format!("role-{}.json", identity.seat.as_str()));
700
+ }
701
+ Err(error) => return Err(error.into()),
702
+ Ok(_) => {},
703
+ }
704
+ }
484
705
  let metadata = std::fs::symlink_metadata(&path)?;
485
706
  if metadata.file_type().is_symlink() || !metadata.is_file() {
486
707
  return Err(BackendError::Metadata);
487
708
  }
488
709
  let mut bytes = Vec::new();
489
- std::fs::File::open(path)?
490
- .take(4 * 1024 * 1024 + 1)
491
- .read_to_end(&mut bytes)?;
710
+ use std::os::unix::fs::{MetadataExt, OpenOptionsExt};
711
+ let file = std::fs::OpenOptions::new().read(true)
712
+ .custom_flags(libc::O_NOFOLLOW | libc::O_CLOEXEC).open(path)?;
713
+ let opened = file.metadata()?;
714
+ if metadata.dev() != opened.dev() || metadata.ino() != opened.ino() {
715
+ return Err(BackendError::Metadata);
716
+ }
717
+ file.take(4 * 1024 * 1024 + 1).read_to_end(&mut bytes)?;
492
718
  if bytes.len() > 4 * 1024 * 1024 {
493
719
  return Err(BackendError::Metadata);
494
720
  }
495
721
  let role: RoleConfig = serde_json::from_slice(&bytes).map_err(|_| BackendError::Metadata)?;
496
- if &role.id != id
497
- || role.provider.as_str() != "kiro"
722
+ if role.id != identity.seat || identity.scope != binding.scope
723
+ || registry::descriptor(role.provider.as_str())
724
+ .is_none_or(|descriptor| descriptor.identity.id != role.provider.as_str())
498
725
  || digest(role.prompt.text.as_bytes()) != role.prompt.sha256
499
726
  {
500
727
  return Err(BackendError::Metadata);
@@ -503,38 +730,36 @@ pub(super) fn read_role(binding: &Binding, id: &SeatId) -> Result<RoleConfig, Ba
503
730
  }
504
731
 
505
732
  pub(super) fn policy_evidence(
733
+ provider: &ProviderId,
506
734
  native: &NativeIdentity,
507
735
  candidate_sha256: Digest,
508
- ) -> Vec<CapabilityEvidence> {
509
- team_agent_contract::kiro::interaction::PROFILES
510
- .iter()
511
- .filter(|profile| profile.matches_native(native))
512
- .flat_map(|profile| {
513
- // Bind this observed executable and candidate to the stable recipe.
514
- // This is not the historical R0 receipt, nor a business/MCP PASS.
515
- let binding_sha256 = digest(
516
- format!(
517
- "kiro-runtime-policy-v1\n{native:?}\n{}\n{:?}\n{candidate_sha256:?}",
518
- profile.id, profile.policy_sha256,
519
- )
520
- .as_bytes(),
521
- );
522
- profile
523
- .operations
524
- .iter()
525
- .map(move |operation| CapabilityEvidence {
526
- provider: ProviderId::new("kiro").expect("static provider"),
527
- native: native.clone(),
528
- profile_id: profile.id.into(),
529
- policy_sha256: profile.policy_sha256,
530
- channel: profile.channel,
531
- operation: *operation,
532
- kind: EvidenceKind::Native,
533
- candidate_sha256,
534
- evidence_sha256: binding_sha256,
535
- })
536
- })
537
- .collect()
736
+ ) -> Result<Vec<CapabilityEvidence>, ContractError> {
737
+ let descriptor = registry::provider(provider.as_str())?.descriptor();
738
+ let profiles = descriptor.input.profiles.require("native input profiles")?;
739
+ let mut evidence = Vec::new();
740
+ for profile in profiles.iter().filter(|profile| profile.matches_native(native)) {
741
+ // Unknown submit recipes never produce Native evidence, even when the
742
+ // provider has a readable catalog or a recognizable TUI surface.
743
+ profile.policy.require("native submit policy")?;
744
+ let binding_sha256 = digest(
745
+ format!(
746
+ "{}-runtime-policy-v1\n{native:?}\n{}\n{:?}\n{candidate_sha256:?}",
747
+ descriptor.identity.id, profile.id, profile.policy_sha256,
748
+ ).as_bytes(),
749
+ );
750
+ evidence.extend(profile.operations.iter().map(|operation| CapabilityEvidence {
751
+ provider: provider.clone(),
752
+ native: native.clone(),
753
+ profile_id: profile.id.into(),
754
+ policy_sha256: profile.policy_sha256,
755
+ channel: profile.channel,
756
+ operation: *operation,
757
+ kind: EvidenceKind::Native,
758
+ candidate_sha256,
759
+ evidence_sha256: binding_sha256,
760
+ }));
761
+ }
762
+ Ok(evidence)
538
763
  }
539
764
 
540
765
  struct NoNewIo;
@@ -577,19 +802,23 @@ pub fn stop(workspace: &Path, team: &str, seat: &SeatId) -> Result<OperationReco
577
802
  .ok_or(Error::Invalid("unknown native seat"))?;
578
803
  let nonce = backend.store.propose_identity(seat)?.instance;
579
804
  let operation = OperationId::new(format!("stop-{}", nonce.as_str()))?;
580
- let adapter = adapter(&backend.binding, &target.identity)?;
805
+ let selected = registry::provider(target.provider.as_str())?;
806
+ let adapter = adapter(&backend.binding, &target.identity, &target.provider)?;
581
807
  let hooks = adapter.hooks();
582
808
  let clock = RealClock::new();
583
809
  let mut physical = PhysicalRuntime::new(
584
810
  Registration {
585
- descriptor: &KIRO_DESCRIPTOR,
811
+ descriptor: selected.descriptor(),
586
812
  hooks: &hooks,
587
- controls: &[],
813
+ controls: selected.controls(),
814
+ startup: selected.startup(),
815
+ session_panel: selected.session_panel(),
588
816
  },
589
- settings(&backend.binding),
817
+ settings(&backend.binding, selected),
590
818
  operation.clone(),
591
819
  &clock,
592
820
  )?;
821
+ physical.evidence = policy_evidence(&target.provider, &target.native, backend.binding.candidate_sha256)?;
593
822
  Ok(Lifecycle {
594
823
  store: &mut backend.store,
595
824
  host: &mut physical,
@@ -602,6 +831,110 @@ pub fn stop(workspace: &Path, team: &str, seat: &SeatId) -> Result<OperationReco
602
831
  mod tests {
603
832
  use super::*;
604
833
 
834
+ struct RoleFixture { root: PathBuf, binding: Binding, role: RoleConfig, identity: InstanceIdentity }
835
+ impl RoleFixture {
836
+ fn new() -> Self {
837
+ use std::os::unix::fs::DirBuilderExt;
838
+ let root = std::env::temp_dir().join(format!("contract-role-{}-{}", std::process::id(),
839
+ std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos()));
840
+ std::fs::DirBuilder::new().mode(0o700).create(&root).unwrap();
841
+ let cwd = resolve_cwd(&root).unwrap();
842
+ let scope = scope_for(&cwd, "test").unwrap();
843
+ std::fs::create_dir_all(root_for(&cwd.path, &scope)).unwrap();
844
+ let seat = SeatId::new("worker").unwrap();
845
+ let identity = InstanceIdentity { scope: scope.clone(), seat: seat.clone(), instance: InstanceId::new("first").unwrap(), generation: Generation(1) };
846
+ let binding = Binding { version: 1, workspace: cwd, team: "test".into(), scope,
847
+ endpoint: "controlled".into(), candidate: "/controlled/candidate".into(), candidate_sha256: digest(b"candidate"),
848
+ tmux: "/controlled/tmux".into(), transport_root: "/controlled/transport".into() };
849
+ let role = RoleConfig { id: seat, provider: ProviderId::new("cursor").unwrap(), source: root.join("role.md"),
850
+ role: "worker".into(), model: "first-model".into(), effort: None, bypass: false, paused: false,
851
+ body: "bounded fixture".into(), prompt: super::super::prompt::Prompt {
852
+ text: "bounded fixture".into(), sha256: digest(b"bounded fixture"), sources: vec![],
853
+ } };
854
+ Self { root, binding, role, identity }
855
+ }
856
+ }
857
+ impl Drop for RoleFixture { fn drop(&mut self) { let _ = std::fs::remove_dir_all(&self.root); } }
858
+
859
+ #[test]
860
+ fn restart_skips_stopped_mirror_and_stopped_native_store_before_resume_discovery() {
861
+ for provider in ["cursor", "grok_build"] {
862
+ let mut fixture = RoleFixture::new();
863
+ let directory = fixture.root.join("roles");
864
+ std::fs::create_dir_all(directory.join("agents")).unwrap();
865
+ std::fs::write(directory.join("TEAM.md"), "---\nname: test\n---\nFixture\n").unwrap();
866
+ std::fs::write(directory.join("agents/worker.md"),
867
+ format!("---\nprovider: {provider}\nmodel: unavailable-fixture-model\n---\nFixture\n")).unwrap();
868
+ let stopped = serde_json::json!({"agents":{"worker":{"provider":provider,"status":"stopped","session_id":"preserved","generation":1}}});
869
+ // No framework binding/store/native installation exists yet. A
870
+ // stopped mirror must not even attempt to open the native store.
871
+ assert!(prepare_restart(&fixture.root, "test", &directory, &stopped).unwrap().is_empty());
872
+
873
+ fixture.binding.transport_root = fixture.binding.workspace.path.join("native-store");
874
+ fixture.binding.endpoint = endpoint(&fixture.binding.scope);
875
+ let store = ContractStore::create(&fixture.binding.transport_root, fixture.binding.scope.clone(), &fixture.binding.endpoint).unwrap();
876
+ let native = NativeIdentity { version: "fixture".into(), harness: "unavailable".into(), ui: "tui".into(),
877
+ platform: Platform::MacOs, executable_sha256: digest(b"fixture") };
878
+ let seat = SeatRecord {
879
+ identity: fixture.identity.clone(), provider: ProviderId::new(provider).unwrap(), native,
880
+ cwd: fixture.binding.workspace.clone(), endpoint: fixture.binding.endpoint.clone(),
881
+ pane: "unpublished".into(), binding_key: "fixture".into(), profile: "unavailable".into(), server_key: "fixture".into(),
882
+ evidence_kind: EvidenceKind::Native, status: SeatStatus::Stopped, process: None, physical: None,
883
+ session: None, resources: vec![], workspace_resources: vec![], bootstrap_used: false,
884
+ };
885
+ // Test-only canonical fixture, never a live-store recovery shortcut.
886
+ let db = rusqlite::Connection::open(fixture.binding.transport_root.join("contract.db")).unwrap();
887
+ db.execute("INSERT INTO contract_seats(seat,record) VALUES(?1,?2)",
888
+ rusqlite::params![fixture.identity.seat.as_str(), serde_json::to_string(&seat).unwrap()]).unwrap();
889
+ db.execute("INSERT INTO contract_generations(seat,generation) VALUES(?1,1)", [fixture.identity.seat.as_str()]).unwrap();
890
+ drop(db);
891
+ std::fs::write(root_for(&fixture.binding.workspace.path, &fixture.binding.scope).join("framework-binding.json"),
892
+ serde_json::to_vec(&fixture.binding).unwrap()).unwrap();
893
+ let stale_running = serde_json::json!({"agents":{"worker":{"provider":provider,"status":"running"}}});
894
+ assert!(prepare_restart(&fixture.root, "test", &directory, &stale_running).unwrap().is_empty());
895
+ assert_eq!(store.seat(&fixture.identity.seat).unwrap(), Some(seat));
896
+ assert_eq!(store.propose_identity(&fixture.identity.seat).unwrap().generation, Generation(2));
897
+ }
898
+ }
899
+
900
+ #[test]
901
+ fn role_snapshots_are_instance_scoped_and_exclusive() {
902
+ let fixture = RoleFixture::new();
903
+ write_role(&fixture.binding, &fixture.identity, &fixture.role).unwrap();
904
+ let mut second = fixture.identity.clone();
905
+ second.instance = InstanceId::new("second").unwrap();
906
+ second.generation = Generation(2);
907
+ let mut role = fixture.role.clone();
908
+ role.model = "second-model".into();
909
+ write_role(&fixture.binding, &second, &role).unwrap();
910
+ assert_eq!(read_role(&fixture.binding, &fixture.identity).unwrap().model, "first-model");
911
+ assert_eq!(read_role(&fixture.binding, &second).unwrap().model, "second-model");
912
+ assert!(write_role(&fixture.binding, &fixture.identity, &role).is_err());
913
+ assert_eq!(read_role(&fixture.binding, &fixture.identity).unwrap().model, "first-model");
914
+ }
915
+
916
+ #[test]
917
+ fn legacy_role_fallback_is_only_for_missing_generation_one_not_symlinks() {
918
+ let fixture = RoleFixture::new();
919
+ let old = root_for(&fixture.binding.workspace.path, &fixture.binding.scope).join("role-worker.json");
920
+ std::fs::write(&old, serde_json::to_vec(&fixture.role).unwrap()).unwrap();
921
+ assert_eq!(read_role(&fixture.binding, &fixture.identity).unwrap().model, "first-model");
922
+ let mut later = fixture.identity.clone();
923
+ later.generation = Generation(2);
924
+ assert!(read_role(&fixture.binding, &later).is_err());
925
+ std::os::unix::fs::symlink(fixture.root.join("missing"), role_path(&fixture.binding, &fixture.identity)).unwrap();
926
+ assert!(matches!(read_role(&fixture.binding, &fixture.identity), Err(BackendError::Metadata)));
927
+ }
928
+
929
+ #[test]
930
+ fn mismatched_role_owner_cannot_write_a_snapshot() {
931
+ let fixture = RoleFixture::new();
932
+ let mut identity = fixture.identity.clone();
933
+ identity.seat = SeatId::new("other").unwrap();
934
+ assert!(matches!(write_role(&fixture.binding, &identity, &fixture.role), Err(BackendError::Metadata)));
935
+ assert!(!role_path(&fixture.binding, &identity).exists());
936
+ }
937
+
605
938
  #[test]
606
939
  fn shutdown_error_exposes_the_durable_operation_failure_not_metadata() {
607
940
  let failure = "native instance not alive or replaced: Unknown; stage=check-bound; expected_pid=44123; process_state=Some(Replaced)";
@@ -618,6 +951,65 @@ mod tests {
618
951
  assert!(!shown.contains("metadata is invalid"));
619
952
  }
620
953
 
954
+ #[test]
955
+ fn cursor_metadata_never_mints_runtime_policy_evidence() {
956
+ let provider = ProviderId::new("cursor").unwrap();
957
+ let mut native = NativeIdentity {
958
+ version: "2026.09.29-a1b2c3".into(),
959
+ harness: "cursor-launcher".into(),
960
+ ui: "metadata".into(),
961
+ platform: Platform::MacOs,
962
+ executable_sha256: digest(b"launcher only"),
963
+ };
964
+ let candidate = digest(b"candidate");
965
+ assert!(policy_evidence(&provider, &native, candidate).unwrap().is_empty());
966
+ native.harness = "cursor-agent".into();
967
+ native.ui = "tui".into();
968
+ // Admission supplies a separately image-fenced runtime identity. The
969
+ // recipe still cannot be borrowed from metadata or another provider.
970
+ assert!(!policy_evidence(&provider, &native, candidate).unwrap().is_empty());
971
+ // Even a Kiro-looking image cannot reuse its evidence under Cursor.
972
+ native.harness = "v2".into();
973
+ native.version = "2.29.0".into();
974
+ assert!(policy_evidence(&provider, &native, candidate).unwrap().is_empty());
975
+ }
976
+
977
+ #[test]
978
+ fn cursor_request_refuses_metadata_launch_and_wrong_provider_catalog() {
979
+ let provider = ProviderId::new("cursor").unwrap();
980
+ let scope = ScopeId::new("controlled").unwrap();
981
+ let seat = SeatId::new("worker").unwrap();
982
+ let binding = Binding {
983
+ version: 1,
984
+ workspace: CwdIdentity { path: "/controlled".into(), identity: digest(b"cwd") },
985
+ team: "demo".into(), scope: scope.clone(), endpoint: "/controlled/socket".into(),
986
+ candidate: "/controlled/candidate".into(), candidate_sha256: digest(b"candidate"),
987
+ tmux: "/controlled/tmux".into(), transport_root: "/controlled/runtime".into(),
988
+ };
989
+ let role = RoleConfig {
990
+ id: seat.clone(), provider: provider.clone(), source: "/controlled/role.md".into(),
991
+ role: "worker".into(), model: "gpt-5.6-luna-xhigh".into(), effort: None,
992
+ bypass: true, paused: false, body: "role".into(),
993
+ prompt: super::super::prompt::Prompt { text: "prompt".into(), sha256: digest(b"prompt"), sources: vec![] },
994
+ };
995
+ let identity = InstanceIdentity { scope, seat, instance: InstanceId::new("preflight").unwrap(), generation: Generation(1) };
996
+ let native = NativeIdentity {
997
+ version: "2026.09.29-a1b2c3".into(), harness: "cursor-launcher".into(),
998
+ ui: "metadata".into(), platform: Platform::MacOs, executable_sha256: digest(b"launcher"),
999
+ };
1000
+ let mut discovery = Discovery {
1001
+ runtime: None,
1002
+ engine: "/controlled/cursor-agent".into(), native: native.clone(),
1003
+ catalog: CatalogObservation { provider, native,
1004
+ schema: team_agent_contract::providers::cursor::native::CATALOG_SOURCE.schema.into(), models: vec![] },
1005
+ };
1006
+ assert!(matches!(request(&binding, &role, identity.clone(), &discovery),
1007
+ Err(BackendError::Contract(ContractError::ProfileUnavailable))));
1008
+ discovery.catalog.provider = ProviderId::new("kiro").unwrap();
1009
+ assert!(matches!(request(&binding, &role, identity, &discovery),
1010
+ Err(BackendError::Contract(ContractError::Mismatch("role native catalog")))));
1011
+ }
1012
+
621
1013
  #[test]
622
1014
  fn policy_bindings_track_runtime_native_identity_not_a_release_receipt() {
623
1015
  let mut native = NativeIdentity {
@@ -628,11 +1020,12 @@ mod tests {
628
1020
  executable_sha256: digest(b"old image"),
629
1021
  };
630
1022
  let candidate = digest(b"candidate");
631
- let old = policy_evidence(&native, candidate);
1023
+ let provider = ProviderId::new("kiro").unwrap();
1024
+ let old = policy_evidence(&provider, &native, candidate).unwrap();
632
1025
  assert!(!old.is_empty());
633
1026
  native.version = "2.29.0".into();
634
1027
  native.executable_sha256 = digest(b"updated image");
635
- let new = policy_evidence(&native, candidate);
1028
+ let new = policy_evidence(&provider, &native, candidate).unwrap();
636
1029
  assert_eq!(new.len(), old.len());
637
1030
  for (old, new) in old.iter().zip(&new) {
638
1031
  assert_eq!(new.native, native);
@@ -641,6 +1034,6 @@ mod tests {
641
1034
  assert_ne!(new.evidence_sha256, old.evidence_sha256);
642
1035
  }
643
1036
  native.harness = "v3".into();
644
- assert!(policy_evidence(&native, candidate).is_empty());
1037
+ assert!(policy_evidence(&provider, &native, candidate).unwrap().is_empty());
645
1038
  }
646
1039
  }