@team-agent/installer 0.5.113 → 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 +252 -84
- package/Cargo.toml +3 -1
- package/crates/team-agent/Cargo.toml +3 -0
- package/crates/team-agent/src/cli/adapters.rs +28 -15
- package/crates/team-agent/src/cli/diagnose.rs +36 -13
- package/crates/team-agent/src/cli/emit.rs +57 -22
- package/crates/team-agent/src/cli/leader.rs +16 -0
- package/crates/team-agent/src/cli/leader_prompt.rs +261 -0
- package/crates/team-agent/src/cli/mod.rs +157 -8
- package/crates/team-agent/src/cli/models.rs +119 -18
- package/crates/team-agent/src/cli/spec.rs +7 -1
- package/crates/team-agent/src/cli/status_port/brief.rs +268 -101
- package/crates/team-agent/src/cli/tests/base.rs +88 -0
- package/crates/team-agent/src/cli/tests/status_send.rs +2 -0
- package/crates/team-agent/src/compiler.rs +66 -18
- package/crates/team-agent/src/contract_runtime/backend.rs +959 -0
- package/crates/team-agent/src/contract_runtime/commands.rs +245 -0
- package/crates/team-agent/src/contract_runtime/config.rs +340 -0
- package/crates/team-agent/src/contract_runtime/config_tests.rs +56 -0
- package/crates/team-agent/src/contract_runtime/discovery.rs +383 -0
- package/crates/team-agent/src/contract_runtime/forward.rs +229 -0
- package/crates/team-agent/src/contract_runtime/framework.rs +294 -0
- package/crates/team-agent/src/contract_runtime/mcp.rs +396 -0
- package/crates/team-agent/src/contract_runtime/mod.rs +16 -0
- package/crates/team-agent/src/contract_runtime/prompt.rs +216 -0
- package/crates/team-agent/src/contract_runtime/pump.rs +269 -0
- package/crates/team-agent/src/contract_runtime/registry.rs +197 -0
- package/crates/team-agent/src/contract_runtime/workspace.rs +160 -0
- package/crates/team-agent/src/coordinator/tick.rs +26 -8
- package/crates/team-agent/src/leader/mod.rs +1 -0
- package/crates/team-agent/src/leader/prompt.rs +906 -0
- package/crates/team-agent/src/leader/start.rs +353 -21
- package/crates/team-agent/src/leader/tests/mod.rs +2 -0
- package/crates/team-agent/src/leader/tests/prompt_contract.rs +268 -0
- package/crates/team-agent/src/leader/types.rs +6 -0
- package/crates/team-agent/src/lib.rs +4 -0
- package/crates/team-agent/src/lifecycle/launch/quick_start.rs +571 -389
- package/crates/team-agent/src/lifecycle/launch/spawn.rs +38 -9
- package/crates/team-agent/src/lifecycle/launch/state_projection.rs +15 -0
- package/crates/team-agent/src/lifecycle/launch.rs +45 -28
- 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/crates/team-agent/src/lifecycle/types.rs +4 -1
- package/crates/team-agent/src/lifecycle/worker_command_context.rs +37 -27
- package/crates/team-agent/src/main.rs +6 -0
- package/crates/team-agent/src/messaging/delivery.rs +50 -1
- package/crates/team-agent/src/messaging/send.rs +10 -0
- package/crates/team-agent/src/messaging/tests/leader_inject_acceptance.rs +131 -0
- package/crates/team-agent/src/model/spec.rs +24 -12
- package/crates/team-agent/src/state/persist.rs +73 -0
- package/package.json +4 -4
|
@@ -0,0 +1,959 @@
|
|
|
1
|
+
//! Framework scope and owned native startup. No legacy Provider enum or adapter
|
|
2
|
+
//! is used here. Native admission is resolved before creating the new store.
|
|
3
|
+
use std::collections::BTreeMap;
|
|
4
|
+
use std::io::{Read, Write};
|
|
5
|
+
use std::path::{Path, PathBuf};
|
|
6
|
+
use std::time::Duration;
|
|
7
|
+
|
|
8
|
+
use serde::{Deserialize, Serialize};
|
|
9
|
+
use team_agent_contract::contract::{
|
|
10
|
+
descriptor::AuthMode,
|
|
11
|
+
hooks::*,
|
|
12
|
+
plan::*,
|
|
13
|
+
session::{CwdIdentity, ResumeBinding},
|
|
14
|
+
types::*,
|
|
15
|
+
};
|
|
16
|
+
use team_agent_contract::host::{
|
|
17
|
+
clock::RealClock,
|
|
18
|
+
command::*,
|
|
19
|
+
digest, digest_hex,
|
|
20
|
+
materialize::ScopedMaterializer,
|
|
21
|
+
process::{fingerprint_file, resolve_cwd},
|
|
22
|
+
tmux::HostLimits,
|
|
23
|
+
};
|
|
24
|
+
use team_agent_contract::orchestration::{lifecycle::*, physical::*, store::*, Error};
|
|
25
|
+
use thiserror::Error as ThisError;
|
|
26
|
+
|
|
27
|
+
use super::{
|
|
28
|
+
config::{MemberRoute, RoleConfig, RuntimeFamily},
|
|
29
|
+
discovery::{self, Discovery, DiscoveryError},
|
|
30
|
+
forward::FrameworkContext,
|
|
31
|
+
registry::{self, NativeAdapter},
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
#[derive(Debug, ThisError)]
|
|
35
|
+
pub enum BackendError {
|
|
36
|
+
#[error("native configuration: {0}")]
|
|
37
|
+
Configuration(#[from] super::config::ConfigError),
|
|
38
|
+
#[error("native discovery: {0}")]
|
|
39
|
+
Discovery(#[from] DiscoveryError),
|
|
40
|
+
#[error("contract runtime: {0}")]
|
|
41
|
+
Runtime(#[from] Error),
|
|
42
|
+
#[error("native contract: {0:?}")]
|
|
43
|
+
Contract(#[from] ContractError),
|
|
44
|
+
#[error("native host: {0}")]
|
|
45
|
+
Host(#[from] team_agent_contract::host::HostError),
|
|
46
|
+
#[error("native runtime I/O: {0}")]
|
|
47
|
+
Io(#[from] std::io::Error),
|
|
48
|
+
#[error("native runtime metadata is invalid")]
|
|
49
|
+
Metadata,
|
|
50
|
+
#[error("native shutdown operation={operation:?} phase={phase:?} outcome={outcome:?} failure={failure:?}")]
|
|
51
|
+
Shutdown {
|
|
52
|
+
operation: OperationId,
|
|
53
|
+
phase: Phase,
|
|
54
|
+
outcome: Outcome,
|
|
55
|
+
failure: Option<String>,
|
|
56
|
+
},
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
#[derive(Clone, Serialize, Deserialize)]
|
|
60
|
+
#[serde(deny_unknown_fields)]
|
|
61
|
+
pub struct Binding {
|
|
62
|
+
pub version: u32,
|
|
63
|
+
pub workspace: CwdIdentity,
|
|
64
|
+
pub team: String,
|
|
65
|
+
pub scope: ScopeId,
|
|
66
|
+
pub endpoint: String,
|
|
67
|
+
pub candidate: PathBuf,
|
|
68
|
+
pub candidate_sha256: Digest,
|
|
69
|
+
pub tmux: PathBuf,
|
|
70
|
+
pub transport_root: PathBuf,
|
|
71
|
+
}
|
|
72
|
+
impl Binding {
|
|
73
|
+
pub fn framework(&self) -> FrameworkContext {
|
|
74
|
+
FrameworkContext {
|
|
75
|
+
workspace: self.workspace.clone(),
|
|
76
|
+
team: self.team.clone(),
|
|
77
|
+
native_scope: self.scope.clone(),
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
pub struct Backend {
|
|
83
|
+
pub store: ContractStore,
|
|
84
|
+
pub binding: Binding,
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
pub fn scope_for(workspace: &CwdIdentity, team: &str) -> Result<ScopeId, BackendError> {
|
|
88
|
+
if team.trim().is_empty() || team.chars().any(char::is_control) {
|
|
89
|
+
return Err(BackendError::Metadata);
|
|
90
|
+
}
|
|
91
|
+
let encoded = serde_json::to_vec(&(workspace, team)).map_err(|_| BackendError::Metadata)?;
|
|
92
|
+
Ok(ScopeId::new(format!(
|
|
93
|
+
"contract-{}",
|
|
94
|
+
&digest_hex(digest(&encoded))[..32]
|
|
95
|
+
))?)
|
|
96
|
+
}
|
|
97
|
+
pub fn root_for(workspace: &Path, scope: &ScopeId) -> PathBuf {
|
|
98
|
+
crate::model::paths::runtime_dir(workspace)
|
|
99
|
+
.join("contract")
|
|
100
|
+
.join(scope.as_str())
|
|
101
|
+
}
|
|
102
|
+
fn endpoint(scope: &ScopeId) -> String {
|
|
103
|
+
format!("framework:{}", scope.as_str())
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
impl Backend {
|
|
107
|
+
pub fn open(workspace: &Path, team: &str) -> Result<Self, BackendError> {
|
|
108
|
+
Self::open_mode(workspace, team, false)
|
|
109
|
+
}
|
|
110
|
+
pub fn open_readonly(workspace: &Path, team: &str) -> Result<Self, BackendError> {
|
|
111
|
+
Self::open_mode(workspace, team, true)
|
|
112
|
+
}
|
|
113
|
+
fn open_mode(workspace: &Path, team: &str, readonly: bool) -> Result<Self, BackendError> {
|
|
114
|
+
let workspace = resolve_cwd(workspace)?;
|
|
115
|
+
let scope = scope_for(&workspace, team)?;
|
|
116
|
+
let root = root_for(&workspace.path, &scope);
|
|
117
|
+
let file_path = root.join("framework-binding.json");
|
|
118
|
+
let metadata = std::fs::symlink_metadata(&file_path)?;
|
|
119
|
+
if metadata.file_type().is_symlink() || !metadata.is_file() {
|
|
120
|
+
return Err(BackendError::Metadata);
|
|
121
|
+
}
|
|
122
|
+
let mut bytes = Vec::new();
|
|
123
|
+
std::fs::File::open(&file_path)?
|
|
124
|
+
.take(65537)
|
|
125
|
+
.read_to_end(&mut bytes)?;
|
|
126
|
+
if bytes.len() > 65536 {
|
|
127
|
+
return Err(BackendError::Metadata);
|
|
128
|
+
}
|
|
129
|
+
let binding: Binding =
|
|
130
|
+
serde_json::from_slice(&bytes).map_err(|_| BackendError::Metadata)?;
|
|
131
|
+
if binding.version != 1
|
|
132
|
+
|| binding.workspace != workspace
|
|
133
|
+
|| binding.team != team
|
|
134
|
+
|| binding.scope != scope
|
|
135
|
+
|| binding.endpoint != endpoint(&scope)
|
|
136
|
+
{
|
|
137
|
+
return Err(BackendError::Metadata);
|
|
138
|
+
}
|
|
139
|
+
let store = if readonly {
|
|
140
|
+
ContractStore::open_readonly(&binding.transport_root, scope, &binding.endpoint)?
|
|
141
|
+
} else {
|
|
142
|
+
ContractStore::open(&binding.transport_root, scope, &binding.endpoint)?
|
|
143
|
+
};
|
|
144
|
+
Ok(Self { store, binding })
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/// Called only after the caller's pure/native-discovery preflight. Exclusive
|
|
148
|
+
/// roots are not adopted; partial initialization stays visible on failure.
|
|
149
|
+
fn create(
|
|
150
|
+
workspace: CwdIdentity,
|
|
151
|
+
team: &str,
|
|
152
|
+
candidate: PathBuf,
|
|
153
|
+
tmux: PathBuf,
|
|
154
|
+
) -> Result<Self, BackendError> {
|
|
155
|
+
use std::os::unix::fs::{DirBuilderExt, OpenOptionsExt};
|
|
156
|
+
let scope = scope_for(&workspace, team)?;
|
|
157
|
+
let root = root_for(&workspace.path, &scope);
|
|
158
|
+
let parent = root.parent().ok_or(BackendError::Metadata)?;
|
|
159
|
+
let base = parent.parent().ok_or(BackendError::Metadata)?;
|
|
160
|
+
if base.canonicalize()? != base {
|
|
161
|
+
return Err(BackendError::Metadata);
|
|
162
|
+
}
|
|
163
|
+
match std::fs::symlink_metadata(parent) {
|
|
164
|
+
Ok(metadata) if metadata.is_dir() && !metadata.file_type().is_symlink() => {}
|
|
165
|
+
Ok(_) => return Err(BackendError::Metadata),
|
|
166
|
+
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
|
|
167
|
+
std::fs::DirBuilder::new().mode(0o700).create(parent)?;
|
|
168
|
+
}
|
|
169
|
+
Err(error) => return Err(error.into()),
|
|
170
|
+
}
|
|
171
|
+
let candidate_sha256 =
|
|
172
|
+
fingerprint_file(&candidate, 1024 * 1024 * 1024, Duration::from_secs(10))?;
|
|
173
|
+
std::fs::DirBuilder::new().mode(0o700).create(&root)?;
|
|
174
|
+
// OS entropy gives this transport parent a fresh incarnation even when
|
|
175
|
+
// a logical team/name is reused. This is not a native session identity.
|
|
176
|
+
let mut nonce = [0u8; 32];
|
|
177
|
+
std::fs::File::open("/dev/urandom")?.read_exact(&mut nonce)?;
|
|
178
|
+
let transport_root = Path::new("/tmp")
|
|
179
|
+
.canonicalize()?
|
|
180
|
+
.join(format!("ta-{}", &digest_hex(digest(&nonce))[..24]));
|
|
181
|
+
// K3 and K2 share the same owned short root. Keeping only the pointer
|
|
182
|
+
// under the project avoids macOS sockaddr_un limits without weakening
|
|
183
|
+
// lifecycle's store-root fence or adopting an existing transport.
|
|
184
|
+
let store = ContractStore::create(&transport_root, scope.clone(), &endpoint(&scope))?;
|
|
185
|
+
let binding = Binding {
|
|
186
|
+
version: 1,
|
|
187
|
+
workspace,
|
|
188
|
+
team: team.into(),
|
|
189
|
+
scope: scope.clone(),
|
|
190
|
+
endpoint: endpoint(&scope),
|
|
191
|
+
candidate,
|
|
192
|
+
candidate_sha256,
|
|
193
|
+
tmux,
|
|
194
|
+
transport_root,
|
|
195
|
+
};
|
|
196
|
+
let bytes = serde_json::to_vec(&binding).map_err(|_| BackendError::Metadata)?;
|
|
197
|
+
let mut file = std::fs::OpenOptions::new()
|
|
198
|
+
.write(true)
|
|
199
|
+
.create_new(true)
|
|
200
|
+
.mode(0o600)
|
|
201
|
+
.open(root.join("framework-binding.json"))?;
|
|
202
|
+
file.write_all(&bytes)?;
|
|
203
|
+
file.sync_all()?;
|
|
204
|
+
Ok(Self { store, binding })
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
pub(super) fn settings(binding: &Binding, provider: registry::NativeProvider) -> PhysicalSettings {
|
|
209
|
+
PhysicalSettings {
|
|
210
|
+
tmux: binding.tmux.clone(),
|
|
211
|
+
limits: HostLimits {
|
|
212
|
+
command_output: OutputLimits {
|
|
213
|
+
stdout: 256 * 1024,
|
|
214
|
+
stderr: 65536,
|
|
215
|
+
stdin: 2 * 1024 * 1024,
|
|
216
|
+
},
|
|
217
|
+
max_script_bytes: 128 * 1024,
|
|
218
|
+
max_capture_bytes: 256 * 1024,
|
|
219
|
+
max_payload_bytes: 1024 * 1024,
|
|
220
|
+
max_executable_bytes: 1024 * 1024 * 1024,
|
|
221
|
+
poll_interval: Duration::from_millis(100),
|
|
222
|
+
columns: 120,
|
|
223
|
+
rows: 40,
|
|
224
|
+
},
|
|
225
|
+
action_budget: provider.action_budget(),
|
|
226
|
+
freshness: Duration::from_secs(2),
|
|
227
|
+
journal_bytes: 2 * 1024 * 1024,
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
pub(super) fn adapter(
|
|
231
|
+
binding: &Binding,
|
|
232
|
+
identity: &InstanceIdentity,
|
|
233
|
+
provider: &ProviderId,
|
|
234
|
+
) -> Result<NativeAdapter, BackendError> {
|
|
235
|
+
Ok(registry::provider(provider.as_str())?.adapter(
|
|
236
|
+
binding.candidate.clone(),
|
|
237
|
+
vec![
|
|
238
|
+
"contract-mcp".into(),
|
|
239
|
+
"--workspace".into(),
|
|
240
|
+
binding
|
|
241
|
+
.workspace
|
|
242
|
+
.path
|
|
243
|
+
.to_str()
|
|
244
|
+
.ok_or(BackendError::Metadata)?
|
|
245
|
+
.into(),
|
|
246
|
+
"--team".into(),
|
|
247
|
+
binding.team.clone(),
|
|
248
|
+
"--seat".into(),
|
|
249
|
+
identity.seat.as_str().into(),
|
|
250
|
+
"--instance".into(),
|
|
251
|
+
identity.instance.as_str().into(),
|
|
252
|
+
"--generation".into(),
|
|
253
|
+
identity.generation.0.to_string(),
|
|
254
|
+
],
|
|
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
|
+
)?)
|
|
267
|
+
}
|
|
268
|
+
fn request(
|
|
269
|
+
binding: &Binding,
|
|
270
|
+
role: &RoleConfig,
|
|
271
|
+
identity: InstanceIdentity,
|
|
272
|
+
discovered: &Discovery,
|
|
273
|
+
) -> Result<LaunchRequest, BackendError> {
|
|
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")?;
|
|
283
|
+
let mut matches = profiles.iter().filter(|profile| {
|
|
284
|
+
profile.matches_native(&discovered.native)
|
|
285
|
+
&& profile.channel == Channel::Tmux
|
|
286
|
+
&& profile.operations.contains(&Operation::FirstBusiness)
|
|
287
|
+
});
|
|
288
|
+
let profile = matches.next().ok_or(ContractError::ProfileUnavailable)?;
|
|
289
|
+
if matches.next().is_some() {
|
|
290
|
+
return Err(ContractError::AmbiguousProfile.into());
|
|
291
|
+
}
|
|
292
|
+
profile.policy.require("native submit policy")?;
|
|
293
|
+
Ok(LaunchRequest {
|
|
294
|
+
provider: role.provider.as_str().into(),
|
|
295
|
+
operation: Operation::Fresh,
|
|
296
|
+
mode: LaunchMode::FullWorker,
|
|
297
|
+
auth: AuthMode::NativeSubscription,
|
|
298
|
+
model: Some(role.model.clone()),
|
|
299
|
+
role_effort: role.effort.clone(),
|
|
300
|
+
team_effort: None,
|
|
301
|
+
bypass: role.bypass,
|
|
302
|
+
prompt: Some(role.prompt.text.clone()),
|
|
303
|
+
identity,
|
|
304
|
+
native: discovered.native.clone(),
|
|
305
|
+
paths: LaunchPaths {
|
|
306
|
+
executable: discovered.engine.clone(),
|
|
307
|
+
candidate: binding.candidate.clone(),
|
|
308
|
+
cwd: binding.workspace.clone(),
|
|
309
|
+
runtime_root: binding.transport_root.clone(),
|
|
310
|
+
},
|
|
311
|
+
channel: Channel::Tmux,
|
|
312
|
+
input_profile: Some(profile.id.into()),
|
|
313
|
+
evidence_kind: EvidenceKind::Native,
|
|
314
|
+
preassigned_session: None,
|
|
315
|
+
resume: None,
|
|
316
|
+
fork: None,
|
|
317
|
+
})
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
/// Team-wide native discovery/admission before the shared launcher creates any
|
|
321
|
+
/// seats (including legacy peers). Bounded read-only native commands run; no
|
|
322
|
+
/// runtime store, worker config, private socket, or worker process is created.
|
|
323
|
+
pub fn preflight(team: &super::config::TeamConfig) -> Result<(), BackendError> {
|
|
324
|
+
if team.roles.iter().all(|role| role.paused) {
|
|
325
|
+
return Ok(());
|
|
326
|
+
}
|
|
327
|
+
let mut discoveries = BTreeMap::new();
|
|
328
|
+
let candidate = std::env::current_exe()?.canonicalize()?;
|
|
329
|
+
let candidate_sha256 =
|
|
330
|
+
fingerprint_file(&candidate, 1024 * 1024 * 1024, Duration::from_secs(10))?;
|
|
331
|
+
let binding = Binding {
|
|
332
|
+
version: 1,
|
|
333
|
+
workspace: team.workspace.clone(),
|
|
334
|
+
team: team.selector.clone(),
|
|
335
|
+
scope: team.scope.clone(),
|
|
336
|
+
endpoint: endpoint(&team.scope),
|
|
337
|
+
candidate,
|
|
338
|
+
candidate_sha256,
|
|
339
|
+
tmux: discovery::executable("tmux")?,
|
|
340
|
+
transport_root: "/tmp/preflight-no-write".into(),
|
|
341
|
+
};
|
|
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)?;
|
|
351
|
+
let identity = InstanceIdentity {
|
|
352
|
+
scope: team.scope.clone(),
|
|
353
|
+
seat: role.id.clone(),
|
|
354
|
+
instance: InstanceId::new("preflight")?,
|
|
355
|
+
generation: Generation(1),
|
|
356
|
+
};
|
|
357
|
+
let adapter = launch_adapter(&binding, &identity, &role.provider, discovered)?;
|
|
358
|
+
let hooks = adapter.hooks();
|
|
359
|
+
let request = request(&binding, role, identity, discovered)?;
|
|
360
|
+
let resolved = resolve_launch_preflight(
|
|
361
|
+
selected.descriptor(),
|
|
362
|
+
&hooks,
|
|
363
|
+
&request,
|
|
364
|
+
Some(&discovered.catalog),
|
|
365
|
+
)?;
|
|
366
|
+
validate_launch_plan(selected.descriptor(), &resolved, &adapter.plan(&resolved)?)?;
|
|
367
|
+
}
|
|
368
|
+
Ok(())
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
/// Fresh startup is distinct from resume. An existing generation is never
|
|
372
|
+
/// silently replaced with a new native session.
|
|
373
|
+
pub fn start(
|
|
374
|
+
workspace: &Path,
|
|
375
|
+
team: &str,
|
|
376
|
+
role: &RoleConfig,
|
|
377
|
+
members: &[MemberRoute],
|
|
378
|
+
) -> Result<(Binding, OperationRecord), BackendError> {
|
|
379
|
+
let selected = registry::provider(role.provider.as_str())?;
|
|
380
|
+
let descriptor = selected.descriptor();
|
|
381
|
+
let discovered = discovery::discover_runtime(&role.provider, workspace)?;
|
|
382
|
+
let cwd = resolve_cwd(workspace)?;
|
|
383
|
+
let scope = scope_for(&cwd, team)?;
|
|
384
|
+
let candidate = std::env::current_exe()?.canonicalize()?;
|
|
385
|
+
let tmux = discovery::executable("tmux")?;
|
|
386
|
+
let candidate_sha256 =
|
|
387
|
+
fingerprint_file(&candidate, 1024 * 1024 * 1024, Duration::from_secs(10))?;
|
|
388
|
+
let provisional = Binding {
|
|
389
|
+
version: 1,
|
|
390
|
+
workspace: cwd.clone(),
|
|
391
|
+
team: team.into(),
|
|
392
|
+
scope: scope.clone(),
|
|
393
|
+
endpoint: endpoint(&scope),
|
|
394
|
+
candidate: candidate.clone(),
|
|
395
|
+
candidate_sha256,
|
|
396
|
+
tmux: tmux.clone(),
|
|
397
|
+
transport_root: PathBuf::from("/tmp/preflight-no-write"),
|
|
398
|
+
};
|
|
399
|
+
let provisional_identity = InstanceIdentity {
|
|
400
|
+
scope: scope.clone(),
|
|
401
|
+
seat: role.id.clone(),
|
|
402
|
+
instance: InstanceId::new("preflight")?,
|
|
403
|
+
generation: Generation(1),
|
|
404
|
+
};
|
|
405
|
+
let probe_adapter = launch_adapter(&provisional, &provisional_identity, &role.provider, &discovered)?;
|
|
406
|
+
let probe_hooks = probe_adapter.hooks();
|
|
407
|
+
let preflight = request(&provisional, role, provisional_identity, &discovered)?;
|
|
408
|
+
let preflight = resolve_launch_preflight(
|
|
409
|
+
descriptor,
|
|
410
|
+
&probe_hooks,
|
|
411
|
+
&preflight,
|
|
412
|
+
Some(&discovered.catalog),
|
|
413
|
+
)?;
|
|
414
|
+
let preflight_plan = probe_adapter.plan(&preflight)?;
|
|
415
|
+
validate_launch_plan(descriptor, &preflight, &preflight_plan)?;
|
|
416
|
+
let server_key = selected.server_key()?;
|
|
417
|
+
let mut backend = if root_for(&cwd.path, &scope).exists() {
|
|
418
|
+
Backend::open(&cwd.path, team)?
|
|
419
|
+
} else {
|
|
420
|
+
Backend::create(cwd, team, candidate, tmux)?
|
|
421
|
+
};
|
|
422
|
+
if backend.binding.candidate != provisional.candidate
|
|
423
|
+
|| backend.binding.candidate_sha256 != candidate_sha256
|
|
424
|
+
{
|
|
425
|
+
return Err(Error::Fence.into());
|
|
426
|
+
}
|
|
427
|
+
if backend.store.seat(&role.id)?.is_some() {
|
|
428
|
+
return Err(Error::Conflict.into());
|
|
429
|
+
}
|
|
430
|
+
let route = backend.binding.framework().route()?;
|
|
431
|
+
let mut peers: Vec<_> = members
|
|
432
|
+
.iter()
|
|
433
|
+
.filter(|member| member.runtime == RuntimeFamily::Legacy)
|
|
434
|
+
.map(
|
|
435
|
+
|member| team_agent_contract::orchestration::forward::FrameworkPeer {
|
|
436
|
+
recipient: member.id.clone(),
|
|
437
|
+
route: route.clone(),
|
|
438
|
+
provider: member.provider.clone(),
|
|
439
|
+
},
|
|
440
|
+
)
|
|
441
|
+
.collect();
|
|
442
|
+
peers.push(team_agent_contract::orchestration::forward::FrameworkPeer {
|
|
443
|
+
recipient: "leader".into(),
|
|
444
|
+
route: route.clone(),
|
|
445
|
+
provider: "framework".into(),
|
|
446
|
+
});
|
|
447
|
+
backend.store.set_framework_routes(&peers, Some(&route))?;
|
|
448
|
+
let identity = backend.store.propose_identity(&role.id)?;
|
|
449
|
+
write_role(&backend.binding, &identity, role)?;
|
|
450
|
+
let adapter = launch_adapter(&backend.binding, &identity, &role.provider, &discovered)?;
|
|
451
|
+
let hooks = adapter.hooks();
|
|
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
|
+
}
|
|
466
|
+
let resolved = resolve_launch(
|
|
467
|
+
descriptor,
|
|
468
|
+
&hooks,
|
|
469
|
+
&request,
|
|
470
|
+
Some(&discovered.catalog),
|
|
471
|
+
)?;
|
|
472
|
+
let plan = adapter.plan(&resolved)?;
|
|
473
|
+
let operation = OperationId::new(format!("start-{}", request.identity.instance.as_str()))?;
|
|
474
|
+
let mut io = ScopedMaterializer::new(
|
|
475
|
+
descriptor,
|
|
476
|
+
&resolved,
|
|
477
|
+
&plan,
|
|
478
|
+
operation.clone(),
|
|
479
|
+
RealCommandRunner::default(),
|
|
480
|
+
)?;
|
|
481
|
+
let clock = RealClock::new();
|
|
482
|
+
let mut physical = PhysicalRuntime::new(
|
|
483
|
+
Registration {
|
|
484
|
+
descriptor,
|
|
485
|
+
hooks: &hooks,
|
|
486
|
+
controls: selected.controls(),
|
|
487
|
+
},
|
|
488
|
+
settings(&backend.binding, selected),
|
|
489
|
+
operation.clone(),
|
|
490
|
+
&clock,
|
|
491
|
+
)?;
|
|
492
|
+
physical.evidence = policy_evidence(&role.provider, &request.native, candidate_sha256)?;
|
|
493
|
+
let record = Lifecycle {
|
|
494
|
+
store: &mut backend.store,
|
|
495
|
+
host: &mut physical,
|
|
496
|
+
io: &mut io,
|
|
497
|
+
}
|
|
498
|
+
.startup(
|
|
499
|
+
&Adapter {
|
|
500
|
+
descriptor,
|
|
501
|
+
hooks: &hooks,
|
|
502
|
+
catalog: Some(&discovered.catalog),
|
|
503
|
+
},
|
|
504
|
+
&request,
|
|
505
|
+
Routing {
|
|
506
|
+
pane: "unpublished".into(),
|
|
507
|
+
binding_key: format!("binding-{}", request.identity.instance.as_str()),
|
|
508
|
+
server_key: server_key.into(),
|
|
509
|
+
},
|
|
510
|
+
operation,
|
|
511
|
+
)?;
|
|
512
|
+
Ok((backend.binding, record))
|
|
513
|
+
}
|
|
514
|
+
|
|
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
|
+
}
|
|
669
|
+
let metadata = std::fs::symlink_metadata(&path)?;
|
|
670
|
+
if metadata.file_type().is_symlink() || !metadata.is_file() {
|
|
671
|
+
return Err(BackendError::Metadata);
|
|
672
|
+
}
|
|
673
|
+
let mut bytes = Vec::new();
|
|
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)?;
|
|
682
|
+
if bytes.len() > 4 * 1024 * 1024 {
|
|
683
|
+
return Err(BackendError::Metadata);
|
|
684
|
+
}
|
|
685
|
+
let role: RoleConfig = serde_json::from_slice(&bytes).map_err(|_| BackendError::Metadata)?;
|
|
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())
|
|
689
|
+
|| digest(role.prompt.text.as_bytes()) != role.prompt.sha256
|
|
690
|
+
{
|
|
691
|
+
return Err(BackendError::Metadata);
|
|
692
|
+
}
|
|
693
|
+
Ok(role)
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
pub(super) fn policy_evidence(
|
|
697
|
+
provider: &ProviderId,
|
|
698
|
+
native: &NativeIdentity,
|
|
699
|
+
candidate_sha256: Digest,
|
|
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)
|
|
727
|
+
}
|
|
728
|
+
|
|
729
|
+
struct NoNewIo;
|
|
730
|
+
impl OwnedIo for NoNewIo {
|
|
731
|
+
fn create_exclusive(
|
|
732
|
+
&mut self,
|
|
733
|
+
_: &OwnedResourceRequest,
|
|
734
|
+
) -> Result<OwnedResourceReceipt, PartialFailure> {
|
|
735
|
+
Err(PartialFailure {
|
|
736
|
+
error: ContractError::Invalid("teardown cannot materialize"),
|
|
737
|
+
receipt: MaterializeReceipt {
|
|
738
|
+
resources: Vec::new(),
|
|
739
|
+
},
|
|
740
|
+
})
|
|
741
|
+
}
|
|
742
|
+
fn read_bound_session(
|
|
743
|
+
&mut self,
|
|
744
|
+
_: &ResumeBinding,
|
|
745
|
+
_: ReadBounds,
|
|
746
|
+
) -> Result<ReadOutput, ReadFailure> {
|
|
747
|
+
Err(ReadFailure::Error(ContractError::Invalid(
|
|
748
|
+
"teardown cannot read session backing",
|
|
749
|
+
)))
|
|
750
|
+
}
|
|
751
|
+
fn validate_configuration(
|
|
752
|
+
&mut self,
|
|
753
|
+
_: &OwnedValidationRequest,
|
|
754
|
+
) -> Result<ReadOutput, ReadFailure> {
|
|
755
|
+
Err(ReadFailure::Error(ContractError::Invalid(
|
|
756
|
+
"teardown cannot run a validator",
|
|
757
|
+
)))
|
|
758
|
+
}
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
pub fn stop(workspace: &Path, team: &str, seat: &SeatId) -> Result<OperationRecord, BackendError> {
|
|
762
|
+
let mut backend = Backend::open(workspace, team)?;
|
|
763
|
+
let target = backend
|
|
764
|
+
.store
|
|
765
|
+
.seat(seat)?
|
|
766
|
+
.ok_or(Error::Invalid("unknown native seat"))?;
|
|
767
|
+
let nonce = backend.store.propose_identity(seat)?.instance;
|
|
768
|
+
let operation = OperationId::new(format!("stop-{}", nonce.as_str()))?;
|
|
769
|
+
let selected = registry::provider(target.provider.as_str())?;
|
|
770
|
+
let adapter = adapter(&backend.binding, &target.identity, &target.provider)?;
|
|
771
|
+
let hooks = adapter.hooks();
|
|
772
|
+
let clock = RealClock::new();
|
|
773
|
+
let mut physical = PhysicalRuntime::new(
|
|
774
|
+
Registration {
|
|
775
|
+
descriptor: selected.descriptor(),
|
|
776
|
+
hooks: &hooks,
|
|
777
|
+
controls: &[],
|
|
778
|
+
},
|
|
779
|
+
settings(&backend.binding, selected),
|
|
780
|
+
operation.clone(),
|
|
781
|
+
&clock,
|
|
782
|
+
)?;
|
|
783
|
+
Ok(Lifecycle {
|
|
784
|
+
store: &mut backend.store,
|
|
785
|
+
host: &mut physical,
|
|
786
|
+
io: &mut NoNewIo,
|
|
787
|
+
}
|
|
788
|
+
.teardown(&target.identity, operation)?)
|
|
789
|
+
}
|
|
790
|
+
|
|
791
|
+
#[cfg(test)]
|
|
792
|
+
mod tests {
|
|
793
|
+
use super::*;
|
|
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
|
+
|
|
858
|
+
#[test]
|
|
859
|
+
fn shutdown_error_exposes_the_durable_operation_failure_not_metadata() {
|
|
860
|
+
let failure = "native instance not alive or replaced: Unknown; stage=check-bound; expected_pid=44123; process_state=Some(Replaced)";
|
|
861
|
+
let error = BackendError::Shutdown {
|
|
862
|
+
operation: OperationId::new("stop-instance-test").unwrap(),
|
|
863
|
+
phase: Phase::F0Preflight,
|
|
864
|
+
outcome: Outcome::NeedsRecovery,
|
|
865
|
+
failure: Some(failure.into()),
|
|
866
|
+
};
|
|
867
|
+
let shown = error.to_string();
|
|
868
|
+
for expected in ["stop-instance-test", "F0Preflight", "NeedsRecovery", failure] {
|
|
869
|
+
assert!(shown.contains(expected), "{shown}");
|
|
870
|
+
}
|
|
871
|
+
assert!(!shown.contains("metadata is invalid"));
|
|
872
|
+
}
|
|
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
|
+
|
|
933
|
+
#[test]
|
|
934
|
+
fn policy_bindings_track_runtime_native_identity_not_a_release_receipt() {
|
|
935
|
+
let mut native = NativeIdentity {
|
|
936
|
+
version: "2.28.0".into(),
|
|
937
|
+
harness: "v2".into(),
|
|
938
|
+
ui: "tui".into(),
|
|
939
|
+
platform: Platform::MacOs,
|
|
940
|
+
executable_sha256: digest(b"old image"),
|
|
941
|
+
};
|
|
942
|
+
let candidate = digest(b"candidate");
|
|
943
|
+
let provider = ProviderId::new("kiro").unwrap();
|
|
944
|
+
let old = policy_evidence(&provider, &native, candidate).unwrap();
|
|
945
|
+
assert!(!old.is_empty());
|
|
946
|
+
native.version = "2.29.0".into();
|
|
947
|
+
native.executable_sha256 = digest(b"updated image");
|
|
948
|
+
let new = policy_evidence(&provider, &native, candidate).unwrap();
|
|
949
|
+
assert_eq!(new.len(), old.len());
|
|
950
|
+
for (old, new) in old.iter().zip(&new) {
|
|
951
|
+
assert_eq!(new.native, native);
|
|
952
|
+
assert_eq!(new.candidate_sha256, candidate);
|
|
953
|
+
assert_eq!(new.policy_sha256, old.policy_sha256);
|
|
954
|
+
assert_ne!(new.evidence_sha256, old.evidence_sha256);
|
|
955
|
+
}
|
|
956
|
+
native.harness = "v3".into();
|
|
957
|
+
assert!(policy_evidence(&provider, &native, candidate).unwrap().is_empty());
|
|
958
|
+
}
|
|
959
|
+
}
|