@team-agent/installer 0.5.114 → 0.5.115

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.
@@ -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,
@@ -302,7 +321,10 @@ fn request(
302
321
  /// seats (including legacy peers). Bounded read-only native commands run; no
303
322
  /// runtime store, worker config, private socket, or worker process is created.
304
323
  pub fn preflight(team: &super::config::TeamConfig) -> Result<(), BackendError> {
305
- let discovered = discovery::discover(&team.workspace.path)?;
324
+ if team.roles.iter().all(|role| role.paused) {
325
+ return Ok(());
326
+ }
327
+ let mut discoveries = BTreeMap::new();
306
328
  let candidate = std::env::current_exe()?.canonicalize()?;
307
329
  let candidate_sha256 =
308
330
  fingerprint_file(&candidate, 1024 * 1024 * 1024, Duration::from_secs(10))?;
@@ -317,36 +339,46 @@ pub fn preflight(team: &super::config::TeamConfig) -> Result<(), BackendError> {
317
339
  tmux: discovery::executable("tmux")?,
318
340
  transport_root: "/tmp/preflight-no-write".into(),
319
341
  };
320
- for role in &team.roles {
342
+ for role in team.roles.iter().filter(|role| !role.paused) {
343
+ let selected = registry::provider(role.provider.as_str())?;
344
+ if !discoveries.contains_key(&role.provider) {
345
+ discoveries.insert(
346
+ role.provider.clone(),
347
+ discovery::discover_runtime(&role.provider, &team.workspace.path)?,
348
+ );
349
+ }
350
+ let discovered = discoveries.get(&role.provider).ok_or(BackendError::Metadata)?;
321
351
  let identity = InstanceIdentity {
322
352
  scope: team.scope.clone(),
323
353
  seat: role.id.clone(),
324
354
  instance: InstanceId::new("preflight")?,
325
355
  generation: Generation(1),
326
356
  };
327
- let adapter = adapter(&binding, &identity)?;
357
+ let adapter = launch_adapter(&binding, &identity, &role.provider, discovered)?;
328
358
  let hooks = adapter.hooks();
329
- let request = request(&binding, role, identity, &discovered)?;
330
- let resolved = resolve_launch(
331
- &KIRO_DESCRIPTOR,
359
+ let request = request(&binding, role, identity, discovered)?;
360
+ let resolved = resolve_launch_preflight(
361
+ selected.descriptor(),
332
362
  &hooks,
333
363
  &request,
334
364
  Some(&discovered.catalog),
335
365
  )?;
336
- validate_launch_plan(&KIRO_DESCRIPTOR, &resolved, &adapter.plan(&resolved)?)?;
366
+ validate_launch_plan(selected.descriptor(), &resolved, &adapter.plan(&resolved)?)?;
337
367
  }
338
368
  Ok(())
339
369
  }
340
370
 
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.
371
+ /// Fresh startup is distinct from resume. An existing generation is never
372
+ /// silently replaced with a new native session.
343
373
  pub fn start(
344
374
  workspace: &Path,
345
375
  team: &str,
346
376
  role: &RoleConfig,
347
377
  members: &[MemberRoute],
348
378
  ) -> Result<(Binding, OperationRecord), BackendError> {
349
- let discovered = discovery::discover(workspace)?;
379
+ let selected = registry::provider(role.provider.as_str())?;
380
+ let descriptor = selected.descriptor();
381
+ let discovered = discovery::discover_runtime(&role.provider, workspace)?;
350
382
  let cwd = resolve_cwd(workspace)?;
351
383
  let scope = scope_for(&cwd, team)?;
352
384
  let candidate = std::env::current_exe()?.canonicalize()?;
@@ -370,16 +402,18 @@ pub fn start(
370
402
  instance: InstanceId::new("preflight")?,
371
403
  generation: Generation(1),
372
404
  };
373
- let probe_adapter = adapter(&provisional, &provisional_identity)?;
405
+ let probe_adapter = launch_adapter(&provisional, &provisional_identity, &role.provider, &discovered)?;
374
406
  let probe_hooks = probe_adapter.hooks();
375
407
  let preflight = request(&provisional, role, provisional_identity, &discovered)?;
376
- let preflight = resolve_launch(
377
- &KIRO_DESCRIPTOR,
408
+ let preflight = resolve_launch_preflight(
409
+ descriptor,
378
410
  &probe_hooks,
379
411
  &preflight,
380
412
  Some(&discovered.catalog),
381
413
  )?;
382
- probe_adapter.plan(&preflight)?;
414
+ let preflight_plan = probe_adapter.plan(&preflight)?;
415
+ validate_launch_plan(descriptor, &preflight, &preflight_plan)?;
416
+ let server_key = selected.server_key()?;
383
417
  let mut backend = if root_for(&cwd.path, &scope).exists() {
384
418
  Backend::open(&cwd.path, team)?
385
419
  } else {
@@ -393,20 +427,6 @@ pub fn start(
393
427
  if backend.store.seat(&role.id)?.is_some() {
394
428
  return Err(Error::Conflict.into());
395
429
  }
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
430
  let route = backend.binding.framework().route()?;
411
431
  let mut peers: Vec<_> = members
412
432
  .iter()
@@ -426,11 +446,25 @@ pub fn start(
426
446
  });
427
447
  backend.store.set_framework_routes(&peers, Some(&route))?;
428
448
  let identity = backend.store.propose_identity(&role.id)?;
429
- let adapter = adapter(&backend.binding, &identity)?;
449
+ write_role(&backend.binding, &identity, role)?;
450
+ let adapter = launch_adapter(&backend.binding, &identity, &role.provider, &discovered)?;
430
451
  let hooks = adapter.hooks();
431
- let request = request(&backend.binding, role, identity, &discovered)?;
452
+ let mut request = request(&backend.binding, role, identity, &discovered)?;
453
+ if !descriptor.workspace.shared_cwd {
454
+ request.paths.cwd = super::workspace::prepare(&backend.binding, &role.id)?;
455
+ }
456
+ if let Some(runtime) = &discovered.runtime {
457
+ request.preassigned_session = Some(
458
+ team_agent_contract::providers::cursor::native::allocate_chat(
459
+ runtime, &request.paths.cwd, &mut RealCommandRunner::default(),
460
+ team_agent_contract::contract::hooks::ReadBounds {
461
+ deadline: Duration::from_secs(90), max_output_bytes: 1024 * 1024,
462
+ },
463
+ ).map_err(|error| BackendError::Discovery(DiscoveryError::Catalog(error)))?,
464
+ );
465
+ }
432
466
  let resolved = resolve_launch(
433
- &KIRO_DESCRIPTOR,
467
+ descriptor,
434
468
  &hooks,
435
469
  &request,
436
470
  Some(&discovered.catalog),
@@ -438,7 +472,7 @@ pub fn start(
438
472
  let plan = adapter.plan(&resolved)?;
439
473
  let operation = OperationId::new(format!("start-{}", request.identity.instance.as_str()))?;
440
474
  let mut io = ScopedMaterializer::new(
441
- &KIRO_DESCRIPTOR,
475
+ descriptor,
442
476
  &resolved,
443
477
  &plan,
444
478
  operation.clone(),
@@ -447,15 +481,15 @@ pub fn start(
447
481
  let clock = RealClock::new();
448
482
  let mut physical = PhysicalRuntime::new(
449
483
  Registration {
450
- descriptor: &KIRO_DESCRIPTOR,
484
+ descriptor,
451
485
  hooks: &hooks,
452
- controls: team_agent_contract::kiro::interaction::CONTROLS,
486
+ controls: selected.controls(),
453
487
  },
454
- settings(&backend.binding),
488
+ settings(&backend.binding, selected),
455
489
  operation.clone(),
456
490
  &clock,
457
491
  )?;
458
- physical.evidence = policy_evidence(&request.native, candidate_sha256);
492
+ physical.evidence = policy_evidence(&role.provider, &request.native, candidate_sha256)?;
459
493
  let record = Lifecycle {
460
494
  store: &mut backend.store,
461
495
  host: &mut physical,
@@ -463,7 +497,7 @@ pub fn start(
463
497
  }
464
498
  .startup(
465
499
  &Adapter {
466
- descriptor: &KIRO_DESCRIPTOR,
500
+ descriptor,
467
501
  hooks: &hooks,
468
502
  catalog: Some(&discovered.catalog),
469
503
  },
@@ -471,30 +505,187 @@ pub fn start(
471
505
  Routing {
472
506
  pane: "unpublished".into(),
473
507
  binding_key: format!("binding-{}", request.identity.instance.as_str()),
474
- server_key: "team".into(),
508
+ server_key: server_key.into(),
475
509
  },
476
510
  operation,
477
511
  )?;
478
512
  Ok((backend.binding, record))
479
513
  }
480
514
 
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()));
515
+ /// A fully admitted resume proposal. It is still effect-free: no parent stop,
516
+ /// session allocation, snapshot write or config replacement has occurred.
517
+ pub struct PreparedResume {
518
+ binding: Binding,
519
+ role: RoleConfig,
520
+ discovered: Discovery,
521
+ request: LaunchRequest,
522
+ }
523
+
524
+ impl PreparedResume {
525
+ pub(crate) fn seat(&self) -> &SeatId { &self.request.identity.seat }
526
+ pub(crate) fn session(&self) -> Option<&str> {
527
+ self.request.resume.as_ref().map(|resume| resume.binding.native_session.as_str())
528
+ }
529
+ }
530
+
531
+ pub(crate) fn prepare_restart(
532
+ workspace: &Path,
533
+ team: &str,
534
+ directory: &Path,
535
+ state: &serde_json::Value,
536
+ ) -> Result<Vec<PreparedResume>, BackendError> {
537
+ let config = super::framework::team_config(workspace, directory, team)?.ok_or(BackendError::Metadata)?;
538
+ let mut prepared = Vec::new();
539
+ let agents = state.get("agents").and_then(serde_json::Value::as_object).ok_or(BackendError::Metadata)?;
540
+ for (id, _) in agents.iter().filter(|(_, row)| super::framework::is_contract(row)) {
541
+ let Some(role) = config.roles.iter().find(|role| role.id.as_str() == id) else {
542
+ // Removed roles are not silently re-created from an old snapshot.
543
+ continue;
544
+ };
545
+ if role.paused { continue; }
546
+ prepared.push(prepare_resume(workspace, team, &role.id, Some(role))?);
547
+ }
548
+ Ok(prepared)
549
+ }
550
+
551
+ pub fn prepare_resume(
552
+ workspace: &Path,
553
+ team: &str,
554
+ seat: &SeatId,
555
+ role: Option<&RoleConfig>,
556
+ ) -> Result<PreparedResume, BackendError> {
557
+ let backend = Backend::open_readonly(workspace, team)?;
558
+ let parent = backend.store.seat(seat)?.ok_or(BackendError::Metadata)?;
559
+ if parent.status == SeatStatus::Unknown {
560
+ return Err(Error::Fence.into());
561
+ }
562
+ let role = match role {
563
+ Some(role) => role.clone(),
564
+ None => read_role(&backend.binding, &parent.identity)?,
565
+ };
566
+ if role.id != parent.identity.seat || role.provider != parent.provider {
567
+ return Err(ContractError::Mismatch("resume role provider").into());
568
+ }
569
+ let source = parent.session.clone().ok_or(ContractError::Invalid("native resume binding required"))?;
570
+ if resolve_cwd(&source.cwd.path)? != source.cwd {
571
+ return Err(ContractError::Mismatch("resume cwd incarnation").into());
572
+ }
573
+ let candidate = std::env::current_exe()?.canonicalize()?;
574
+ let candidate_sha256 = fingerprint_file(&candidate, 1024 * 1024 * 1024, Duration::from_secs(10))?;
575
+ if backend.binding.candidate != candidate || backend.binding.candidate_sha256 != candidate_sha256 {
576
+ return Err(Error::Fence.into());
577
+ }
578
+ let discovered = discovery::discover_runtime(&role.provider, workspace)?;
579
+ let identity = backend.store.propose_identity(seat)?;
580
+ let adapter = launch_adapter(&backend.binding, &identity, &role.provider, &discovered)?;
581
+ let hooks = adapter.hooks();
582
+ let mut request = request(&backend.binding, &role, identity, &discovered)?;
583
+ request.operation = Operation::Resume;
584
+ request.paths.cwd = source.cwd.clone();
585
+ request.resume = Some(ResumeRequest {
586
+ binding: source, expected_source: parent.identity,
587
+ });
588
+ let selected = registry::provider(role.provider.as_str())?;
589
+ let resolved = resolve_launch(selected.descriptor(), &hooks, &request, Some(&discovered.catalog))?;
590
+ validate_launch_plan(selected.descriptor(), &resolved, &adapter.plan(&resolved)?)?;
591
+ Ok(PreparedResume { binding: backend.binding, role, discovered, request })
592
+ }
593
+
594
+ pub fn resume(prepared: PreparedResume) -> Result<OperationRecord, BackendError> {
595
+ let mut backend = Backend::open(&prepared.binding.workspace.path, &prepared.binding.team)?;
596
+ if serde_json::to_vec(&backend.binding).map_err(|_| BackendError::Metadata)?
597
+ != serde_json::to_vec(&prepared.binding).map_err(|_| BackendError::Metadata)?
598
+ {
599
+ return Err(Error::Fence.into());
600
+ }
601
+ let source = prepared.request.resume.as_ref().ok_or(BackendError::Metadata)?;
602
+ let parent = backend.store.assert_current(&source.expected_source)?;
603
+ if parent.session.as_ref() != Some(&source.binding) {
604
+ return Err(Error::Fence.into());
605
+ }
606
+ let selected = registry::provider(prepared.role.provider.as_str())?;
607
+ let descriptor = selected.descriptor();
608
+ let adapter = launch_adapter(&backend.binding, &prepared.request.identity, &prepared.role.provider, &prepared.discovered)?;
609
+ let hooks = adapter.hooks();
610
+ // Revalidate the exact plan before publishing the generation snapshot.
611
+ let resolved = resolve_launch(descriptor, &hooks, &prepared.request, Some(&prepared.discovered.catalog))?;
612
+ let plan = adapter.plan(&resolved)?;
613
+ validate_launch_plan(descriptor, &resolved, &plan)?;
614
+ write_role(&backend.binding, &prepared.request.identity, &prepared.role)?;
615
+ let operation = OperationId::new(format!("resume-{}", prepared.request.identity.instance.as_str()))?;
616
+ let mut io = ScopedMaterializer::new(descriptor, &resolved, &plan, operation.clone(), RealCommandRunner::default())?;
617
+ let clock = RealClock::new();
618
+ let mut physical = PhysicalRuntime::new(
619
+ Registration { descriptor, hooks: &hooks, controls: selected.controls() },
620
+ settings(&backend.binding, selected), operation.clone(), &clock,
621
+ )?;
622
+ physical.evidence = policy_evidence(&prepared.role.provider, &prepared.request.native, backend.binding.candidate_sha256)?;
623
+ let record = Lifecycle { store: &mut backend.store, host: &mut physical, io: &mut io }.startup(
624
+ &Adapter { descriptor, hooks: &hooks, catalog: Some(&prepared.discovered.catalog) },
625
+ &prepared.request,
626
+ Routing {
627
+ pane: "unpublished".into(),
628
+ binding_key: format!("binding-{}", prepared.request.identity.instance.as_str()),
629
+ server_key: selected.server_key()?.into(),
630
+ },
631
+ operation,
632
+ )?;
633
+ Ok(record)
634
+ }
635
+
636
+ fn role_path(binding: &Binding, identity: &InstanceIdentity) -> PathBuf {
637
+ root_for(&binding.workspace.path, &binding.scope).join(format!(
638
+ "role-{}-{}.json", identity.seat.as_str(), identity.instance.as_str(),
639
+ ))
640
+ }
641
+ fn write_role(binding: &Binding, identity: &InstanceIdentity, role: &RoleConfig) -> Result<(), BackendError> {
642
+ use std::os::unix::fs::OpenOptionsExt;
643
+ if identity.scope != binding.scope || identity.seat != role.id {
644
+ return Err(BackendError::Metadata);
645
+ }
646
+ // Each generation has an immutable source; an old stopped generation's
647
+ // model/instructions never change when a new role is admitted.
648
+ let snapshot = serde_json::to_vec(role).map_err(|_| BackendError::Metadata)?;
649
+ let mut file = std::fs::OpenOptions::new().write(true).create_new(true)
650
+ .mode(0o600).custom_flags(libc::O_NOFOLLOW | libc::O_CLOEXEC)
651
+ .open(role_path(binding, identity))?;
652
+ file.write_all(&snapshot)?;
653
+ file.sync_all()?;
654
+ Ok(())
655
+ }
656
+ pub(super) fn read_role(binding: &Binding, identity: &InstanceIdentity) -> Result<RoleConfig, BackendError> {
657
+ let mut path = role_path(binding, identity);
658
+ // Existing generation-one deployments retain their immutable source.
659
+ if identity.generation == Generation(1) {
660
+ match std::fs::symlink_metadata(&path) {
661
+ Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
662
+ path = root_for(&binding.workspace.path, &binding.scope)
663
+ .join(format!("role-{}.json", identity.seat.as_str()));
664
+ }
665
+ Err(error) => return Err(error.into()),
666
+ Ok(_) => {},
667
+ }
668
+ }
484
669
  let metadata = std::fs::symlink_metadata(&path)?;
485
670
  if metadata.file_type().is_symlink() || !metadata.is_file() {
486
671
  return Err(BackendError::Metadata);
487
672
  }
488
673
  let mut bytes = Vec::new();
489
- std::fs::File::open(path)?
490
- .take(4 * 1024 * 1024 + 1)
491
- .read_to_end(&mut bytes)?;
674
+ use std::os::unix::fs::{MetadataExt, OpenOptionsExt};
675
+ let file = std::fs::OpenOptions::new().read(true)
676
+ .custom_flags(libc::O_NOFOLLOW | libc::O_CLOEXEC).open(path)?;
677
+ let opened = file.metadata()?;
678
+ if metadata.dev() != opened.dev() || metadata.ino() != opened.ino() {
679
+ return Err(BackendError::Metadata);
680
+ }
681
+ file.take(4 * 1024 * 1024 + 1).read_to_end(&mut bytes)?;
492
682
  if bytes.len() > 4 * 1024 * 1024 {
493
683
  return Err(BackendError::Metadata);
494
684
  }
495
685
  let role: RoleConfig = serde_json::from_slice(&bytes).map_err(|_| BackendError::Metadata)?;
496
- if &role.id != id
497
- || role.provider.as_str() != "kiro"
686
+ if role.id != identity.seat || identity.scope != binding.scope
687
+ || registry::descriptor(role.provider.as_str())
688
+ .is_none_or(|descriptor| descriptor.identity.id != role.provider.as_str())
498
689
  || digest(role.prompt.text.as_bytes()) != role.prompt.sha256
499
690
  {
500
691
  return Err(BackendError::Metadata);
@@ -503,38 +694,36 @@ pub(super) fn read_role(binding: &Binding, id: &SeatId) -> Result<RoleConfig, Ba
503
694
  }
504
695
 
505
696
  pub(super) fn policy_evidence(
697
+ provider: &ProviderId,
506
698
  native: &NativeIdentity,
507
699
  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()
700
+ ) -> Result<Vec<CapabilityEvidence>, ContractError> {
701
+ let descriptor = registry::provider(provider.as_str())?.descriptor();
702
+ let profiles = descriptor.input.profiles.require("native input profiles")?;
703
+ let mut evidence = Vec::new();
704
+ for profile in profiles.iter().filter(|profile| profile.matches_native(native)) {
705
+ // Unknown submit recipes never produce Native evidence, even when the
706
+ // provider has a readable catalog or a recognizable TUI surface.
707
+ profile.policy.require("native submit policy")?;
708
+ let binding_sha256 = digest(
709
+ format!(
710
+ "{}-runtime-policy-v1\n{native:?}\n{}\n{:?}\n{candidate_sha256:?}",
711
+ descriptor.identity.id, profile.id, profile.policy_sha256,
712
+ ).as_bytes(),
713
+ );
714
+ evidence.extend(profile.operations.iter().map(|operation| CapabilityEvidence {
715
+ provider: provider.clone(),
716
+ native: native.clone(),
717
+ profile_id: profile.id.into(),
718
+ policy_sha256: profile.policy_sha256,
719
+ channel: profile.channel,
720
+ operation: *operation,
721
+ kind: EvidenceKind::Native,
722
+ candidate_sha256,
723
+ evidence_sha256: binding_sha256,
724
+ }));
725
+ }
726
+ Ok(evidence)
538
727
  }
539
728
 
540
729
  struct NoNewIo;
@@ -577,16 +766,17 @@ pub fn stop(workspace: &Path, team: &str, seat: &SeatId) -> Result<OperationReco
577
766
  .ok_or(Error::Invalid("unknown native seat"))?;
578
767
  let nonce = backend.store.propose_identity(seat)?.instance;
579
768
  let operation = OperationId::new(format!("stop-{}", nonce.as_str()))?;
580
- let adapter = adapter(&backend.binding, &target.identity)?;
769
+ let selected = registry::provider(target.provider.as_str())?;
770
+ let adapter = adapter(&backend.binding, &target.identity, &target.provider)?;
581
771
  let hooks = adapter.hooks();
582
772
  let clock = RealClock::new();
583
773
  let mut physical = PhysicalRuntime::new(
584
774
  Registration {
585
- descriptor: &KIRO_DESCRIPTOR,
775
+ descriptor: selected.descriptor(),
586
776
  hooks: &hooks,
587
777
  controls: &[],
588
778
  },
589
- settings(&backend.binding),
779
+ settings(&backend.binding, selected),
590
780
  operation.clone(),
591
781
  &clock,
592
782
  )?;
@@ -602,6 +792,69 @@ pub fn stop(workspace: &Path, team: &str, seat: &SeatId) -> Result<OperationReco
602
792
  mod tests {
603
793
  use super::*;
604
794
 
795
+ struct RoleFixture { root: PathBuf, binding: Binding, role: RoleConfig, identity: InstanceIdentity }
796
+ impl RoleFixture {
797
+ fn new() -> Self {
798
+ use std::os::unix::fs::DirBuilderExt;
799
+ let root = std::env::temp_dir().join(format!("contract-role-{}-{}", std::process::id(),
800
+ std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos()));
801
+ std::fs::DirBuilder::new().mode(0o700).create(&root).unwrap();
802
+ let cwd = resolve_cwd(&root).unwrap();
803
+ let scope = scope_for(&cwd, "test").unwrap();
804
+ std::fs::create_dir_all(root_for(&cwd.path, &scope)).unwrap();
805
+ let seat = SeatId::new("worker").unwrap();
806
+ let identity = InstanceIdentity { scope: scope.clone(), seat: seat.clone(), instance: InstanceId::new("first").unwrap(), generation: Generation(1) };
807
+ let binding = Binding { version: 1, workspace: cwd, team: "test".into(), scope,
808
+ endpoint: "controlled".into(), candidate: "/controlled/candidate".into(), candidate_sha256: digest(b"candidate"),
809
+ tmux: "/controlled/tmux".into(), transport_root: "/controlled/transport".into() };
810
+ let role = RoleConfig { id: seat, provider: ProviderId::new("cursor").unwrap(), source: root.join("role.md"),
811
+ role: "worker".into(), model: "first-model".into(), effort: None, bypass: false, paused: false,
812
+ body: "bounded fixture".into(), prompt: super::super::prompt::Prompt {
813
+ text: "bounded fixture".into(), sha256: digest(b"bounded fixture"), sources: vec![],
814
+ } };
815
+ Self { root, binding, role, identity }
816
+ }
817
+ }
818
+ impl Drop for RoleFixture { fn drop(&mut self) { let _ = std::fs::remove_dir_all(&self.root); } }
819
+
820
+ #[test]
821
+ fn role_snapshots_are_instance_scoped_and_exclusive() {
822
+ let fixture = RoleFixture::new();
823
+ write_role(&fixture.binding, &fixture.identity, &fixture.role).unwrap();
824
+ let mut second = fixture.identity.clone();
825
+ second.instance = InstanceId::new("second").unwrap();
826
+ second.generation = Generation(2);
827
+ let mut role = fixture.role.clone();
828
+ role.model = "second-model".into();
829
+ write_role(&fixture.binding, &second, &role).unwrap();
830
+ assert_eq!(read_role(&fixture.binding, &fixture.identity).unwrap().model, "first-model");
831
+ assert_eq!(read_role(&fixture.binding, &second).unwrap().model, "second-model");
832
+ assert!(write_role(&fixture.binding, &fixture.identity, &role).is_err());
833
+ assert_eq!(read_role(&fixture.binding, &fixture.identity).unwrap().model, "first-model");
834
+ }
835
+
836
+ #[test]
837
+ fn legacy_role_fallback_is_only_for_missing_generation_one_not_symlinks() {
838
+ let fixture = RoleFixture::new();
839
+ let old = root_for(&fixture.binding.workspace.path, &fixture.binding.scope).join("role-worker.json");
840
+ std::fs::write(&old, serde_json::to_vec(&fixture.role).unwrap()).unwrap();
841
+ assert_eq!(read_role(&fixture.binding, &fixture.identity).unwrap().model, "first-model");
842
+ let mut later = fixture.identity.clone();
843
+ later.generation = Generation(2);
844
+ assert!(read_role(&fixture.binding, &later).is_err());
845
+ std::os::unix::fs::symlink(fixture.root.join("missing"), role_path(&fixture.binding, &fixture.identity)).unwrap();
846
+ assert!(matches!(read_role(&fixture.binding, &fixture.identity), Err(BackendError::Metadata)));
847
+ }
848
+
849
+ #[test]
850
+ fn mismatched_role_owner_cannot_write_a_snapshot() {
851
+ let fixture = RoleFixture::new();
852
+ let mut identity = fixture.identity.clone();
853
+ identity.seat = SeatId::new("other").unwrap();
854
+ assert!(matches!(write_role(&fixture.binding, &identity, &fixture.role), Err(BackendError::Metadata)));
855
+ assert!(!role_path(&fixture.binding, &identity).exists());
856
+ }
857
+
605
858
  #[test]
606
859
  fn shutdown_error_exposes_the_durable_operation_failure_not_metadata() {
607
860
  let failure = "native instance not alive or replaced: Unknown; stage=check-bound; expected_pid=44123; process_state=Some(Replaced)";
@@ -618,6 +871,65 @@ mod tests {
618
871
  assert!(!shown.contains("metadata is invalid"));
619
872
  }
620
873
 
874
+ #[test]
875
+ fn cursor_metadata_never_mints_runtime_policy_evidence() {
876
+ let provider = ProviderId::new("cursor").unwrap();
877
+ let mut native = NativeIdentity {
878
+ version: "2026.09.29-a1b2c3".into(),
879
+ harness: "cursor-launcher".into(),
880
+ ui: "metadata".into(),
881
+ platform: Platform::MacOs,
882
+ executable_sha256: digest(b"launcher only"),
883
+ };
884
+ let candidate = digest(b"candidate");
885
+ assert!(policy_evidence(&provider, &native, candidate).unwrap().is_empty());
886
+ native.harness = "cursor-agent".into();
887
+ native.ui = "tui".into();
888
+ // Admission supplies a separately image-fenced runtime identity. The
889
+ // recipe still cannot be borrowed from metadata or another provider.
890
+ assert!(!policy_evidence(&provider, &native, candidate).unwrap().is_empty());
891
+ // Even a Kiro-looking image cannot reuse its evidence under Cursor.
892
+ native.harness = "v2".into();
893
+ native.version = "2.29.0".into();
894
+ assert!(policy_evidence(&provider, &native, candidate).unwrap().is_empty());
895
+ }
896
+
897
+ #[test]
898
+ fn cursor_request_refuses_metadata_launch_and_wrong_provider_catalog() {
899
+ let provider = ProviderId::new("cursor").unwrap();
900
+ let scope = ScopeId::new("controlled").unwrap();
901
+ let seat = SeatId::new("worker").unwrap();
902
+ let binding = Binding {
903
+ version: 1,
904
+ workspace: CwdIdentity { path: "/controlled".into(), identity: digest(b"cwd") },
905
+ team: "demo".into(), scope: scope.clone(), endpoint: "/controlled/socket".into(),
906
+ candidate: "/controlled/candidate".into(), candidate_sha256: digest(b"candidate"),
907
+ tmux: "/controlled/tmux".into(), transport_root: "/controlled/runtime".into(),
908
+ };
909
+ let role = RoleConfig {
910
+ id: seat.clone(), provider: provider.clone(), source: "/controlled/role.md".into(),
911
+ role: "worker".into(), model: "gpt-5.6-luna-xhigh".into(), effort: None,
912
+ bypass: true, paused: false, body: "role".into(),
913
+ prompt: super::super::prompt::Prompt { text: "prompt".into(), sha256: digest(b"prompt"), sources: vec![] },
914
+ };
915
+ let identity = InstanceIdentity { scope, seat, instance: InstanceId::new("preflight").unwrap(), generation: Generation(1) };
916
+ let native = NativeIdentity {
917
+ version: "2026.09.29-a1b2c3".into(), harness: "cursor-launcher".into(),
918
+ ui: "metadata".into(), platform: Platform::MacOs, executable_sha256: digest(b"launcher"),
919
+ };
920
+ let mut discovery = Discovery {
921
+ runtime: None,
922
+ engine: "/controlled/cursor-agent".into(), native: native.clone(),
923
+ catalog: CatalogObservation { provider, native,
924
+ schema: team_agent_contract::providers::cursor::native::CATALOG_SOURCE.schema.into(), models: vec![] },
925
+ };
926
+ assert!(matches!(request(&binding, &role, identity.clone(), &discovery),
927
+ Err(BackendError::Contract(ContractError::ProfileUnavailable))));
928
+ discovery.catalog.provider = ProviderId::new("kiro").unwrap();
929
+ assert!(matches!(request(&binding, &role, identity, &discovery),
930
+ Err(BackendError::Contract(ContractError::Mismatch("role native catalog")))));
931
+ }
932
+
621
933
  #[test]
622
934
  fn policy_bindings_track_runtime_native_identity_not_a_release_receipt() {
623
935
  let mut native = NativeIdentity {
@@ -628,11 +940,12 @@ mod tests {
628
940
  executable_sha256: digest(b"old image"),
629
941
  };
630
942
  let candidate = digest(b"candidate");
631
- let old = policy_evidence(&native, candidate);
943
+ let provider = ProviderId::new("kiro").unwrap();
944
+ let old = policy_evidence(&provider, &native, candidate).unwrap();
632
945
  assert!(!old.is_empty());
633
946
  native.version = "2.29.0".into();
634
947
  native.executable_sha256 = digest(b"updated image");
635
- let new = policy_evidence(&native, candidate);
948
+ let new = policy_evidence(&provider, &native, candidate).unwrap();
636
949
  assert_eq!(new.len(), old.len());
637
950
  for (old, new) in old.iter().zip(&new) {
638
951
  assert_eq!(new.native, native);
@@ -641,6 +954,6 @@ mod tests {
641
954
  assert_ne!(new.evidence_sha256, old.evidence_sha256);
642
955
  }
643
956
  native.harness = "v3".into();
644
- assert!(policy_evidence(&native, candidate).is_empty());
957
+ assert!(policy_evidence(&provider, &native, candidate).unwrap().is_empty());
645
958
  }
646
959
  }