@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.
- 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 +12 -7
- package/crates/team-agent/src/cli/models.rs +52 -1
- package/crates/team-agent/src/cli/spec.rs +1 -1
- package/crates/team-agent/src/contract_runtime/backend.rs +408 -95
- package/crates/team-agent/src/contract_runtime/commands.rs +245 -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 +186 -40
- package/crates/team-agent/src/contract_runtime/framework.rs +38 -5
- package/crates/team-agent/src/contract_runtime/mod.rs +2 -0
- package/crates/team-agent/src/contract_runtime/pump.rs +95 -33
- package/crates/team-agent/src/contract_runtime/registry.rs +171 -4
- package/crates/team-agent/src/contract_runtime/workspace.rs +160 -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/rebuild.rs +23 -1
- package/crates/team-agent/src/lifecycle/restart/selection.rs +34 -0
- package/crates/team-agent/src/lifecycle/role_config.rs +17 -1
- 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:
|
|
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
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
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
|
-
|
|
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
|
|
262
|
-
|
|
263
|
-
.
|
|
264
|
-
|
|
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
|
-
|
|
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
|
|
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 =
|
|
357
|
+
let adapter = launch_adapter(&binding, &identity, &role.provider, discovered)?;
|
|
328
358
|
let hooks = adapter.hooks();
|
|
329
|
-
let request = request(&binding, role, identity,
|
|
330
|
-
let resolved =
|
|
331
|
-
|
|
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(
|
|
366
|
+
validate_launch_plan(selected.descriptor(), &resolved, &adapter.plan(&resolved)?)?;
|
|
337
367
|
}
|
|
338
368
|
Ok(())
|
|
339
369
|
}
|
|
340
370
|
|
|
341
|
-
///
|
|
342
|
-
///
|
|
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
|
|
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 =
|
|
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 =
|
|
377
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
484
|
+
descriptor,
|
|
451
485
|
hooks: &hooks,
|
|
452
|
-
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
|
|
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:
|
|
508
|
+
server_key: server_key.into(),
|
|
475
509
|
},
|
|
476
510
|
operation,
|
|
477
511
|
)?;
|
|
478
512
|
Ok((backend.binding, record))
|
|
479
513
|
}
|
|
480
514
|
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
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::
|
|
490
|
-
|
|
491
|
-
.
|
|
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
|
|
497
|
-
|| role.provider.as_str()
|
|
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
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
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
|
|
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:
|
|
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
|
|
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
|
}
|