Skip to main content

mur_common/agent/
lifecycle.rs

1use super::*;
2
3#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
4pub struct NotificationsConfig {
5    #[serde(default)]
6    pub on_task_complete: Vec<NotificationTarget>,
7    #[serde(default)]
8    pub on_error: Vec<NotificationTarget>,
9    #[serde(default)]
10    pub on_shutdown: Vec<NotificationTarget>,
11}
12
13#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
14#[serde(tag = "target", rename_all = "lowercase")]
15pub enum NotificationTarget {
16    Agent {
17        name: String,
18    },
19    Commander,
20    Email {
21        address: String,
22        #[serde(default)]
23        smtp_config_file: Option<String>,
24    },
25    Slack {
26        #[serde(default)]
27        channel: Option<String>,
28        #[serde(default)]
29        webhook_url_env: Option<String>,
30    },
31    Webpush {
32        url: String,
33    },
34    Webhook {
35        url: String,
36        #[serde(default = "default_post")]
37        method: String,
38        #[serde(default)]
39        auth: Option<String>,
40    },
41}
42fn default_post() -> String {
43    "POST".to_string()
44}
45
46#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
47pub struct RetryConfig {
48    pub llm: RetryPolicy,
49    pub tool: RetryPolicy,
50}
51
52#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
53pub struct RetryPolicy {
54    pub max_retries: u32,
55    pub backoff: BackoffStrategy,
56    pub initial_delay_ms: u64,
57    #[serde(default)]
58    pub max_delay_ms: Option<u64>,
59    #[serde(default)]
60    pub retry_on: Vec<String>,
61}
62
63#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
64#[serde(rename_all = "lowercase")]
65pub enum BackoffStrategy {
66    Linear,
67    Exponential,
68    Fixed,
69}
70
71#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
72pub struct LifecycleConfig {
73    pub restart: RestartPolicy,
74    #[serde(default = "default_max_restarts")]
75    pub max_restarts: u32,
76    #[serde(default = "default_window")]
77    pub restart_window_secs: u64,
78    #[serde(default = "default_stop_timeout")]
79    pub stop_timeout_secs: u64,
80    #[serde(default = "default_mcp_required")]
81    pub mcp_required: bool,
82    #[serde(default)]
83    pub execution: ExecutionMode,
84    #[serde(default)]
85    pub schedule: Vec<ScheduleEntry>,
86    #[serde(default)]
87    pub idle_triggers: Vec<IdleTrigger>,
88}
89fn default_max_restarts() -> u32 {
90    3
91}
92fn default_window() -> u64 {
93    600
94}
95fn default_stop_timeout() -> u64 {
96    15
97}
98fn default_mcp_required() -> bool {
99    true
100}
101
102#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
103#[serde(rename_all = "snake_case")]
104pub enum RestartPolicy {
105    Never,
106    OnFailure,
107    Always,
108}
109
110#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
111#[serde(rename_all = "snake_case")]
112pub enum ExecutionMode {
113    #[default]
114    Daemon,
115    OnDemand,
116}
117
118/// Where an agent leaves a schedule it wants but cannot create.
119///
120/// An agent's schedules live in `lifecycle.schedule` inside its own
121/// `profile.yaml`, and an agent may not write that file — the sandbox denies it
122/// unconditionally so a running agent cannot widen its own entitlements and
123/// restart into them. So "remind me at 10 tomorrow" cannot become a schedule
124/// from the inside, however much the agent understands the request.
125///
126/// It becomes a proposal instead: a file in the agent's own home, which it may
127/// write, that `mur agent schedule accept` turns into the real entry.
128///
129/// Public and shared because both halves must name the same directory. Two
130/// spellings would not fail loudly — the agent would write proposals nobody
131/// lists, which is the shape of failure this whole area keeps producing.
132pub const SCHEDULE_PROPOSAL_DIR: &str = "schedule-proposals";
133
134/// File in the agent's home holding the id of the channel a fired schedule
135/// leaves its reply in. One stable channel per agent, remembered rather than
136/// re-derived (#1125).
137pub const SCHEDULE_CHANNEL_FILE: &str = "schedule-channel";
138
139/// Marker file in the agent's home naming the channel that records chat-gate
140/// decisions (`HitlResponse` events keyed by `action_hash`). Same shape as
141/// `SCHEDULE_CHANNEL_FILE`: created on first use, replaced if it names a
142/// channel that no longer loads.
143pub const HITL_CHANNEL_FILE: &str = "hitl-channel";
144
145/// Directory in the agent's home holding the signed head of the decision
146/// channel (`<agent_home>/hitl-heads/<channel_id>.head`, #1859). It is how gate
147/// B tells "a record was written and then deleted" from "never written".
148pub const HITL_HEAD_DIR: &str = "hitl-heads";
149
150/// A schedule an agent asked for and a person has not yet granted.
151#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
152pub struct ScheduleProposal {
153    pub cron: String,
154    pub message: String,
155    /// What the user actually said, kept verbatim: a cron expression is not
156    /// reviewable on its own, and the reviewer is being asked whether this is
157    /// what they meant.
158    #[serde(default, skip_serializing_if = "Option::is_none")]
159    pub asked_for: Option<String>,
160    /// Proposed bound, carried verbatim onto the accepted [`ScheduleEntry`].
161    /// Present exactly when the agent judged the request to name one occasion
162    /// rather than a recurrence.
163    #[serde(default, skip_serializing_if = "Option::is_none")]
164    pub not_after: Option<String>,
165    pub proposed_at: String,
166}
167
168#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
169pub struct ScheduleEntry {
170    pub cron: String,
171    pub message: String,
172    #[serde(default, skip_serializing_if = "Option::is_none")]
173    pub sends_to: Option<String>,
174    /// Retire the entry once its next firing would fall after this instant
175    /// (RFC3339 with offset). How a one-shot reminder is expressed: cron has no
176    /// year field, so "tomorrow at 10:00" can only be written as an annual
177    /// recurrence, and unbounded it turns a request for one morning into a
178    /// perpetual commitment (#1119).
179    ///
180    /// A bound rather than a fired-yet flag, because the scheduler runs inside
181    /// the agent's own sandbox where `profile.yaml` is denied
182    /// (`SELF_PROTECTED_AGENT_FILES`, #712) — it cannot record that an entry has
183    /// fired. Comparing the next firing against a stored instant needs no write
184    /// at all, so the bound works where a flag structurally could not.
185    #[serde(default, skip_serializing_if = "Option::is_none")]
186    pub not_after: Option<String>,
187}
188
189#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
190pub struct IdleTrigger {
191    /// Idle threshold in seconds. Fires when (now - last_activity) >= after_secs.
192    pub after_secs: u64,
193    /// Message body injected into the task runner when this trigger fires.
194    pub message: String,
195    /// Optional A2A peer to route the resulting reply to. None means the agent itself.
196    #[serde(default, skip_serializing_if = "Option::is_none")]
197    pub sends_to: Option<String>,
198    /// Per-trigger refire cooldown in seconds. Prevents tight loops when the
199    /// idle threshold is short and the runner finishes quickly. Default 600.
200    #[serde(default = "default_idle_cooldown")]
201    pub cooldown_secs: u64,
202    /// When true, suppress firing during the agent's quiet-hours window.
203    /// Default true — idle pings should not wake the user at 3 a.m.
204    #[serde(default = "default_true")]
205    pub respect_quiet_hours: bool,
206}
207
208fn default_idle_cooldown() -> u64 {
209    600
210}
211/// True if `name` is not present in a denylist (i.e. enabled).
212pub fn name_enabled(denylist: &[String], name: &str) -> bool {
213    !denylist.iter().any(|n| n == name)
214}
215
216/// Add/remove `name` in a denylist. `enabled=true` removes it (idempotent),
217/// `enabled=false` adds it once (idempotent).
218pub fn set_denylist(list: &mut Vec<String>, name: &str, enabled: bool) {
219    if enabled {
220        list.retain(|n| n != name);
221    } else if !list.iter().any(|n| n == name) {
222        list.push(name.to_string());
223    }
224}
225
226#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
227pub struct FileTransferConfig {
228    #[serde(default = "default_accept_max")]
229    pub accept_incoming_file_max_bytes: u64,
230    #[serde(default = "default_accept_total")]
231    pub accept_incoming_total_per_hour: u64,
232    #[serde(default = "default_approval_threshold")]
233    pub require_approval_above_bytes: u64,
234    #[serde(default = "default_reject_paths")]
235    pub reject_paths: Vec<String>,
236    #[serde(default = "default_allowed_mime")]
237    pub allowed_mime_types: Vec<String>,
238}
239
240impl Default for FileTransferConfig {
241    fn default() -> Self {
242        Self {
243            accept_incoming_file_max_bytes: default_accept_max(),
244            accept_incoming_total_per_hour: default_accept_total(),
245            require_approval_above_bytes: default_approval_threshold(),
246            reject_paths: default_reject_paths(),
247            allowed_mime_types: default_allowed_mime(),
248        }
249    }
250}
251
252fn default_accept_max() -> u64 {
253    10_485_760
254}
255fn default_accept_total() -> u64 {
256    104_857_600
257}
258fn default_approval_threshold() -> u64 {
259    10_485_760
260}
261fn default_reject_paths() -> Vec<String> {
262    vec!["~/.ssh".into(), "~/.aws".into(), "~/.gnupg".into()]
263}
264fn default_allowed_mime() -> Vec<String> {
265    vec!["*".into()]
266}
267
268#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
269#[serde(rename_all = "snake_case")]
270pub enum DeploymentType {
271    #[default]
272    Laptop,
273    Vm,
274    Docker,
275    K8s,
276    Lambda,
277}
278
279#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
280pub struct DeploymentConfig {
281    #[serde(rename = "type", default)]
282    pub deployment_type: DeploymentType,
283    #[serde(default, skip_serializing_if = "Option::is_none")]
284    pub region: Option<String>,
285    #[serde(default = "default_env")]
286    pub environment: Option<String>,
287}
288
289impl Default for DeploymentConfig {
290    fn default() -> Self {
291        Self {
292            deployment_type: DeploymentType::default(),
293            region: None,
294            environment: default_env(),
295        }
296    }
297}
298
299fn default_env() -> Option<String> {
300    Some("dev".into())
301}
302
303/// One filesystem grant the sandbox refused to install, and why.
304///
305/// The grant stays in `profile.yaml` — this records that it did not reach the
306/// kernel, which is otherwise knowable only from a WARN line in a log nobody
307/// queries.
308#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
309pub struct DroppedGrant {
310    pub path: String,
311    /// `"read"` or `"write"`.
312    pub verb: String,
313    pub reason: String,
314}
315
316/// Digest of the filesystem half of a profile's entitlements.
317///
318/// Narrower than `card_digest` on purpose: that one moves whenever any profile
319/// field does, so using it to flag "grants changed since this agent started"
320/// would raise a false alarm on an unrelated edit — and a status line that
321/// cries wolf is one people stop reading.
322pub fn filesystem_grants_digest(fs: &FilesystemEntitlement) -> String {
323    use sha2::{Digest, Sha256};
324    let mut h = Sha256::new();
325    for (label, list) in [("r", &fs.read), ("w", &fs.write), ("d", &fs.deny)] {
326        let mut sorted = list.clone();
327        sorted.sort();
328        for p in sorted {
329            h.update(label.as_bytes());
330            h.update(b"\0");
331            h.update(p.as_bytes());
332            h.update(b"\0");
333        }
334    }
335    format!("sha256:{:x}", h.finalize())
336}
337
338/// What the sandbox actually installed, recorded at the moment it sealed.
339///
340/// A seatbelt profile cannot be widened after `sandbox_init`, so this is fixed
341/// for the process's lifetime — the same lifetime as the lock file it rides in.
342/// Without it, `profile.yaml` is the only readable account of an agent's
343/// permissions, and it describes what was asked for rather than what took
344/// effect.
345#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
346pub struct SandboxRecord {
347    /// False means the kernel sandbox is NOT installed and only advisory hooks
348    /// remain — the agent then has MORE access than its profile grants, which
349    /// is the opposite of every other failure here and the one worth shouting.
350    pub enforcing: bool,
351    /// `"macos-sbpl"`, `"linux-landlock"`, `"advisory-only"`, …
352    pub mode: String,
353    /// Digest of `entitlements.filesystem` as sealed. Comparing it against the
354    /// profile on disk answers "were grants changed since this agent started"
355    /// without anyone tracking that — and unlike `card_digest` it does not move
356    /// when an unrelated field does, so it cannot raise a false alarm.
357    pub granted_digest: String,
358    /// Grants that did not reach the kernel. Empty is the normal case.
359    #[serde(default)]
360    pub dropped: Vec<DroppedGrant>,
361}
362
363#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
364pub struct LockFile {
365    pub schema: u32,
366    pub uuid: String,
367    pub name: String,
368    pub pid: u32,
369    pub ppid: u32,
370    pub started_at: String,
371    pub binary_version: String,
372    pub transports: LockTransports,
373    pub card_digest: String,
374    pub capabilities: Vec<String>,
375    /// Git sha the running binary was built from (mur_common::build::SHORT_SHA).
376    /// Empty = an old lock predating this field. Drives stale detection.
377    #[serde(default)]
378    pub build_sha: String,
379    /// A2A method-surface version this runtime supports (A2A_PROTO_VERSION).
380    /// 0 = an old lock; the dial gates versioned methods on it.
381    #[serde(default)]
382    pub proto_version: u32,
383    /// What the sandbox installed at seal time. `None` = a lock written before
384    /// this field existed, or a platform that installs no sandbox.
385    #[serde(default, skip_serializing_if = "Option::is_none")]
386    pub sandbox: Option<SandboxRecord>,
387}
388
389#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
390pub struct LockTransports {
391    pub stdio: bool,
392    #[serde(default)]
393    pub unix_socket: Option<String>,
394    #[serde(default)]
395    pub tcp: Option<String>,
396    /// C5 / M5.3 — webhook listener URL (e.g. `http://127.0.0.1:6789`).
397    /// Populated by the supervisor when `transport.webhook.enabled =
398    /// true` so peers and the commander can discover the live
399    /// endpoint without re-reading `profile.yaml`.
400    #[serde(default)]
401    pub webhook: Option<String>,
402}
403
404#[cfg(test)]
405mod tests {
406    /// Order must not matter: the same grants written in a different order are
407    /// the same grants, and a digest that disagreed would report "restart to
408    /// apply" after a cosmetic profile edit.
409    #[test]
410    fn grants_digest_ignores_order_but_not_content() {
411        let a = FilesystemEntitlement {
412            read: vec!["/a".into(), "/b".into()],
413            write: vec!["/w".into()],
414            deny: vec![],
415        };
416        let reordered = FilesystemEntitlement {
417            read: vec!["/b".into(), "/a".into()],
418            ..a.clone()
419        };
420        let changed = FilesystemEntitlement {
421            write: vec!["/w".into(), "/x".into()],
422            ..a.clone()
423        };
424        assert_eq!(
425            filesystem_grants_digest(&a),
426            filesystem_grants_digest(&reordered)
427        );
428        assert_ne!(
429            filesystem_grants_digest(&a),
430            filesystem_grants_digest(&changed)
431        );
432    }
433
434    /// A read grant and a write grant for the same path are different grants.
435    #[test]
436    fn grants_digest_separates_the_verbs() {
437        let r = FilesystemEntitlement {
438            read: vec!["/p".into()],
439            write: vec![],
440            deny: vec![],
441        };
442        let w = FilesystemEntitlement {
443            read: vec![],
444            write: vec!["/p".into()],
445            deny: vec![],
446        };
447        assert_ne!(filesystem_grants_digest(&r), filesystem_grants_digest(&w));
448    }
449
450    /// A lock written before this field existed must still load — every agent
451    /// running at upgrade time wrote one.
452    #[test]
453    fn a_lock_without_the_sandbox_block_still_deserialises() {
454        let old = r#"{"schema":1,"uuid":"u","name":"n","pid":1,"ppid":0,
455            "started_at":"t","binary_version":"v",
456            "transports":{"stdio":true},"card_digest":"d","capabilities":[]}"#;
457        let lf: LockFile = serde_json::from_str(old).expect("old lock must load");
458        assert!(lf.sandbox.is_none());
459    }
460
461    #[test]
462    fn the_sandbox_block_round_trips() {
463        let rec = SandboxRecord {
464            enforcing: false,
465            mode: "advisory-only".into(),
466            granted_digest: "sha256:x".into(),
467            dropped: vec![DroppedGrant {
468                path: "/gone".into(),
469                verb: "write".into(),
470                reason: "path does not exist on disk".into(),
471            }],
472        };
473        let back: SandboxRecord =
474            serde_json::from_str(&serde_json::to_string(&rec).unwrap()).unwrap();
475        assert_eq!(back, rec);
476    }
477
478    use super::*;
479
480    #[test]
481    fn broad_audited_mcp_net_serde_roundtrip_and_defaults() {
482        let net = McpServerNetwork {
483            mode: McpNetMode::BroadAudited,
484            allow_hosts: vec![],
485            deny_hosts: vec!["evil.example".into()],
486            authorization: Some(EgressAuthorization {
487                authorized_by: "david".into(),
488                authorized_at_ms: 1_750_000_000_000,
489            }),
490        };
491        let y = serde_yaml::to_string(&net).unwrap();
492        assert!(y.contains("broad_audited"));
493        let back: McpServerNetwork = serde_yaml::from_str(&y).unwrap();
494        assert_eq!(back, net);
495        // legacy per-server policy without the new fields still parses (serde default)
496        let legacy: McpServerNetwork =
497            serde_yaml::from_str("mode: restricted\nallow_hosts: []\n").unwrap();
498        assert_eq!(legacy.deny_hosts, Vec::<String>::new());
499        assert!(legacy.authorization.is_none());
500    }
501
502    #[test]
503    fn mcp_entry_network_is_optional_and_round_trips() {
504        // Absent in YAML → None (every existing profile keeps working).
505        let bare = "name: x\ncommand: npx\n";
506        let e: McpServerEntry = serde_yaml_ng::from_str(bare).unwrap();
507        assert!(e.network.is_none());
508
509        // Present → parsed.
510        let with = "name: browser\ncommand: npx\nnetwork:\n  mode: restricted\n  allow_hosts: [\"example.com\", \"*.api.example.com\"]\n";
511        let e2: McpServerEntry = serde_yaml_ng::from_str(with).unwrap();
512        let net = e2.network.expect("network present");
513        assert_eq!(net.mode, McpNetMode::Restricted);
514        assert_eq!(net.allow_hosts, vec!["example.com", "*.api.example.com"]);
515
516        // Round-trip keeps None out of the serialized form.
517        let out = serde_yaml_ng::to_string(&e).unwrap();
518        assert!(!out.contains("network"));
519    }
520
521    #[test]
522    fn profile_round_trip_yaml() {
523        let yaml = r#"
524schema: 1
525id: 01JQX4TM8Y9K7VQH6B2N3R5DPE
526name: agent_a
527display_name: "Price Hunter"
528version: "0.1.0"
529persona:
530  category: research
531  description: "Finds prices"
532  traits: { tone: concise, risk: cautious, verbosity: low }
533sys_prompt_file: "sys_prompt.md"
534model: { provider: ollama, name: "llama3.2:3b", params: { temperature: 0.2, max_tokens: 4096 } }
535mcp_servers: []
536skills: []
537transport:
538  stdio: true
539  socket: { enabled: true, bind: "unix:///tmp/a.sock" }
540communication: { accepts_from: ["*"], sends_to: [] }
541capabilities: ["a2a.message.send", "a2a.tasks"]
542entitlements:
543  network:
544    inbound: { ports: [] }
545    outbound: { mode: restricted, allow_hosts: [], protocols: ["tcp"], resolve_dns: { mode: system } }
546  filesystem: { read: [], write: [], deny: [] }
547  processes: { spawn: { mode: allowlist, allowed: [] } }
548  syscalls: { mode: default }
549  limits: { memory_mb: 512, file_descriptors: 1024, processes: 32 }
550notifications: { on_task_complete: [], on_error: [], on_shutdown: [] }
551retry:
552  llm: { max_retries: 3, backoff: exponential, initial_delay_ms: 1000, max_delay_ms: 30000, retry_on: [rate_limit, timeout, connection_error] }
553  tool: { max_retries: 1, backoff: fixed, initial_delay_ms: 500 }
554lifecycle: { restart: on_failure, max_restarts: 3, restart_window_secs: 600, stop_timeout_secs: 15, mcp_required: true }
555created_at: "2026-04-22T10:00:00+08:00"
556updated_at: "2026-04-22T10:00:00+08:00"
557"#;
558        let profile: AgentProfile = serde_yaml_ng::from_str(yaml).expect("parse");
559        assert_eq!(profile.name, "agent_a");
560        assert_eq!(profile.persona.category, PersonaCategory::Research);
561        assert_eq!(
562            profile.entitlements.network.outbound.mode,
563            NetworkOutboundMode::Restricted
564        );
565        let reserialized = serde_yaml_ng::to_string(&profile).expect("emit");
566        let round_tripped: AgentProfile = serde_yaml_ng::from_str(&reserialized).expect("re-parse");
567        assert_eq!(profile.id, round_tripped.id);
568    }
569
570    #[test]
571    fn requires_capabilities_defaults_empty_and_round_trips() {
572        let base = include_str!("../../tests/fixtures/profile_p0a_minimal.yaml");
573        let p: AgentProfile = serde_yaml_ng::from_str(base).unwrap();
574        assert!(p.requires_capabilities.is_empty());
575        let with = format!("{base}\nrequires_capabilities:\n  - media\n");
576        let p2: AgentProfile = serde_yaml_ng::from_str(&with).unwrap();
577        assert_eq!(p2.requires_capabilities, vec!["media"]);
578    }
579}
580
581#[cfg(test)]
582mod idle_trigger_tests {
583    use super::*;
584
585    #[test]
586    fn idle_trigger_yaml_round_trip() {
587        let yaml = r#"
588restart: on_failure
589idle_triggers:
590  - after_secs: 3600
591    message: "still there?"
592    sends_to: other_agent
593    cooldown_secs: 1800
594    respect_quiet_hours: true
595"#;
596        let cfg: LifecycleConfig = serde_yaml_ng::from_str(yaml).unwrap();
597        assert_eq!(cfg.idle_triggers.len(), 1);
598        assert_eq!(cfg.idle_triggers[0].after_secs, 3600);
599        assert_eq!(cfg.idle_triggers[0].message, "still there?");
600        assert_eq!(
601            cfg.idle_triggers[0].sends_to.as_deref(),
602            Some("other_agent")
603        );
604        assert_eq!(cfg.idle_triggers[0].cooldown_secs, 1800);
605        assert!(cfg.idle_triggers[0].respect_quiet_hours);
606    }
607
608    #[test]
609    fn idle_trigger_defaults_when_omitted() {
610        let yaml = "restart: on_failure\n";
611        let cfg: LifecycleConfig = serde_yaml_ng::from_str(yaml).unwrap();
612        assert!(cfg.idle_triggers.is_empty());
613    }
614}
615
616#[cfg(test)]
617mod lockfile_compat_tests {
618    use super::*;
619
620    #[test]
621    fn lockfile_new_fields_default_for_old_locks() {
622        // An old lock JSON without build_sha/proto_version must still parse,
623        // defaulting to "" / 0 (= "predates this feature → stale/unsupported").
624        let old = r#"{"schema":1,"uuid":"u","name":"a","pid":1,"ppid":1,
625          "started_at":"t","binary_version":"mur-agent-runtime 2.26.9",
626          "transports":{"stdio":true},"card_digest":"d","capabilities":[]}"#;
627        let lock: LockFile = serde_json::from_str(old).unwrap();
628        assert_eq!(lock.build_sha, "");
629        assert_eq!(lock.proto_version, 0);
630    }
631}