pointlock_provider_devicerail/
provider.rs1use std::collections::BTreeSet;
14use std::time::Duration;
15
16use async_trait::async_trait;
17use devicerail_client::protocol::{ActionDefinition, DeviceId, DeviceInfo, DeviceSelectParams};
18use devicerail_client::{CallOptions, DeviceRailClient, SpawnConfig, methods};
19use pointlock_ir::ErrorClass;
20use pointlock_provider_kit::lockfile::{CapabilityAttestation, CapabilityLockfile};
21use pointlock_provider_kit::manifest::ProviderManifest;
22use pointlock_provider_kit::{
23 OpenSessionOptions, Provider, ProviderError, ProviderSession, RetryableSource,
24};
25
26use crate::budget::{DEFAULT_CALL_BUDGET_MS, bounded, envelope_options};
27use crate::convert::now_utc_iso;
28use crate::endpoint::{SpawnSpec, parse_spawn_endpoint};
29use crate::lock::{hello_params, lockfile_provider_identity, make_lockfile};
30use crate::manifest::{PROVIDER_NAME, devicerail_manifest};
31use crate::session::DeviceRailSession;
32
33#[derive(Debug, Clone)]
36pub struct DeviceRailProvider {
37 lockfile: CapabilityLockfile,
38}
39
40impl DeviceRailProvider {
41 pub fn new(lockfile: CapabilityLockfile) -> Result<Self, ProviderError> {
44 if lockfile.provider.name != PROVIDER_NAME {
45 return Err(ProviderError::new(
46 ErrorClass::CapabilityDrift,
47 format!(
48 "lockfile was produced by provider `{}`; this provider is `{PROVIDER_NAME}`",
49 lockfile.provider.name
50 ),
51 RetryableSource::Classifier,
52 ));
53 }
54 if !lockfile.digest_consistent() {
55 return Err(ProviderError::new(
56 ErrorClass::CapabilityDrift,
57 "lockfile digest is inconsistent with its content; re-run `pointlock lock`",
58 RetryableSource::Classifier,
59 ));
60 }
61 Ok(DeviceRailProvider { lockfile })
62 }
63
64 pub fn lockfile(&self) -> &CapabilityLockfile {
66 &self.lockfile
67 }
68
69 async fn open_inner(
70 &self,
71 client: &DeviceRailClient,
72 opts: &OpenSessionOptions,
73 ) -> Result<DeviceRailSession, ProviderError> {
74 let hello = client
75 .negotiated_hello()
76 .map_err(|error| crate::error_map::provider_error_from_client(error, "system.hello"))?;
77
78 let (device, actions) = connect_device(client, &opts.device_id).await?;
79
80 let live = make_lockfile(
85 self.lockfile.provider.clone(),
86 self.lockfile.attested_at.clone(),
87 &hello,
88 &device,
89 &actions,
90 )?;
91 if live.digest != opts.lockfile_digest {
92 return Err(ProviderError::new(
93 ErrorClass::CapabilityDrift,
94 format!(
95 "attestation mismatch: the live world's canonical digest {} does not match \
96 the expected lockfileDigest {}; {} — re-run `pointlock lock` and recompile",
97 live.digest,
98 opts.lockfile_digest,
99 describe_drift(&self.lockfile, &live)
100 ),
101 RetryableSource::Classifier,
102 ));
103 }
104
105 let session = bounded(
107 client.call::<methods::SessionStart>(methods::NoParams, CallOptions::default()),
108 )
109 .await
110 .map_err(|error| error.into_provider_error("session.start"))?;
111
112 let attestation = CapabilityAttestation::from_lockfile(&live, now_utc_iso());
113 Ok(DeviceRailSession::new(
114 client.clone(),
115 attestation,
116 session.id,
117 ))
118 }
119}
120
121#[async_trait]
122impl Provider for DeviceRailProvider {
123 fn manifest(&self) -> &ProviderManifest {
124 devicerail_manifest()
125 }
126
127 async fn open_session(
128 &self,
129 opts: OpenSessionOptions,
130 ) -> Result<Box<dyn ProviderSession>, ProviderError> {
131 if opts.lockfile_digest != self.lockfile.digest {
134 return Err(ProviderError::new(
135 ErrorClass::CapabilityDrift,
136 format!(
137 "the IR's lockfileDigest {} does not match the held lockfile digest {}; \
138 recompile against the current lockfile",
139 opts.lockfile_digest, self.lockfile.digest
140 ),
141 RetryableSource::Classifier,
142 ));
143 }
144 let spawn = parse_spawn_endpoint(&opts.endpoint)?;
145 let client = spawn_client(&spawn, &opts.required_features).await?;
146 match self.open_inner(&client, &opts).await {
147 Ok(session) => Ok(Box::new(session)),
148 Err(error) => {
149 let _ = client.close().await;
152 Err(error)
153 }
154 }
155 }
156}
157
158async fn spawn_client(
163 spawn: &SpawnSpec,
164 required_features: &[pointlock_ir::FeatureId],
165) -> Result<DeviceRailClient, ProviderError> {
166 let mut config = SpawnConfig::new(&spawn.command, hello_params(required_features));
167 config = config.args(spawn.args.iter());
168 for (key, value) in &spawn.env {
169 config = config.env(key, value);
170 }
171 if let Some(cwd) = &spawn.cwd {
172 config = config.cwd(cwd);
173 }
174 config.client.close_grace = Duration::from_millis(spawn.shutdown_grace_ms.max(1));
177 DeviceRailClient::spawn(config).await.map_err(|error| {
178 crate::error_map::provider_error_from_client(error, "spawn daemon / system.hello")
179 })
180}
181
182async fn connect_device(
188 client: &DeviceRailClient,
189 device_id: &str,
190) -> Result<(DeviceInfo, Vec<ActionDefinition>), ProviderError> {
191 let devices =
192 bounded(client.call::<methods::DevicesList>(methods::NoParams, CallOptions::default()))
193 .await
194 .map_err(|error| error.into_provider_error("devices.list"))?;
195 let wanted = DeviceId::new(device_id);
196 if !devices.devices.iter().any(|device| device.id == wanted) {
197 let known: Vec<String> = devices
198 .devices
199 .iter()
200 .map(|device| device.id.to_string())
201 .collect();
202 return Err(ProviderError::new(
204 ErrorClass::ActionFailedRetryable,
205 format!("device `{device_id}` is not present in devices.list (known: {known:?})"),
206 RetryableSource::Classifier,
207 ));
208 }
209 bounded(client.call::<methods::DeviceSelect>(
210 DeviceSelectParams { device_id: wanted },
211 CallOptions::default(),
212 ))
213 .await
214 .map_err(|error| error.into_provider_error("device.select"))?;
215 let device = bounded(client.call::<methods::DeviceConnect>(
216 methods::NoParams,
217 envelope_options(DEFAULT_CALL_BUDGET_MS),
218 ))
219 .await
220 .map_err(|error| error.into_provider_error("device.connect"))?;
221 let actions = bounded(client.call::<methods::DeviceCapabilities>(
222 methods::NoParams,
223 envelope_options(DEFAULT_CALL_BUDGET_MS),
224 ))
225 .await
226 .map_err(|error| error.into_provider_error("device.capabilities"))?;
227 Ok((device, actions))
228}
229
230pub async fn lock_via_spawn(
234 spawn: &SpawnSpec,
235 device_id: &str,
236) -> Result<CapabilityLockfile, ProviderError> {
237 lock_via_spawn_at(spawn, device_id, now_utc_iso()).await
238}
239
240pub async fn lock_via_spawn_at(
245 spawn: &SpawnSpec,
246 device_id: &str,
247 attested_at: impl Into<String>,
248) -> Result<CapabilityLockfile, ProviderError> {
249 let client = spawn_client(spawn, &[]).await?;
250 let result = async {
251 let hello = client
252 .negotiated_hello()
253 .map_err(|error| crate::error_map::provider_error_from_client(error, "system.hello"))?;
254 let (device, actions) = connect_device(&client, device_id).await?;
255 make_lockfile(
256 lockfile_provider_identity(),
257 attested_at,
258 &hello,
259 &device,
260 &actions,
261 )
262 }
263 .await;
264 let _ = client.close().await;
265 result
266}
267
268fn describe_drift(expected: &CapabilityLockfile, live: &CapabilityLockfile) -> String {
271 let mut notes: Vec<String> = Vec::new();
272 if expected.hello.protocol_selected != live.hello.protocol_selected {
273 notes.push(format!(
274 "protocol {}.{} -> {}.{}",
275 expected.hello.protocol_selected.major,
276 expected.hello.protocol_selected.minor,
277 live.hello.protocol_selected.major,
278 live.hello.protocol_selected.minor,
279 ));
280 }
281 if expected.hello.server != live.hello.server {
282 notes.push(format!(
283 "server {}@{} -> {}@{}",
284 expected.hello.server.name,
285 expected.hello.server.version,
286 live.hello.server.name,
287 live.hello.server.version,
288 ));
289 }
290 let expected_features: BTreeSet<&str> = expected
291 .hello
292 .features_enabled
293 .iter()
294 .map(|feature| feature.as_str())
295 .collect();
296 let live_features: BTreeSet<&str> = live
297 .hello
298 .features_enabled
299 .iter()
300 .map(|feature| feature.as_str())
301 .collect();
302 let missing: Vec<&&str> = expected_features.difference(&live_features).collect();
303 if !missing.is_empty() {
304 notes.push(format!("features lost: {missing:?}"));
305 }
306 let gained: Vec<&&str> = live_features.difference(&expected_features).collect();
307 if !gained.is_empty() {
308 notes.push(format!("features gained: {gained:?}"));
309 }
310 if expected.device.platform != live.device.platform {
311 notes.push(format!(
312 "platform {:?} -> {:?}",
313 expected.device.platform, live.device.platform
314 ));
315 }
316 let expected_actions: BTreeSet<&str> = expected
317 .device
318 .actions
319 .iter()
320 .map(|action| action.name.as_str())
321 .collect();
322 let live_actions: BTreeSet<&str> = live
323 .device
324 .actions
325 .iter()
326 .map(|action| action.name.as_str())
327 .collect();
328 let vanished: Vec<&&str> = expected_actions.difference(&live_actions).collect();
329 if !vanished.is_empty() {
330 notes.push(format!("actions vanished: {vanished:?}"));
331 }
332 let appeared: Vec<&&str> = live_actions.difference(&expected_actions).collect();
333 if !appeared.is_empty() {
334 notes.push(format!("actions appeared: {appeared:?}"));
335 }
336 let changed: Vec<&str> = expected
337 .device
338 .actions
339 .iter()
340 .filter_map(|action| {
341 live.device
342 .actions
343 .iter()
344 .find(|live_action| live_action.name == action.name)
345 .filter(|live_action| *live_action != action)
346 .map(|_| action.name.as_str())
347 })
348 .collect();
349 if !changed.is_empty() {
350 notes.push(format!("action definitions changed: {changed:?}"));
351 }
352 if notes.is_empty() {
353 "no field-level diff detected at summary granularity".to_owned()
356 } else {
357 format!("drift: {}", notes.join("; "))
358 }
359}
360
361#[cfg(test)]
362mod tests {
363 use pointlock_ir::{FeatureId, Hash};
364 use pointlock_provider_kit::lockfile::{
365 LockfileDevice, LockfileHello, LockfileProvider, PeerInfo, ProtocolVersion,
366 };
367 use pointlock_provider_kit::manifest::PlatformKind;
368
369 use super::*;
370
371 fn sample_lockfile(name: &str) -> CapabilityLockfile {
372 let mut lockfile = CapabilityLockfile {
373 provider: LockfileProvider {
374 name: name.to_owned(),
375 version: "0.1.0".to_owned(),
376 },
377 attested_at: "2026-01-01T00:00:00Z".to_owned(),
378 hello: LockfileHello {
379 protocol_selected: ProtocolVersion { major: 1, minor: 5 },
380 features_enabled: vec![FeatureId::new("events.snapshot.v1").unwrap()],
381 server: PeerInfo {
382 name: "devicerail-daemon".to_owned(),
383 version: "0.9.0".to_owned(),
384 },
385 },
386 device: LockfileDevice {
387 platform: PlatformKind::Linux,
388 actions: Vec::new(),
389 },
390 digest: Hash::new(format!("sha256:{}", "0".repeat(64))).unwrap(),
391 };
392 lockfile.seal();
393 lockfile
394 }
395
396 #[test]
397 fn provider_construction_fails_closed_on_bad_lockfiles() {
398 let error = DeviceRailProvider::new(sample_lockfile("fake")).expect_err("name check");
400 assert_eq!(error.error_class, ErrorClass::CapabilityDrift);
401
402 let mut tampered = sample_lockfile(PROVIDER_NAME);
404 tampered.digest = Hash::new(format!("sha256:{}", "f".repeat(64))).unwrap();
405 let error = DeviceRailProvider::new(tampered).expect_err("digest check");
406 assert_eq!(error.error_class, ErrorClass::CapabilityDrift);
407
408 assert!(DeviceRailProvider::new(sample_lockfile(PROVIDER_NAME)).is_ok());
410 }
411
412 #[tokio::test]
413 async fn open_session_rejects_a_foreign_lockfile_digest_before_spawning() {
414 let provider = DeviceRailProvider::new(sample_lockfile(PROVIDER_NAME)).unwrap();
415 let opts = OpenSessionOptions {
416 endpoint: serde_json::json!({ "spawn": { "command": "/nonexistent/devicerail" } }),
418 device_id: "mock-1".to_owned(),
419 required_features: Vec::new(),
420 lockfile_digest: Hash::new(format!("sha256:{}", "e".repeat(64))).unwrap(),
421 };
422 let error = match provider.open_session(opts).await {
423 Err(error) => error,
424 Ok(_) => panic!("the digest gate must fire before spawning"),
425 };
426 assert_eq!(error.error_class, ErrorClass::CapabilityDrift);
427 assert!(error.message.contains("recompile"));
428 }
429
430 #[test]
431 fn drift_description_names_the_differences() {
432 let expected = sample_lockfile(PROVIDER_NAME);
433 let mut live = expected.clone();
434 live.hello.features_enabled = vec![
435 FeatureId::new("events.snapshot.v1").unwrap(),
436 FeatureId::new("media.stream.v1").unwrap(),
437 ];
438 live.hello.server.version = "1.0.0".to_owned();
439 live.seal();
440 let summary = describe_drift(&expected, &live);
441 assert!(summary.contains("features gained"), "{summary}");
442 assert!(summary.contains("media.stream.v1"), "{summary}");
443 assert!(summary.contains("server"), "{summary}");
444 }
445}