@team-agent/installer 0.5.114 → 0.5.116
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/Cargo.lock +1 -1
- package/Cargo.toml +1 -1
- package/crates/team-agent/src/cli/emit.rs +2 -2
- package/crates/team-agent/src/cli/mod.rs +155 -19
- package/crates/team-agent/src/cli/models.rs +52 -1
- package/crates/team-agent/src/cli/shutdown_socket.rs +288 -0
- package/crates/team-agent/src/cli/spec.rs +1 -1
- package/crates/team-agent/src/cli/tests/shutdown_kill_plan.rs +11 -4
- package/crates/team-agent/src/contract_runtime/backend.rs +490 -97
- package/crates/team-agent/src/contract_runtime/commands.rs +326 -0
- package/crates/team-agent/src/contract_runtime/config.rs +39 -8
- package/crates/team-agent/src/contract_runtime/config_tests.rs +56 -0
- package/crates/team-agent/src/contract_runtime/discovery.rs +218 -40
- package/crates/team-agent/src/contract_runtime/framework.rs +38 -5
- package/crates/team-agent/src/contract_runtime/mcp.rs +3 -0
- package/crates/team-agent/src/contract_runtime/mod.rs +2 -0
- package/crates/team-agent/src/contract_runtime/pump.rs +97 -33
- package/crates/team-agent/src/contract_runtime/registry.rs +248 -4
- package/crates/team-agent/src/contract_runtime/workspace.rs +228 -0
- package/crates/team-agent/src/leader/start.rs +93 -5
- package/crates/team-agent/src/lifecycle/launch/quick_start.rs +99 -1
- package/crates/team-agent/src/lifecycle/launch/state_projection.rs +3 -5
- package/crates/team-agent/src/lifecycle/launch.rs +6 -0
- package/crates/team-agent/src/lifecycle/restart/agent.rs +4 -0
- package/crates/team-agent/src/lifecycle/restart/rebuild.rs +109 -58
- package/crates/team-agent/src/lifecycle/restart/selection.rs +63 -7
- package/crates/team-agent/src/lifecycle/role_config.rs +17 -1
- package/crates/team-agent/src/provider/session/capture.rs +24 -1
- package/crates/team-agent/src/state/persist.rs +72 -2
- package/crates/team-agent/src/state/projection.rs +29 -0
- package/crates/team-agent/src/state/repository/tests.rs +70 -0
- package/crates/team-agent/src/state/repository.rs +5 -1
- package/package.json +4 -4
|
@@ -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,
|
|
@@ -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
|
-
|
|
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
|
|
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 =
|
|
374
|
+
let adapter = launch_adapter(&binding, &identity, &role.provider, discovered)?;
|
|
328
375
|
let hooks = adapter.hooks();
|
|
329
|
-
let request =
|
|
330
|
-
let resolved =
|
|
331
|
-
|
|
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(
|
|
383
|
+
validate_launch_plan(selected.descriptor(), &resolved, &adapter.plan(&resolved)?)?;
|
|
337
384
|
}
|
|
338
385
|
Ok(())
|
|
339
386
|
}
|
|
340
387
|
|
|
341
|
-
///
|
|
342
|
-
///
|
|
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
|
|
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 =
|
|
422
|
+
let probe_adapter = launch_adapter(&provisional, &provisional_identity, &role.provider, &discovered)?;
|
|
374
423
|
let probe_hooks = probe_adapter.hooks();
|
|
375
|
-
let preflight =
|
|
376
|
-
let preflight =
|
|
377
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
504
|
+
descriptor,
|
|
451
505
|
hooks: &hooks,
|
|
452
|
-
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
|
|
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:
|
|
530
|
+
server_key: server_key.into(),
|
|
475
531
|
},
|
|
476
532
|
operation,
|
|
477
533
|
)?;
|
|
478
534
|
Ok((backend.binding, record))
|
|
479
535
|
}
|
|
480
536
|
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
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::
|
|
490
|
-
|
|
491
|
-
.
|
|
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
|
|
497
|
-
|| role.provider.as_str()
|
|
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
|
-
|
|
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()
|
|
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
|
|
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:
|
|
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
|
|
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
|
}
|